mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-11 08:42:11 +00:00
Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
commit
12307a66e0
58 changed files with 6334 additions and 277 deletions
|
|
@ -17,6 +17,7 @@ logger = logging.getLogger(__name__)
|
|||
# so silent-drops (e.g. OpenRouter 402 exhausting the fallback chain)
|
||||
# become visible instead of piling up as NULL session titles.
|
||||
FailureCallback = Callable[[str, BaseException], None]
|
||||
TitleCallback = Callable[[str], None]
|
||||
|
||||
_TITLE_PROMPT = (
|
||||
"Generate a short, descriptive title (3-7 words) for a conversation that starts with the "
|
||||
|
|
@ -90,6 +91,7 @@ def auto_title_session(
|
|||
assistant_response: str,
|
||||
failure_callback: Optional[FailureCallback] = None,
|
||||
main_runtime: dict = None,
|
||||
title_callback: Optional[TitleCallback] = None,
|
||||
) -> None:
|
||||
"""Generate and set a session title if one doesn't already exist.
|
||||
|
||||
|
|
@ -119,6 +121,11 @@ def auto_title_session(
|
|||
try:
|
||||
session_db.set_session_title(session_id, title)
|
||||
logger.debug("Auto-generated session title: %s", title)
|
||||
if title_callback is not None:
|
||||
try:
|
||||
title_callback(title)
|
||||
except Exception:
|
||||
logger.debug("Auto-title callback failed", exc_info=True)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to set auto-generated title: %s", e)
|
||||
|
||||
|
|
@ -131,6 +138,7 @@ def maybe_auto_title(
|
|||
conversation_history: list,
|
||||
failure_callback: Optional[FailureCallback] = None,
|
||||
main_runtime: dict = None,
|
||||
title_callback: Optional[TitleCallback] = None,
|
||||
) -> None:
|
||||
"""Fire-and-forget title generation after the first exchange.
|
||||
|
||||
|
|
@ -152,7 +160,11 @@ def maybe_auto_title(
|
|||
thread = threading.Thread(
|
||||
target=auto_title_session,
|
||||
args=(session_db, session_id, user_message, assistant_response),
|
||||
kwargs={"failure_callback": failure_callback, "main_runtime": main_runtime},
|
||||
kwargs={
|
||||
"failure_callback": failure_callback,
|
||||
"main_runtime": main_runtime,
|
||||
"title_callback": title_callback,
|
||||
},
|
||||
daemon=True,
|
||||
name="auto-title",
|
||||
)
|
||||
|
|
|
|||
93
cli.py
93
cli.py
|
|
@ -10483,7 +10483,98 @@ class HermesCLI:
|
|||
else:
|
||||
self._should_exit = True
|
||||
event.app.exit()
|
||||
|
||||
|
||||
@kb.add('c-S-c') # Ctrl+Shift+C
|
||||
def handle_ctrl_shift_c(event):
|
||||
"""Copy text to clipboard (terminal-native).
|
||||
|
||||
This is a no-op at the application level. Terminal emulators
|
||||
handle the actual copy operation when Ctrl+Shift+C is pressed.
|
||||
This binding prevents Hermes from intercepting the keystroke
|
||||
as an interrupt signal.
|
||||
|
||||
On macOS the standard copy shortcut is Cmd+C (no Hermes binding
|
||||
needed). On Linux/Windows Ctrl+Shift+C is the conventional
|
||||
terminal copy shortcut.
|
||||
"""
|
||||
return # No-op — let the terminal perform native copy
|
||||
|
||||
@kb.add('c-q') # Ctrl+Q
|
||||
def handle_ctrl_q(event):
|
||||
"""Alternative interrupt/exit shortcut (Ctrl+Q).
|
||||
|
||||
Behaves like Ctrl+C: cancels active prompts, interrupts the
|
||||
running agent, or clears the input buffer. Does not support
|
||||
the double-press 'force exit' feature of Ctrl+C.
|
||||
"""
|
||||
# Cancel active voice recording.
|
||||
_should_cancel_voice = False
|
||||
_recorder_ref = None
|
||||
with cli_ref._voice_lock:
|
||||
if cli_ref._voice_recording and cli_ref._voice_recorder:
|
||||
_recorder_ref = cli_ref._voice_recorder
|
||||
cli_ref._voice_recording = False
|
||||
cli_ref._voice_continuous = False
|
||||
_should_cancel_voice = True
|
||||
if _should_cancel_voice:
|
||||
_cprint(f"\n{_DIM}Recording cancelled.{_RST}")
|
||||
threading.Thread(
|
||||
target=_recorder_ref.cancel, daemon=True
|
||||
).start()
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Cancel sudo prompt
|
||||
if self._sudo_state:
|
||||
self._sudo_state["response_queue"].put("")
|
||||
self._sudo_state = None
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Cancel secret prompt
|
||||
if self._secret_state:
|
||||
self._cancel_secret_capture()
|
||||
event.app.current_buffer.reset()
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Cancel approval prompt (deny)
|
||||
if self._approval_state:
|
||||
self._approval_state["response_queue"].put("deny")
|
||||
self._approval_state = None
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Cancel /model picker
|
||||
if self._model_picker_state:
|
||||
self._close_model_picker()
|
||||
event.app.current_buffer.reset()
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Cancel clarify prompt
|
||||
if self._clarify_state:
|
||||
self._clarify_state["response_queue"].put(
|
||||
"The user cancelled. Use your best judgement to proceed."
|
||||
)
|
||||
self._clarify_state = None
|
||||
self._clarify_freetext = False
|
||||
event.app.current_buffer.reset()
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
if self._agent_running and self.agent:
|
||||
print("\n⚡ Interrupting agent...")
|
||||
self.agent.interrupt()
|
||||
else:
|
||||
if event.app.current_buffer.text or self._attached_images:
|
||||
event.app.current_buffer.reset()
|
||||
self._attached_images.clear()
|
||||
event.app.invalidate()
|
||||
else:
|
||||
self._should_exit = True
|
||||
event.app.exit()
|
||||
|
||||
@kb.add('c-d')
|
||||
def handle_ctrl_d(event):
|
||||
"""Ctrl+D: delete char under cursor (standard readline behaviour).
|
||||
|
|
|
|||
43
cron/jobs.py
43
cron/jobs.py
|
|
@ -420,7 +420,7 @@ def _normalize_workdir(workdir: Optional[str]) -> Optional[str]:
|
|||
|
||||
|
||||
def create_job(
|
||||
prompt: str,
|
||||
prompt: Optional[str],
|
||||
schedule: str,
|
||||
name: Optional[str] = None,
|
||||
repeat: Optional[int] = None,
|
||||
|
|
@ -435,12 +435,14 @@ def create_job(
|
|||
context_from: Optional[Union[str, List[str]]] = None,
|
||||
enabled_toolsets: Optional[List[str]] = None,
|
||||
workdir: Optional[str] = None,
|
||||
no_agent: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new cron job.
|
||||
|
||||
Args:
|
||||
prompt: The prompt to run (must be self-contained, or a task instruction when skill is set)
|
||||
prompt: The prompt to run (must be self-contained, or a task instruction when skill is set).
|
||||
Ignored when ``no_agent=True`` except as an optional name hint.
|
||||
schedule: Schedule string (see parse_schedule)
|
||||
name: Optional friendly name
|
||||
repeat: How many times to run (None = forever, 1 = once)
|
||||
|
|
@ -451,21 +453,33 @@ def create_job(
|
|||
model: Optional per-job model override
|
||||
provider: Optional per-job provider override
|
||||
base_url: Optional per-job base URL override
|
||||
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.
|
||||
script: Optional path to a script whose stdout feeds the job. With
|
||||
``no_agent=True`` the script IS the job — its stdout is
|
||||
delivered verbatim. Without ``no_agent``, its stdout is
|
||||
injected into the agent's prompt as context (data-collection /
|
||||
change-detection pattern). Paths resolve under
|
||||
~/.hermes/scripts/; ``.sh`` / ``.bash`` files run via bash,
|
||||
anything else via Python.
|
||||
context_from: Optional job ID (or list of job IDs) whose most recent output
|
||||
is injected into the prompt as context before each run.
|
||||
Useful for chaining cron jobs: job A finds data, job B processes it.
|
||||
enabled_toolsets: Optional list of toolset names to restrict the agent to.
|
||||
When set, only tools from these toolsets are loaded, reducing
|
||||
token overhead. When omitted, all default tools are loaded.
|
||||
Ignored when ``no_agent=True``.
|
||||
workdir: Optional absolute path. When set, the job runs as if launched
|
||||
from that directory: AGENTS.md / CLAUDE.md / .cursorrules from
|
||||
that directory are injected into the system prompt, and the
|
||||
terminal/file/code_exec tools use it as their working directory
|
||||
(via TERMINAL_CWD). When unset, the old behaviour is preserved
|
||||
(no context files injected, tools use the scheduler's cwd).
|
||||
With ``no_agent=True``, ``workdir`` is still applied as the
|
||||
script's cwd so relative paths inside the script behave
|
||||
predictably.
|
||||
no_agent: When True, skip the agent entirely — run ``script`` on schedule
|
||||
and deliver its stdout directly. Empty stdout = silent (no
|
||||
delivery). Requires ``script`` to be set. Ideal for classic
|
||||
watchdogs and periodic alerts that don't need LLM reasoning.
|
||||
|
||||
Returns:
|
||||
The created job dict
|
||||
|
|
@ -499,6 +513,16 @@ def create_job(
|
|||
normalized_toolsets = [str(t).strip() for t in enabled_toolsets if str(t).strip()] if enabled_toolsets else None
|
||||
normalized_toolsets = normalized_toolsets or None
|
||||
normalized_workdir = _normalize_workdir(workdir)
|
||||
normalized_no_agent = bool(no_agent)
|
||||
|
||||
# no_agent jobs are meaningless without a script — the script IS the job.
|
||||
# Surface this as a clear ValueError at create time so bad configs never
|
||||
# reach the scheduler.
|
||||
if normalized_no_agent and not normalized_script:
|
||||
raise ValueError(
|
||||
"no_agent=True requires a script — with no agent and no script "
|
||||
"there is nothing for the job to run."
|
||||
)
|
||||
|
||||
# Normalize context_from: accept str or list of str, store as list or None
|
||||
if isinstance(context_from, str):
|
||||
|
|
@ -508,7 +532,7 @@ def create_job(
|
|||
else:
|
||||
context_from = None
|
||||
|
||||
label_source = (prompt or (normalized_skills[0] if normalized_skills else None)) or "cron job"
|
||||
label_source = (prompt or (normalized_skills[0] if normalized_skills else None) or (normalized_script if normalized_no_agent else None)) or "cron job"
|
||||
job = {
|
||||
"id": job_id,
|
||||
"name": name or label_source[:50].strip(),
|
||||
|
|
@ -519,6 +543,7 @@ def create_job(
|
|||
"provider": normalized_provider,
|
||||
"base_url": normalized_base_url,
|
||||
"script": normalized_script,
|
||||
"no_agent": normalized_no_agent,
|
||||
"context_from": context_from,
|
||||
"schedule": parsed_schedule,
|
||||
"schedule_display": parsed_schedule.get("display", schedule),
|
||||
|
|
@ -785,6 +810,12 @@ def get_due_jobs() -> List[Dict[str, Any]]:
|
|||
the job is fast-forwarded to the next future run instead of firing
|
||||
immediately. This prevents a burst of missed jobs on gateway restart.
|
||||
"""
|
||||
with _jobs_file_lock:
|
||||
return _get_due_jobs_locked()
|
||||
|
||||
|
||||
def _get_due_jobs_locked() -> List[Dict[str, Any]]:
|
||||
"""Inner implementation of get_due_jobs(); must be called with _jobs_file_lock held."""
|
||||
now = _hermes_now()
|
||||
raw_jobs = load_jobs()
|
||||
jobs = [_apply_skill_fields(j) for j in copy.deepcopy(raw_jobs)]
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ from typing import List, Optional
|
|||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.config import load_config, _expand_env_vars
|
||||
from hermes_time import now as _hermes_now
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -576,8 +576,18 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
|
|||
prevent arbitrary script execution via path traversal or absolute
|
||||
path injection.
|
||||
|
||||
Supported interpreters (chosen by file extension):
|
||||
|
||||
* ``.sh`` / ``.bash`` — run with ``/bin/bash``
|
||||
* anything else — run with the current Python interpreter
|
||||
(``sys.executable``), preserving the original behaviour for
|
||||
Python-based pre-check and data-collection scripts.
|
||||
|
||||
Shell support lets ``no_agent=True`` jobs ship classic bash watchdogs
|
||||
(the `memory-watchdog.sh` pattern) without wrapping them in Python.
|
||||
|
||||
Args:
|
||||
script_path: Path to a Python script. Relative paths are resolved
|
||||
script_path: Path to the script. Relative paths are resolved
|
||||
against HERMES_HOME/scripts/. Absolute and ~-prefixed paths
|
||||
are also validated to ensure they stay within the scripts dir.
|
||||
|
||||
|
|
@ -614,9 +624,19 @@ def _run_job_script(script_path: str) -> tuple[bool, str]:
|
|||
|
||||
script_timeout = _get_script_timeout()
|
||||
|
||||
# Pick an interpreter by extension. Bash for .sh/.bash, Python for
|
||||
# everything else. We deliberately do NOT honour the file's own
|
||||
# shebang: the scripts dir is trusted, but keeping the interpreter
|
||||
# choice explicit here keeps the allowed surface small and auditable.
|
||||
suffix = path.suffix.lower()
|
||||
if suffix in (".sh", ".bash"):
|
||||
argv = ["/bin/bash", str(path)]
|
||||
else:
|
||||
argv = [sys.executable, str(path)]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(path)],
|
||||
argv,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=script_timeout,
|
||||
|
|
@ -830,8 +850,120 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
|||
Returns:
|
||||
Tuple of (success, full_output_doc, final_response, error_message)
|
||||
"""
|
||||
job_id = job["id"]
|
||||
job_name = job["name"]
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# no_agent short-circuit — the script IS the job, no LLM involvement.
|
||||
# ---------------------------------------------------------------
|
||||
# This mirrors the classic "run a bash script on a timer, send its
|
||||
# stdout to telegram" watchdog pattern. The agent path is skipped
|
||||
# entirely: no AIAgent, no prompt, no tool loop, no token spend.
|
||||
#
|
||||
# We check this BEFORE importing run_agent / constructing SessionDB so
|
||||
# a pure-script tick never pays for the agent machinery it isn't going
|
||||
# to use. Keep this block self-contained.
|
||||
#
|
||||
# Semantics:
|
||||
# - script stdout (trimmed) → delivered verbatim as the final message
|
||||
# - empty stdout → silent run (no delivery, success=True)
|
||||
# - non-zero exit / timeout → delivered as an error alert, success=False
|
||||
# - wakeAgent=false gate → treated like empty stdout (silent), since
|
||||
# the whole point of no_agent is that there
|
||||
# is no agent to wake
|
||||
if job.get("no_agent"):
|
||||
script_path = job.get("script")
|
||||
if not script_path:
|
||||
err = "no_agent=True but no script is set for this job"
|
||||
logger.error("Job '%s': %s", job_id, err)
|
||||
return False, "", "", err
|
||||
|
||||
# Apply workdir if configured — lets scripts use predictable relative
|
||||
# paths. For no_agent jobs this is just the subprocess cwd (not an
|
||||
# agent TERMINAL_CWD bridge).
|
||||
_job_workdir = (job.get("workdir") or "").strip() or None
|
||||
_prior_cwd = None
|
||||
if _job_workdir and Path(_job_workdir).is_dir():
|
||||
_prior_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(_job_workdir)
|
||||
except OSError:
|
||||
_prior_cwd = None
|
||||
|
||||
try:
|
||||
ok, output = _run_job_script(script_path)
|
||||
finally:
|
||||
if _prior_cwd is not None:
|
||||
try:
|
||||
os.chdir(_prior_cwd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
now_iso = _hermes_now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
if not ok:
|
||||
# Script crashed / timed out / exited non-zero. Deliver the
|
||||
# error so the user knows the watchdog itself broke — silent
|
||||
# failure for an alerting job is the worst-case outcome.
|
||||
alert = (
|
||||
f"⚠ Cron watchdog '{job_name}' script failed\n\n"
|
||||
f"{output}\n\n"
|
||||
f"Time: {now_iso}"
|
||||
)
|
||||
doc = (
|
||||
f"# Cron Job: {job_name}\n\n"
|
||||
f"**Job ID:** {job_id}\n"
|
||||
f"**Run Time:** {now_iso}\n"
|
||||
f"**Mode:** no_agent (script)\n"
|
||||
f"**Status:** script failed\n\n"
|
||||
f"{output}\n"
|
||||
)
|
||||
return False, doc, alert, output
|
||||
|
||||
# Honour the wakeAgent gate as a silent signal — `wakeAgent: false`
|
||||
# means "nothing to report this tick", same as empty stdout.
|
||||
if not _parse_wake_gate(output):
|
||||
logger.info(
|
||||
"Job '%s' (no_agent): wakeAgent=false gate — silent run", job_id
|
||||
)
|
||||
silent_doc = (
|
||||
f"# Cron Job: {job_name}\n\n"
|
||||
f"**Job ID:** {job_id}\n"
|
||||
f"**Run Time:** {now_iso}\n"
|
||||
f"**Mode:** no_agent (script)\n"
|
||||
f"**Status:** silent (wakeAgent=false)\n"
|
||||
)
|
||||
return True, silent_doc, SILENT_MARKER, None
|
||||
|
||||
if not output.strip():
|
||||
logger.info("Job '%s' (no_agent): empty stdout — silent run", job_id)
|
||||
silent_doc = (
|
||||
f"# Cron Job: {job_name}\n\n"
|
||||
f"**Job ID:** {job_id}\n"
|
||||
f"**Run Time:** {now_iso}\n"
|
||||
f"**Mode:** no_agent (script)\n"
|
||||
f"**Status:** silent (empty output)\n"
|
||||
)
|
||||
return True, silent_doc, SILENT_MARKER, None
|
||||
|
||||
doc = (
|
||||
f"# Cron Job: {job_name}\n\n"
|
||||
f"**Job ID:** {job_id}\n"
|
||||
f"**Run Time:** {now_iso}\n"
|
||||
f"**Mode:** no_agent (script)\n\n"
|
||||
f"---\n\n"
|
||||
f"{output}\n"
|
||||
)
|
||||
return True, doc, output, None
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Default (LLM) path — import and construct the agent machinery now
|
||||
# that we know we actually need it. Doing these imports here instead of
|
||||
# at module top keeps no_agent ticks from paying for AIAgent / SessionDB
|
||||
# construction costs.
|
||||
# ---------------------------------------------------------------
|
||||
from run_agent import AIAgent
|
||||
|
||||
|
||||
# Initialize SQLite session store so cron job messages are persisted
|
||||
# and discoverable via session_search (same pattern as gateway/run.py).
|
||||
_session_db = None
|
||||
|
|
@ -840,9 +972,6 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
|||
_session_db = SessionDB()
|
||||
except Exception as e:
|
||||
logger.debug("Job '%s': SQLite session store not available: %s", job.get("id", "?"), e)
|
||||
|
||||
job_id = job["id"]
|
||||
job_name = job["name"]
|
||||
|
||||
# Wake-gate: if this job has a pre-check script, run it BEFORE building
|
||||
# the prompt so a ``{"wakeAgent": false}`` response can short-circuit
|
||||
|
|
@ -953,6 +1082,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
|||
if os.path.exists(_cfg_path):
|
||||
with open(_cfg_path) as _f:
|
||||
_cfg = yaml.safe_load(_f) or {}
|
||||
_cfg = _expand_env_vars(_cfg)
|
||||
_model_cfg = _cfg.get("model", {})
|
||||
if not job.get("model"):
|
||||
if isinstance(_model_cfg, str):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,473 @@
|
|||
# Telegram DM User-Managed Multi-Session Topics Implementation Plan
|
||||
|
||||
> **For Hermes:** Use test-driven-development for implementation. Use subagent-driven-development only after this plan is split into small reviewed tasks.
|
||||
|
||||
**Goal:** Add an opt-in Telegram DM multi-session mode where Telegram user-created private-chat topics become independent Hermes session lanes, while the root DM becomes a system lobby.
|
||||
|
||||
**Architecture:** Rely on Telegram's native private-chat topic UI. Users create new topics with the `+` button; Hermes maps each `message_thread_id` to a separate session lane. Hermes does not create topics for normal `/new` flow and does not try to manage topic lifecycle beyond activation/status, root-lobby behavior, and restoring legacy sessions into a user-created topic.
|
||||
|
||||
**Tech Stack:** Hermes gateway, Telegram Bot API 9.4+, python-telegram-bot adapter, SQLite SessionDB / side tables, pytest.
|
||||
|
||||
---
|
||||
|
||||
## 1. Product decisions
|
||||
|
||||
### Accepted
|
||||
|
||||
- PR-quality implementation: migrations, tests, docs, backwards compatibility.
|
||||
- Use SQLite persistence, not JSON sidecars.
|
||||
- Live status suffixes in topic titles are out of MVP.
|
||||
- Topic title sync/editing is out of MVP except future-compatible storage if cheap.
|
||||
- User creates Telegram topics manually through the Telegram bot interface.
|
||||
- `/new` does **not** create Telegram topics.
|
||||
- Root/main DM becomes a system lobby after activation.
|
||||
- Existing Telegram behavior remains unchanged until the feature is activated/enabled.
|
||||
- Migration of old sessions is supported through `/topic` listing and `/topic <session_id>` restore inside a user-created topic.
|
||||
|
||||
### Telegram API assumptions verified from Bot API docs
|
||||
|
||||
- `getMe` returns bot `User` fields:
|
||||
- `has_topics_enabled`: forum/topic mode enabled in private chats.
|
||||
- `allows_users_to_create_topics`: users may create/delete topics in private chats.
|
||||
- `createForumTopic` works for private chats with a user, but MVP does not rely on it for normal flow.
|
||||
- `Message.message_thread_id` identifies a topic in private chats.
|
||||
- `sendMessage` supports `message_thread_id` for private-chat topics.
|
||||
- `pinChatMessage` is allowed in private chats.
|
||||
|
||||
---
|
||||
|
||||
## 2. Target UX
|
||||
|
||||
### 2.1 Activation from root/main DM
|
||||
|
||||
User sends:
|
||||
|
||||
```text
|
||||
/topic
|
||||
```
|
||||
|
||||
Hermes:
|
||||
|
||||
1. calls Telegram `getMe`;
|
||||
2. verifies `has_topics_enabled` and `allows_users_to_create_topics`;
|
||||
3. enables multi-session topic mode for this Telegram DM user/chat;
|
||||
4. sends an onboarding message;
|
||||
5. pins the onboarding message if configured;
|
||||
6. shows old/unlinked sessions that can be restored into topics.
|
||||
|
||||
Suggested onboarding text:
|
||||
|
||||
```text
|
||||
Multi-session mode is enabled.
|
||||
|
||||
Create new Hermes chats with the + button in this bot interface. Each Telegram topic is an independent Hermes session, so you can work on different tasks in parallel.
|
||||
|
||||
This main chat is reserved for system commands, status, and session management.
|
||||
|
||||
To restore an old session:
|
||||
1. Use /topic here to see unlinked sessions.
|
||||
2. Create a new topic with the + button.
|
||||
3. Send /topic <session_id> inside that topic.
|
||||
```
|
||||
|
||||
### 2.2 Root/main DM after activation
|
||||
|
||||
Root DM is a system lobby.
|
||||
|
||||
Allowed/system commands include at least:
|
||||
|
||||
- `/topic`
|
||||
- `/status`
|
||||
- `/sessions` if available
|
||||
- `/usage`
|
||||
- `/help`
|
||||
- `/platforms`
|
||||
|
||||
Normal user prompts in root DM do not enter the agent loop. Reply:
|
||||
|
||||
```text
|
||||
This main chat is reserved for system commands.
|
||||
|
||||
To chat with Hermes, create a new topic using the + button in this bot interface. Each topic works as an independent Hermes session.
|
||||
```
|
||||
|
||||
`/new` in root DM does not create a session/topic. Reply:
|
||||
|
||||
```text
|
||||
To start a new parallel Hermes chat, create a new topic with the + button in this bot interface.
|
||||
|
||||
Each topic is an independent Hermes session. Use /new inside a topic only if you want to replace that topic's current session.
|
||||
```
|
||||
|
||||
### 2.3 First message in a user-created topic
|
||||
|
||||
When a user creates a Telegram topic and sends the first message there:
|
||||
|
||||
1. Hermes receives a Telegram DM message with `message_thread_id`.
|
||||
2. Hermes derives the existing thread-aware `session_key` from `(platform=telegram, chat_type=dm, chat_id, thread_id)`.
|
||||
3. If no binding exists, Hermes creates a fresh Hermes session for this topic lane and persists the binding.
|
||||
4. The message runs through the normal agent loop for that lane.
|
||||
|
||||
### 2.4 `/new` inside a non-main topic
|
||||
|
||||
`/new` remains supported but replaces the session attached to the current topic lane.
|
||||
|
||||
Hermes should warn:
|
||||
|
||||
```text
|
||||
Started a new Hermes session in this topic.
|
||||
|
||||
Tip: for parallel work, create a new topic with the + button instead of using /new here. /new replaces the session attached to the current topic.
|
||||
```
|
||||
|
||||
### 2.5 `/topic` in root/main DM after activation
|
||||
|
||||
Shows:
|
||||
|
||||
- mode enabled/disabled;
|
||||
- last capability check result;
|
||||
- whether intro message is pinned if known;
|
||||
- count of known topic bindings;
|
||||
- list of old/unlinked sessions.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Telegram multi-session topics are enabled.
|
||||
|
||||
Create new Hermes chats with the + button in this bot interface.
|
||||
|
||||
Unlinked previous sessions:
|
||||
1. 2026-05-01 Research notes — id: abc123
|
||||
2. 2026-04-30 Deploy debugging — id: def456
|
||||
3. Untitled session — id: ghi789
|
||||
|
||||
To restore one:
|
||||
1. Create a new topic with the + button.
|
||||
2. Open that topic.
|
||||
3. Send /topic <id>
|
||||
```
|
||||
|
||||
### 2.6 `/topic` inside a non-main topic
|
||||
|
||||
Without args, show the current topic binding:
|
||||
|
||||
```text
|
||||
This topic is linked to:
|
||||
Session: Research notes
|
||||
ID: abc123
|
||||
|
||||
Use /new to replace this topic with a fresh session.
|
||||
For parallel work, create another topic with the + button.
|
||||
```
|
||||
|
||||
### 2.7 `/topic <session_id>` inside a non-main topic
|
||||
|
||||
Restore an old/unlinked session into the current user-created topic.
|
||||
|
||||
Behavior:
|
||||
|
||||
1. reject if not in Telegram DM topic;
|
||||
2. verify session belongs to the same Telegram user/chat or is a safe legacy root DM session for this user;
|
||||
3. reject if session is already linked to another active topic in MVP;
|
||||
4. `SessionStore.switch_session(current_topic_session_key, target_session_id)`;
|
||||
5. upsert binding with `managed_mode = restored`;
|
||||
6. send two messages into the topic:
|
||||
- session restored confirmation;
|
||||
- last Hermes assistant message if available.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Session restored: Research notes
|
||||
|
||||
Last Hermes message:
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Persistence model
|
||||
|
||||
Use SQLite, but topic-mode schema changes are **explicit opt-in migrations**, not automatic startup reconciliation.
|
||||
|
||||
Important rollback-safety rule:
|
||||
|
||||
- upgrading Hermes and starting the gateway must not create Telegram topic-mode tables or columns;
|
||||
- old/default Telegram behavior must keep working on the existing `state.db`;
|
||||
- the first `/topic` activation path calls an idempotent explicit migration, then enables topic mode for that chat;
|
||||
- if activation fails before the migration is needed, the database remains in the pre-topic-mode shape.
|
||||
|
||||
### 3.1 No eager `sessions` table mutation for MVP
|
||||
|
||||
Do **not** add `chat_id`, `chat_type`, `thread_id`, or `session_key` columns to `sessions` as part of ordinary `SessionDB()` startup. The existing declarative `_reconcile_columns()` mechanism would add them eagerly on every process start, which violates the managed-migration requirement.
|
||||
|
||||
For MVP, keep origin/session-lane data in topic-specific side tables created only by the explicit `/topic` migration. Legacy unlinked sessions can be discovered conservatively from existing data (`source = telegram`, `user_id = current Telegram user`) plus absence from topic bindings.
|
||||
|
||||
If future PRs need richer origin metadata for all gateway sessions, introduce it behind a separate explicit migration/command or a compatibility-reviewed schema bump.
|
||||
|
||||
### 3.2 Explicit `/topic` migration API
|
||||
|
||||
Add an idempotent method such as:
|
||||
|
||||
```python
|
||||
def apply_telegram_topic_migration(self) -> None: ...
|
||||
```
|
||||
|
||||
It creates only topic-mode side tables/indexes and records:
|
||||
|
||||
```text
|
||||
state_meta.telegram_dm_topic_schema_version = 1
|
||||
```
|
||||
|
||||
This method is called from `/topic` activation/status paths before reading or writing topic-mode state. It is not called from generic `SessionDB.__init__`, gateway startup, CLI startup, or auto-maintenance.
|
||||
|
||||
### 3.3 `telegram_dm_topic_mode`
|
||||
|
||||
Stores per-user/chat activation state. Created only by `apply_telegram_topic_migration()`.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `chat_id` primary key
|
||||
- `user_id`
|
||||
- `enabled`
|
||||
- `activated_at`
|
||||
- `updated_at`
|
||||
- `has_topics_enabled`
|
||||
- `allows_users_to_create_topics`
|
||||
- `capability_checked_at`
|
||||
- `intro_message_id`
|
||||
- `pinned_message_id`
|
||||
|
||||
### 3.4 `telegram_dm_topic_bindings`
|
||||
|
||||
Stores Telegram topic/thread to Hermes session binding. Created only by `apply_telegram_topic_migration()`.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `chat_id`
|
||||
- `thread_id`
|
||||
- `user_id`
|
||||
- `session_key`
|
||||
- `session_id`
|
||||
- `managed_mode`
|
||||
- `auto`
|
||||
- `restored`
|
||||
- `new_replaced`
|
||||
- `linked_at`
|
||||
- `updated_at`
|
||||
|
||||
Recommended constraints:
|
||||
|
||||
- primary key `(chat_id, thread_id)`;
|
||||
- unique index on `session_id` for MVP to prevent one session linked to multiple topics;
|
||||
- index `(user_id, chat_id)` for status/listing.
|
||||
|
||||
### 3.5 Unlinked session semantics
|
||||
|
||||
For MVP, a session is unlinked if:
|
||||
|
||||
- `source = telegram`;
|
||||
- `user_id = current Telegram user`;
|
||||
- no row in `telegram_dm_topic_bindings` has `session_id = session_id`.
|
||||
|
||||
This is intentionally conservative until a future explicit migration adds richer cross-platform origin metadata.
|
||||
|
||||
Never dedupe by title.
|
||||
|
||||
---
|
||||
|
||||
## 4. Config
|
||||
|
||||
Suggested config block:
|
||||
|
||||
```yaml
|
||||
platforms:
|
||||
telegram:
|
||||
extra:
|
||||
multisession_topics:
|
||||
enabled: false
|
||||
mode: user_managed_topics
|
||||
root_chat_behavior: system_lobby
|
||||
pin_intro_message: true
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `enabled: false` means existing Telegram behavior is unchanged.
|
||||
- Activation via `/topic` may create per-chat enabled state only if global config permits it.
|
||||
- `root_chat_behavior: system_lobby` is the MVP behavior for activated chats.
|
||||
|
||||
---
|
||||
|
||||
## 5. Command behavior summary
|
||||
|
||||
### `/topic` root/main DM
|
||||
|
||||
- If not activated: capability check, activate, send/pin onboarding, list unlinked sessions.
|
||||
- If activated: show status and unlinked sessions.
|
||||
|
||||
### `/topic` non-main topic
|
||||
|
||||
- Show current binding.
|
||||
|
||||
### `/topic <session_id>` root/main DM
|
||||
|
||||
Reject with instructions:
|
||||
|
||||
```text
|
||||
Create a new topic with the + button, open it, then send /topic <session_id> there to restore this session.
|
||||
```
|
||||
|
||||
### `/topic <session_id>` non-main topic
|
||||
|
||||
Restore that session into this topic if ownership/linking checks pass.
|
||||
|
||||
### `/new` root/main DM when activated
|
||||
|
||||
Reply with instructions to use the `+` button. Do not enter agent loop.
|
||||
|
||||
### `/new` non-main topic
|
||||
|
||||
Create a new session in the current topic lane, persist/update binding, warn that `+` is preferred for parallel work.
|
||||
|
||||
### Normal text root/main DM when activated
|
||||
|
||||
Reply with system-lobby instruction. Do not enter agent loop.
|
||||
|
||||
### Normal text non-main topic
|
||||
|
||||
Normal Hermes agent flow for that topic's session lane.
|
||||
|
||||
---
|
||||
|
||||
## 6. PR breakdown
|
||||
|
||||
### PR 1 — Explicit topic-mode schema migration
|
||||
|
||||
**Goal:** Add rollback-safe SQLite support for Telegram topic mode without mutating `state.db` on ordinary upgrade/startup.
|
||||
|
||||
**Files likely touched:**
|
||||
|
||||
- `hermes_state.py`
|
||||
- tests under `tests/`
|
||||
|
||||
**Tests first:**
|
||||
|
||||
1. opening an old/current DB with `SessionDB()` does not create topic-mode tables or `sessions` origin columns;
|
||||
2. calling `apply_telegram_topic_migration()` creates `telegram_dm_topic_mode` and `telegram_dm_topic_bindings` idempotently;
|
||||
3. migration records `state_meta.telegram_dm_topic_schema_version = 1`.
|
||||
|
||||
### PR 2 — Topic mode activation and binding APIs
|
||||
|
||||
**Goal:** Add SQLite persistence for activation and topic bindings.
|
||||
|
||||
**Tests first:**
|
||||
|
||||
1. enable/check mode row round-trips;
|
||||
2. binding upsert and lookup by `(chat_id, user_id, thread_id)`;
|
||||
3. linked sessions are excluded from unlinked list.
|
||||
|
||||
### PR 3 — `/topic` activation/status command
|
||||
|
||||
**Goal:** Implement root activation/status/listing behavior.
|
||||
|
||||
**Tests first:**
|
||||
|
||||
1. `/topic` in root checks `getMe` capabilities and records activation;
|
||||
2. capability failure returns readable instructions;
|
||||
3. activated root `/topic` lists unlinked sessions.
|
||||
|
||||
### PR 4 — System lobby behavior
|
||||
|
||||
**Goal:** Prevent root chat from entering agent loop after activation.
|
||||
|
||||
**Tests first:**
|
||||
|
||||
1. normal text in activated root returns lobby instruction;
|
||||
2. `/new` in activated root returns `+` button instruction;
|
||||
3. non-activated root behavior is unchanged.
|
||||
|
||||
### PR 5 — Auto-bind user-created topics
|
||||
|
||||
**Goal:** First message in non-main topic creates/uses an independent session lane.
|
||||
|
||||
**Tests first:**
|
||||
|
||||
1. new topic message creates binding with `auto_created`;
|
||||
2. repeated topic message reuses same binding/lane;
|
||||
3. two topics in same DM do not share sessions.
|
||||
|
||||
### PR 6 — Restore legacy sessions into a topic
|
||||
|
||||
**Goal:** Implement `/topic <session_id>` in non-main topics.
|
||||
|
||||
**Tests first:**
|
||||
|
||||
1. root `/topic <id>` rejects with instructions;
|
||||
2. topic `/topic <id>` switches current topic lane to target session;
|
||||
3. restore rejects sessions from other users/chats;
|
||||
4. restore rejects already-linked sessions;
|
||||
5. restore emits confirmation and last Hermes assistant message.
|
||||
|
||||
### PR 7 — `/new` inside topic updates binding
|
||||
|
||||
**Goal:** Keep existing `/new` semantics but persist topic binding replacement.
|
||||
|
||||
**Tests first:**
|
||||
|
||||
1. `/new` in topic creates a new session for same topic lane;
|
||||
2. binding updates to `managed_mode = new_replaced`;
|
||||
3. response includes guidance to use `+` for parallel work.
|
||||
|
||||
### PR 8 — Docs and polish
|
||||
|
||||
**Goal:** Document the feature and Telegram setup.
|
||||
|
||||
**Files likely touched:**
|
||||
|
||||
- `website/docs/user-guide/messaging/telegram.md`
|
||||
- maybe `website/docs/user-guide/sessions.md`
|
||||
|
||||
Docs must explain:
|
||||
|
||||
- BotFather/Telegram settings for topic mode and user-created topics;
|
||||
- `/topic` activation;
|
||||
- root system lobby;
|
||||
- using `+` for new parallel chats;
|
||||
- restoring old sessions with `/topic <id>` inside a topic;
|
||||
- limitations.
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing / quality gates
|
||||
|
||||
Run targeted tests after each TDD cycle, then broader tests before completion.
|
||||
|
||||
Suggested commands after inspection confirms test paths:
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_hermes_state.py -q
|
||||
python -m pytest tests/gateway/ -q
|
||||
python -m pytest tests/ -o 'addopts=' -q
|
||||
```
|
||||
|
||||
Do not ship without verifying disabled-feature backwards compatibility.
|
||||
|
||||
---
|
||||
|
||||
## 8. Definition of done for MVP
|
||||
|
||||
- `/topic` activates/checks Telegram DM multi-session mode.
|
||||
- Root DM becomes a system lobby after activation.
|
||||
- Onboarding message tells users to create new chats with the Telegram `+` button.
|
||||
- Onboarding message can be pinned in private chat.
|
||||
- User-created topics automatically become independent Hermes session lanes.
|
||||
- `/new` in root gives instructions, not a new agent run.
|
||||
- `/new` in a topic creates a new session in that topic and warns that `+` is preferred for parallel work.
|
||||
- `/topic` in root lists unlinked old sessions.
|
||||
- `/topic <session_id>` inside a topic restores that session and sends confirmation + last Hermes assistant message.
|
||||
- Ownership checks prevent restoring other users' sessions.
|
||||
- Already-linked sessions are not restored into a second topic in MVP.
|
||||
- Existing Telegram behavior is unchanged when the feature is disabled.
|
||||
- Tests and docs are included.
|
||||
BIN
gateway/assets/telegram-botfather-threads-settings.jpg
Normal file
BIN
gateway/assets/telegram-botfather-threads-settings.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
|
|
@ -416,6 +416,18 @@ class EmailAdapter(BasePlatformAdapter):
|
|||
logger.debug("[Email] Dropping automated sender at dispatch: %s", sender_addr)
|
||||
return
|
||||
|
||||
# Skip senders not in EMAIL_ALLOWED_USERS — prevents the adapter
|
||||
# from creating a MessageEvent (and thus thread context) for senders
|
||||
# that the gateway will never authorize. Without this early guard,
|
||||
# a race between dispatch and authorization can result in the adapter
|
||||
# sending a reply even though the handler returned None.
|
||||
allowed_raw = os.getenv("EMAIL_ALLOWED_USERS", "").strip()
|
||||
if allowed_raw:
|
||||
allowed = {addr.strip().lower() for addr in allowed_raw.split(",") if addr.strip()}
|
||||
if sender_addr.lower() not in allowed:
|
||||
logger.debug("[Email] Dropping non-allowlisted sender at dispatch: %s", sender_addr)
|
||||
return
|
||||
|
||||
subject = msg_data["subject"]
|
||||
body = msg_data["body"].strip()
|
||||
attachments = msg_data["attachments"]
|
||||
|
|
|
|||
|
|
@ -688,6 +688,29 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
)
|
||||
return None
|
||||
|
||||
async def rename_dm_topic(
|
||||
self,
|
||||
chat_id: int,
|
||||
thread_id: int,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""Rename a forum topic in a private (DM) chat."""
|
||||
if not self._bot:
|
||||
return
|
||||
try:
|
||||
chat_id_arg = int(chat_id)
|
||||
except (TypeError, ValueError):
|
||||
chat_id_arg = chat_id
|
||||
await self._bot.edit_forum_topic(
|
||||
chat_id=chat_id_arg,
|
||||
message_thread_id=int(thread_id),
|
||||
name=name,
|
||||
)
|
||||
logger.info(
|
||||
"[%s] Renamed DM topic in chat %s thread_id=%s -> '%s'",
|
||||
self.name, chat_id, thread_id, name,
|
||||
)
|
||||
|
||||
def _persist_dm_topic_thread_id(self, chat_id: int, topic_name: str, thread_id: int) -> None:
|
||||
"""Save a newly created thread_id back into config.yaml so it persists across restarts."""
|
||||
try:
|
||||
|
|
|
|||
894
gateway/run.py
894
gateway/run.py
File diff suppressed because it is too large
Load diff
|
|
@ -65,6 +65,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||
# Session
|
||||
CommandDef("new", "Start a new session (fresh session ID + history)", "Session",
|
||||
aliases=("reset",), args_hint="[name]"),
|
||||
CommandDef("topic", "Enable or inspect Telegram DM topic sessions", "Session",
|
||||
gateway_only=True, args_hint="[off|help|session-id]"),
|
||||
CommandDef("clear", "Clear screen and start a new session", "Session",
|
||||
cli_only=True),
|
||||
CommandDef("redraw", "Force a full UI repaint (recovers from terminal drift)", "Session",
|
||||
|
|
|
|||
|
|
@ -809,6 +809,7 @@ DEFAULT_CONFIG = {
|
|||
"enabled": False,
|
||||
"fields": ["model", "context_pct", "cwd"], # Order shown; drop any to hide
|
||||
},
|
||||
"copy_shortcut": "auto", # "auto" (platform default) | "ctrl_c" | "ctrl_shift_c" | "disabled"
|
||||
},
|
||||
|
||||
# Web dashboard settings
|
||||
|
|
|
|||
|
|
@ -93,6 +93,8 @@ def cron_list(show_all: bool = False):
|
|||
script = job.get("script")
|
||||
if script:
|
||||
print(f" Script: {script}")
|
||||
if job.get("no_agent"):
|
||||
print(f" Mode: {color('no-agent', Colors.DIM)} (script stdout delivered directly)")
|
||||
workdir = job.get("workdir")
|
||||
if workdir:
|
||||
print(f" Workdir: {workdir}")
|
||||
|
|
@ -172,6 +174,7 @@ def cron_create(args):
|
|||
skills=_normalize_skills(getattr(args, "skill", None), getattr(args, "skills", None)),
|
||||
script=getattr(args, "script", None),
|
||||
workdir=getattr(args, "workdir", None),
|
||||
no_agent=getattr(args, "no_agent", False) or None,
|
||||
)
|
||||
if not result.get("success"):
|
||||
print(color(f"Failed to create job: {result.get('error', 'unknown error')}", Colors.RED))
|
||||
|
|
@ -184,6 +187,8 @@ def cron_create(args):
|
|||
job_data = result.get("job", {})
|
||||
if job_data.get("script"):
|
||||
print(f" Script: {job_data['script']}")
|
||||
if job_data.get("no_agent"):
|
||||
print(" Mode: no-agent (script stdout delivered directly)")
|
||||
if job_data.get("workdir"):
|
||||
print(f" Workdir: {job_data['workdir']}")
|
||||
print(f" Next run: {result['next_run_at']}")
|
||||
|
|
@ -225,6 +230,7 @@ def cron_edit(args):
|
|||
skills=final_skills,
|
||||
script=getattr(args, "script", None),
|
||||
workdir=getattr(args, "workdir", None),
|
||||
no_agent=getattr(args, "no_agent", None),
|
||||
)
|
||||
if not result.get("success"):
|
||||
print(color(f"Failed to update job: {result.get('error', 'unknown error')}", Colors.RED))
|
||||
|
|
@ -240,6 +246,8 @@ def cron_edit(args):
|
|||
print(" Skills: none")
|
||||
if updated.get("script"):
|
||||
print(f" Script: {updated['script']}")
|
||||
if updated.get("no_agent"):
|
||||
print(" Mode: no-agent (script stdout delivered directly)")
|
||||
if updated.get("workdir"):
|
||||
print(f" Workdir: {updated['workdir']}")
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -1264,9 +1264,23 @@ def run_doctor(args):
|
|||
check_warn("Skills Hub directory not initialized", "(run: hermes skills list)")
|
||||
|
||||
from hermes_cli.config import get_env_value
|
||||
|
||||
def _gh_authenticated() -> bool:
|
||||
"""Check if gh CLI is authenticated via token file or device flow."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["gh", "auth", "status", "--json", "authenticated"],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
github_token = get_env_value("GITHUB_TOKEN") or get_env_value("GH_TOKEN")
|
||||
if github_token:
|
||||
check_ok("GitHub token configured (authenticated API access)")
|
||||
elif _gh_authenticated():
|
||||
check_ok("GitHub authenticated via gh CLI", "(full API access — no GITHUB_TOKEN needed)")
|
||||
else:
|
||||
check_warn("No GITHUB_TOKEN", f"(60 req/hr rate limit — set in {_DHH}/.env for better rates)")
|
||||
|
||||
|
|
|
|||
|
|
@ -1608,6 +1608,46 @@ def _build_user_local_paths(home: Path, path_entries: list[str]) -> list[str]:
|
|||
return [p for p in candidates if p not in path_entries and Path(p).exists()]
|
||||
|
||||
|
||||
def _build_wsl_interop_paths(path_entries: list[str]) -> list[str]:
|
||||
"""Return WSL Windows interop PATH entries for generated systemd units.
|
||||
|
||||
WSL shells normally inherit Windows PATH entries such as
|
||||
``/mnt/c/WINDOWS/System32``. systemd user services do not, so gateway tools
|
||||
that call ``powershell.exe``/``cmd.exe`` work in a terminal but fail in the
|
||||
background service unless we persist the relevant entries at install time.
|
||||
"""
|
||||
if not is_wsl():
|
||||
return []
|
||||
|
||||
candidates: list[str] = []
|
||||
for entry in os.environ.get("PATH", "").split(os.pathsep):
|
||||
if entry.startswith("/mnt/"):
|
||||
candidates.append(entry)
|
||||
|
||||
for executable in ("powershell.exe", "cmd.exe", "explorer.exe", "wsl.exe"):
|
||||
resolved = shutil.which(executable)
|
||||
if resolved:
|
||||
candidates.append(str(Path(resolved).parent))
|
||||
|
||||
for entry in (
|
||||
"/mnt/c/WINDOWS/system32",
|
||||
"/mnt/c/WINDOWS",
|
||||
"/mnt/c/WINDOWS/System32/Wbem",
|
||||
"/mnt/c/WINDOWS/System32/WindowsPowerShell/v1.0/",
|
||||
"/mnt/c/WINDOWS/System32/OpenSSH/",
|
||||
):
|
||||
if Path(entry).exists():
|
||||
candidates.append(entry)
|
||||
|
||||
result: list[str] = []
|
||||
seen = set(path_entries)
|
||||
for entry in candidates:
|
||||
if entry and entry not in seen:
|
||||
seen.add(entry)
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
def _remap_path_for_user(path: str, target_home_dir: str) -> str:
|
||||
"""Remap *path* from the current user's home to *target_home_dir*.
|
||||
|
||||
|
|
@ -1699,6 +1739,7 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
|
|||
node_bin = _remap_path_for_user(node_bin, home_dir)
|
||||
path_entries = [_remap_path_for_user(p, home_dir) for p in path_entries]
|
||||
path_entries.extend(_build_user_local_paths(Path(home_dir), path_entries))
|
||||
path_entries.extend(_build_wsl_interop_paths(path_entries))
|
||||
path_entries.extend(common_bin_paths)
|
||||
sane_path = ":".join(path_entries)
|
||||
return f"""[Unit]
|
||||
|
|
@ -1738,6 +1779,7 @@ WantedBy=multi-user.target
|
|||
hermes_home = str(get_hermes_home().resolve())
|
||||
profile_arg = _profile_arg(hermes_home)
|
||||
path_entries.extend(_build_user_local_paths(Path.home(), path_entries))
|
||||
path_entries.extend(_build_wsl_interop_paths(path_entries))
|
||||
path_entries.extend(common_bin_paths)
|
||||
sane_path = ":".join(path_entries)
|
||||
return f"""[Unit]
|
||||
|
|
|
|||
|
|
@ -7075,20 +7075,22 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
|||
except Exception as e:
|
||||
logger.debug("Skills sync during update failed: %s", e)
|
||||
|
||||
# Sync bundled skills to all other profiles
|
||||
# Sync bundled skills to all profiles (including the active one).
|
||||
# seed_profile_skills() uses subprocess with an explicit HERMES_HOME so
|
||||
# it is not affected by sync_skills()'s module-level HERMES_HOME cache,
|
||||
# which means the active profile is reliably synced regardless of whether
|
||||
# the caller's HERMES_HOME env var points at the default or a named profile.
|
||||
try:
|
||||
from hermes_cli.profiles import (
|
||||
list_profiles,
|
||||
get_active_profile_name,
|
||||
seed_profile_skills,
|
||||
)
|
||||
|
||||
active = get_active_profile_name()
|
||||
other_profiles = [p for p in list_profiles() if p.name != active]
|
||||
if other_profiles:
|
||||
all_profiles = list_profiles()
|
||||
if all_profiles:
|
||||
print()
|
||||
print("→ Syncing bundled skills to other profiles...")
|
||||
for p in other_profiles:
|
||||
print("→ Syncing bundled skills to all profiles...")
|
||||
for p in all_profiles:
|
||||
try:
|
||||
r = seed_profile_skills(p.path, quiet=True)
|
||||
if r:
|
||||
|
|
@ -8684,7 +8686,24 @@ def main():
|
|||
)
|
||||
cron_create.add_argument(
|
||||
"--script",
|
||||
help="Path to a Python script whose stdout is injected into the prompt each run",
|
||||
help=(
|
||||
"Path to a script under ~/.hermes/scripts/. Default mode: "
|
||||
"script stdout is injected into the agent's prompt each run. "
|
||||
"With --no-agent: the script IS the job and its stdout is "
|
||||
"delivered verbatim. .sh/.bash files run via bash, everything "
|
||||
"else via Python."
|
||||
),
|
||||
)
|
||||
cron_create.add_argument(
|
||||
"--no-agent",
|
||||
dest="no_agent",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"Skip the LLM entirely — run --script on schedule and deliver "
|
||||
"its stdout directly. Empty stdout = silent. Classic watchdog "
|
||||
"pattern (memory alerts, disk alerts, CI pings)."
|
||||
),
|
||||
)
|
||||
cron_create.add_argument(
|
||||
"--workdir",
|
||||
|
|
@ -8726,7 +8745,29 @@ def main():
|
|||
)
|
||||
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.",
|
||||
help=(
|
||||
"Path to a script under ~/.hermes/scripts/. Pass empty string to clear. "
|
||||
"With --no-agent the script IS the job; otherwise its stdout is "
|
||||
"injected into the agent's prompt each run."
|
||||
),
|
||||
)
|
||||
cron_edit.add_argument(
|
||||
"--no-agent",
|
||||
dest="no_agent",
|
||||
action="store_const",
|
||||
const=True,
|
||||
default=None,
|
||||
help=(
|
||||
"Enable no-agent mode on this job (requires --script or an "
|
||||
"existing script on the job)."
|
||||
),
|
||||
)
|
||||
cron_edit.add_argument(
|
||||
"--agent",
|
||||
dest="no_agent",
|
||||
action="store_const",
|
||||
const=False,
|
||||
help="Disable no-agent mode on this job (reverts to LLM-driven execution).",
|
||||
)
|
||||
cron_edit.add_argument(
|
||||
"--workdir",
|
||||
|
|
|
|||
|
|
@ -2906,6 +2906,19 @@ def fetch_api_models(
|
|||
_OLLAMA_CLOUD_CACHE_TTL = 3600 # 1 hour
|
||||
|
||||
|
||||
def _strip_ollama_cloud_suffix(model_id: str) -> str:
|
||||
"""Strip :cloud / -cloud suffixes that models.dev appends to Ollama Cloud IDs.
|
||||
|
||||
The live API uses clean IDs (e.g. 'kimi-k2.6') while models.dev sometimes
|
||||
returns them as 'kimi-k2.6:cloud'. Normalising before the dedup merge
|
||||
prevents duplicate entries in the merged model list.
|
||||
"""
|
||||
for suffix in (":cloud", "-cloud"):
|
||||
if model_id.endswith(suffix):
|
||||
return model_id[: -len(suffix)]
|
||||
return model_id
|
||||
|
||||
|
||||
def _ollama_cloud_cache_path() -> Path:
|
||||
"""Return the path for the Ollama Cloud model cache."""
|
||||
from hermes_constants import get_hermes_home
|
||||
|
|
@ -3001,9 +3014,10 @@ def fetch_ollama_cloud_models(
|
|||
seen.add(m)
|
||||
merged.append(m)
|
||||
for m in mdev_models:
|
||||
if m and m not in seen:
|
||||
seen.add(m)
|
||||
merged.append(m)
|
||||
normalized = _strip_ollama_cloud_suffix(m)
|
||||
if normalized and normalized not in seen:
|
||||
seen.add(normalized)
|
||||
merged.append(normalized)
|
||||
if merged:
|
||||
_save_ollama_cloud_cache(merged)
|
||||
return merged
|
||||
|
|
|
|||
382
hermes_state.py
382
hermes_state.py
|
|
@ -2159,6 +2159,388 @@ class SessionDB:
|
|||
)
|
||||
self._execute_write(_do)
|
||||
|
||||
def apply_telegram_topic_migration(self) -> None:
|
||||
"""Create Telegram DM topic-mode tables on explicit /topic opt-in.
|
||||
|
||||
This migration is deliberately not part of automatic SessionDB startup
|
||||
reconciliation. Operators must be able to upgrade Hermes, keep the old
|
||||
Telegram bot behavior running, and only mutate topic-mode state when the
|
||||
user executes /topic to opt into the feature.
|
||||
|
||||
Schema versions:
|
||||
v1 — initial shape (no ON DELETE CASCADE on session_id FK)
|
||||
v2 — session_id FK gets ON DELETE CASCADE so session pruning
|
||||
automatically clears bindings.
|
||||
"""
|
||||
def _do(conn):
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS telegram_dm_topic_mode (
|
||||
chat_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
activated_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
has_topics_enabled INTEGER,
|
||||
allows_users_to_create_topics INTEGER,
|
||||
capability_checked_at REAL,
|
||||
intro_message_id TEXT,
|
||||
pinned_message_id TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS telegram_dm_topic_bindings (
|
||||
chat_id TEXT NOT NULL,
|
||||
thread_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
session_key TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
managed_mode TEXT NOT NULL DEFAULT 'auto',
|
||||
linked_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
PRIMARY KEY (chat_id, thread_id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_dm_topic_bindings_session
|
||||
ON telegram_dm_topic_bindings(session_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_telegram_dm_topic_bindings_user
|
||||
ON telegram_dm_topic_bindings(user_id, chat_id);
|
||||
"""
|
||||
)
|
||||
|
||||
# v1 → v2: rebuild telegram_dm_topic_bindings if its session_id FK
|
||||
# lacks ON DELETE CASCADE. SQLite can't ALTER a foreign key, so we
|
||||
# rebuild the table. Only runs once per DB (version gate).
|
||||
current = conn.execute(
|
||||
"SELECT value FROM state_meta WHERE key = ?",
|
||||
("telegram_dm_topic_schema_version",),
|
||||
).fetchone()
|
||||
current_version = int(current[0]) if current and str(current[0]).isdigit() else 0
|
||||
if current_version < 2:
|
||||
fk_rows = conn.execute(
|
||||
"PRAGMA foreign_key_list('telegram_dm_topic_bindings')"
|
||||
).fetchall()
|
||||
needs_rebuild = any(
|
||||
row[2] == "sessions" and (row[6] or "") != "CASCADE"
|
||||
for row in fk_rows
|
||||
)
|
||||
if needs_rebuild:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE telegram_dm_topic_bindings_new (
|
||||
chat_id TEXT NOT NULL,
|
||||
thread_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
session_key TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
managed_mode TEXT NOT NULL DEFAULT 'auto',
|
||||
linked_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
PRIMARY KEY (chat_id, thread_id)
|
||||
);
|
||||
INSERT INTO telegram_dm_topic_bindings_new
|
||||
SELECT chat_id, thread_id, user_id, session_key,
|
||||
session_id, managed_mode, linked_at, updated_at
|
||||
FROM telegram_dm_topic_bindings;
|
||||
DROP TABLE telegram_dm_topic_bindings;
|
||||
ALTER TABLE telegram_dm_topic_bindings_new
|
||||
RENAME TO telegram_dm_topic_bindings;
|
||||
CREATE UNIQUE INDEX idx_telegram_dm_topic_bindings_session
|
||||
ON telegram_dm_topic_bindings(session_id);
|
||||
CREATE INDEX idx_telegram_dm_topic_bindings_user
|
||||
ON telegram_dm_topic_bindings(user_id, chat_id);
|
||||
"""
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO state_meta (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
("telegram_dm_topic_schema_version", "2"),
|
||||
)
|
||||
self._execute_write(_do)
|
||||
|
||||
def enable_telegram_topic_mode(
|
||||
self,
|
||||
*,
|
||||
chat_id: str,
|
||||
user_id: str,
|
||||
has_topics_enabled: Optional[bool] = None,
|
||||
allows_users_to_create_topics: Optional[bool] = None,
|
||||
) -> None:
|
||||
"""Enable Telegram DM topic mode for one private chat/user.
|
||||
|
||||
This method intentionally owns the explicit topic migration. Ordinary
|
||||
SessionDB startup must not create these side tables.
|
||||
"""
|
||||
self.apply_telegram_topic_migration()
|
||||
now = time.time()
|
||||
|
||||
def _to_int(value: Optional[bool]) -> Optional[int]:
|
||||
if value is None:
|
||||
return None
|
||||
return 1 if value else 0
|
||||
|
||||
def _do(conn):
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO telegram_dm_topic_mode (
|
||||
chat_id, user_id, enabled, activated_at, updated_at,
|
||||
has_topics_enabled, allows_users_to_create_topics,
|
||||
capability_checked_at
|
||||
) VALUES (?, ?, 1, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(chat_id) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
enabled = 1,
|
||||
updated_at = excluded.updated_at,
|
||||
has_topics_enabled = excluded.has_topics_enabled,
|
||||
allows_users_to_create_topics = excluded.allows_users_to_create_topics,
|
||||
capability_checked_at = excluded.capability_checked_at
|
||||
""",
|
||||
(
|
||||
str(chat_id),
|
||||
str(user_id),
|
||||
now,
|
||||
now,
|
||||
_to_int(has_topics_enabled),
|
||||
_to_int(allows_users_to_create_topics),
|
||||
now,
|
||||
),
|
||||
)
|
||||
self._execute_write(_do)
|
||||
|
||||
def disable_telegram_topic_mode(
|
||||
self,
|
||||
*,
|
||||
chat_id: str,
|
||||
clear_bindings: bool = True,
|
||||
) -> None:
|
||||
"""Disable Telegram DM topic mode for one private chat.
|
||||
|
||||
When ``clear_bindings`` is True (default) the (chat_id, thread_id)
|
||||
bindings for this chat are also cleared so re-enabling later
|
||||
starts from a clean slate. Set to False if the operator wants to
|
||||
preserve bindings for a later re-enable.
|
||||
|
||||
Never creates the topic-mode tables from scratch; if they don't
|
||||
exist there is nothing to disable and the call is a no-op.
|
||||
"""
|
||||
def _do(conn):
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE telegram_dm_topic_mode SET enabled = 0, updated_at = ? "
|
||||
"WHERE chat_id = ?",
|
||||
(time.time(), str(chat_id)),
|
||||
)
|
||||
if clear_bindings:
|
||||
conn.execute(
|
||||
"DELETE FROM telegram_dm_topic_bindings WHERE chat_id = ?",
|
||||
(str(chat_id),),
|
||||
)
|
||||
except sqlite3.OperationalError:
|
||||
# Tables don't exist yet — nothing to disable.
|
||||
return
|
||||
self._execute_write(_do)
|
||||
|
||||
def is_telegram_topic_mode_enabled(self, *, chat_id: str, user_id: str) -> bool:
|
||||
"""Return whether Telegram DM topic mode is enabled for this chat/user."""
|
||||
with self._lock:
|
||||
try:
|
||||
row = self._conn.execute(
|
||||
"""
|
||||
SELECT enabled FROM telegram_dm_topic_mode
|
||||
WHERE chat_id = ? AND user_id = ?
|
||||
""",
|
||||
(str(chat_id), str(user_id)),
|
||||
).fetchone()
|
||||
except sqlite3.OperationalError:
|
||||
return False
|
||||
if row is None:
|
||||
return False
|
||||
enabled = row["enabled"] if isinstance(row, sqlite3.Row) else row[0]
|
||||
return bool(enabled)
|
||||
|
||||
def get_telegram_topic_binding(
|
||||
self,
|
||||
*,
|
||||
chat_id: str,
|
||||
thread_id: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Return the session binding for a Telegram DM topic, if present."""
|
||||
with self._lock:
|
||||
try:
|
||||
row = self._conn.execute(
|
||||
"""
|
||||
SELECT * FROM telegram_dm_topic_bindings
|
||||
WHERE chat_id = ? AND thread_id = ?
|
||||
""",
|
||||
(str(chat_id), str(thread_id)),
|
||||
).fetchone()
|
||||
except sqlite3.OperationalError:
|
||||
return None
|
||||
return dict(row) if row else None
|
||||
|
||||
def bind_telegram_topic(
|
||||
self,
|
||||
*,
|
||||
chat_id: str,
|
||||
thread_id: str,
|
||||
user_id: str,
|
||||
session_key: str,
|
||||
session_id: str,
|
||||
managed_mode: str = "auto",
|
||||
) -> None:
|
||||
"""Bind one Telegram DM topic thread to one Hermes session.
|
||||
|
||||
A Hermes session may only be linked to one Telegram topic in MVP.
|
||||
Rebinding the same topic to the same session is idempotent; trying to
|
||||
link the same session to a different topic raises ValueError.
|
||||
"""
|
||||
self.apply_telegram_topic_migration()
|
||||
now = time.time()
|
||||
chat_id = str(chat_id)
|
||||
thread_id = str(thread_id)
|
||||
user_id = str(user_id)
|
||||
session_key = str(session_key)
|
||||
session_id = str(session_id)
|
||||
|
||||
def _do(conn):
|
||||
existing_session = conn.execute(
|
||||
"""
|
||||
SELECT chat_id, thread_id FROM telegram_dm_topic_bindings
|
||||
WHERE session_id = ?
|
||||
""",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if existing_session is not None:
|
||||
linked_chat = existing_session["chat_id"] if isinstance(existing_session, sqlite3.Row) else existing_session[0]
|
||||
linked_thread = existing_session["thread_id"] if isinstance(existing_session, sqlite3.Row) else existing_session[1]
|
||||
if str(linked_chat) != chat_id or str(linked_thread) != thread_id:
|
||||
raise ValueError("session is already linked to another Telegram topic")
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO telegram_dm_topic_bindings (
|
||||
chat_id, thread_id, user_id, session_key, session_id,
|
||||
managed_mode, linked_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(chat_id, thread_id) DO UPDATE SET
|
||||
user_id = excluded.user_id,
|
||||
session_key = excluded.session_key,
|
||||
session_id = excluded.session_id,
|
||||
managed_mode = excluded.managed_mode,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
chat_id,
|
||||
thread_id,
|
||||
user_id,
|
||||
session_key,
|
||||
session_id,
|
||||
managed_mode,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
self._execute_write(_do)
|
||||
|
||||
def is_telegram_session_linked_to_topic(self, *, session_id: str) -> bool:
|
||||
"""Return True if a Hermes session is already bound to any Telegram DM topic.
|
||||
|
||||
Read-only: does NOT trigger the telegram-topic migration. If the
|
||||
topic-mode tables have not been created yet (i.e. nobody has run
|
||||
``/topic`` in this profile), the session is by definition unbound
|
||||
and we return False.
|
||||
"""
|
||||
with self._lock:
|
||||
try:
|
||||
row = self._conn.execute(
|
||||
"""
|
||||
SELECT 1 FROM telegram_dm_topic_bindings
|
||||
WHERE session_id = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(str(session_id),),
|
||||
).fetchone()
|
||||
except sqlite3.OperationalError:
|
||||
return False
|
||||
return row is not None
|
||||
|
||||
def list_unlinked_telegram_sessions_for_user(
|
||||
self,
|
||||
*,
|
||||
chat_id: str,
|
||||
user_id: str,
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List previous Telegram sessions for this user that are not bound to a topic.
|
||||
|
||||
Read-only: does NOT trigger the telegram-topic migration. If the
|
||||
topic-mode tables are absent, fall back to a simpler query that
|
||||
just returns this user's Telegram sessions — there can't be any
|
||||
bindings yet.
|
||||
"""
|
||||
with self._lock:
|
||||
try:
|
||||
rows = self._conn.execute(
|
||||
"""
|
||||
SELECT s.*,
|
||||
COALESCE(
|
||||
(SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
|
||||
FROM messages m
|
||||
WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
|
||||
ORDER BY m.timestamp, m.id LIMIT 1),
|
||||
''
|
||||
) AS _preview_raw,
|
||||
COALESCE(
|
||||
(SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
|
||||
s.started_at
|
||||
) AS last_active
|
||||
FROM sessions s
|
||||
WHERE s.source = 'telegram'
|
||||
AND s.user_id = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM telegram_dm_topic_bindings b
|
||||
WHERE b.session_id = s.id
|
||||
)
|
||||
ORDER BY last_active DESC, s.started_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(str(user_id), int(limit)),
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
# telegram_dm_topic_bindings doesn't exist yet — no bindings
|
||||
# means every telegram session for this user is "unlinked".
|
||||
rows = self._conn.execute(
|
||||
"""
|
||||
SELECT s.*,
|
||||
COALESCE(
|
||||
(SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
|
||||
FROM messages m
|
||||
WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
|
||||
ORDER BY m.timestamp, m.id LIMIT 1),
|
||||
''
|
||||
) AS _preview_raw,
|
||||
COALESCE(
|
||||
(SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
|
||||
s.started_at
|
||||
) AS last_active
|
||||
FROM sessions s
|
||||
WHERE s.source = 'telegram'
|
||||
AND s.user_id = ?
|
||||
ORDER BY last_active DESC, s.started_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(str(user_id), int(limit)),
|
||||
).fetchall()
|
||||
|
||||
sessions: List[Dict[str, Any]] = []
|
||||
for row in rows:
|
||||
session = dict(row)
|
||||
raw = str(session.pop("_preview_raw", "") or "").strip()
|
||||
session["preview"] = raw[:60] + ("..." if len(raw) > 60 else "") if raw else ""
|
||||
sessions.append(session)
|
||||
return sessions
|
||||
|
||||
# ── Space reclamation ──
|
||||
|
||||
def vacuum(self) -> None:
|
||||
|
|
|
|||
96
plugins/kanban/dashboard/dist/index.js
vendored
96
plugins/kanban/dashboard/dist/index.js
vendored
|
|
@ -1375,6 +1375,11 @@
|
|||
const [err, setErr] = useState(null);
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
// Home-channel notification toggles. homeChannels is the list of platforms
|
||||
// the user has a /sethome on; each entry has a `subscribed` bool telling
|
||||
// us whether this task is currently subscribed via that platform's home.
|
||||
const [homeChannels, setHomeChannels] = useState([]);
|
||||
const [homeBusy, setHomeBusy] = useState({});
|
||||
const boardSlug = props.boardSlug;
|
||||
|
||||
const load = useCallback(function () {
|
||||
|
|
@ -1384,10 +1389,19 @@
|
|||
.finally(function () { setLoading(false); });
|
||||
}, [props.taskId, boardSlug]);
|
||||
|
||||
const loadHomeChannels = useCallback(function () {
|
||||
const qs = new URLSearchParams({ task_id: props.taskId });
|
||||
const url = withBoard(`${API}/home-channels?${qs}`, boardSlug);
|
||||
return SDK.fetchJSON(url)
|
||||
.then(function (d) { setHomeChannels(d.home_channels || []); })
|
||||
.catch(function () { /* silent — endpoint optional on older gateways */ });
|
||||
}, [props.taskId, boardSlug]);
|
||||
|
||||
// Reload when the WS stream reports new events for this task id
|
||||
// (completion, block, crash, etc. — anything that'd make the drawer
|
||||
// show stale data if we only loaded on mount).
|
||||
useEffect(function () { load(); }, [load, props.eventTick]);
|
||||
useEffect(function () { loadHomeChannels(); }, [loadHomeChannels]);
|
||||
useEffect(function () {
|
||||
function onKey(e) { if (e.key === "Escape" && !editing) props.onClose(); }
|
||||
window.addEventListener("keydown", onKey);
|
||||
|
|
@ -1448,6 +1462,43 @@
|
|||
.catch(function (e) { setErr(String(e.message || e)); });
|
||||
};
|
||||
|
||||
const toggleHomeSubscription = function (platform, currentlySubscribed) {
|
||||
// Optimistic flip + busy flag to keep double-clicks idempotent.
|
||||
setHomeBusy(function (b) { return Object.assign({}, b, { [platform]: true }); });
|
||||
setHomeChannels(function (list) {
|
||||
return list.map(function (h) {
|
||||
return h.platform === platform
|
||||
? Object.assign({}, h, { subscribed: !currentlySubscribed })
|
||||
: h;
|
||||
});
|
||||
});
|
||||
const method = currentlySubscribed ? "DELETE" : "POST";
|
||||
const url = withBoard(
|
||||
`${API}/tasks/${encodeURIComponent(props.taskId)}/home-subscribe/${encodeURIComponent(platform)}`,
|
||||
boardSlug,
|
||||
);
|
||||
return SDK.fetchJSON(url, { method: method })
|
||||
.then(function () { return loadHomeChannels(); })
|
||||
.catch(function (e) {
|
||||
// Revert optimistic flip on failure.
|
||||
setHomeChannels(function (list) {
|
||||
return list.map(function (h) {
|
||||
return h.platform === platform
|
||||
? Object.assign({}, h, { subscribed: currentlySubscribed })
|
||||
: h;
|
||||
});
|
||||
});
|
||||
setErr(String(e.message || e));
|
||||
})
|
||||
.finally(function () {
|
||||
setHomeBusy(function (b) {
|
||||
const next = Object.assign({}, b);
|
||||
delete next[platform];
|
||||
return next;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return h("div", { className: "hermes-kanban-drawer-shade", onClick: props.onClose },
|
||||
h("div", {
|
||||
className: "hermes-kanban-drawer",
|
||||
|
|
@ -1474,6 +1525,9 @@
|
|||
onRemoveParent: removeLink,
|
||||
onAddChild: addChild,
|
||||
onRemoveChild: removeChild,
|
||||
homeChannels: homeChannels,
|
||||
homeBusy: homeBusy,
|
||||
onToggleHomeSub: toggleHomeSubscription,
|
||||
}) : null,
|
||||
data ? h("div", { className: "hermes-kanban-drawer-comment-row" },
|
||||
h(Input, {
|
||||
|
|
@ -1535,6 +1589,11 @@
|
|||
t.created_by ? h(MetaRow, { label: "Created by", value: t.created_by }) : null,
|
||||
),
|
||||
h(StatusActions, { task: t, onPatch: props.onPatch }),
|
||||
h(HomeSubsSection, {
|
||||
homeChannels: props.homeChannels || [],
|
||||
homeBusy: props.homeBusy || {},
|
||||
onToggle: props.onToggleHomeSub,
|
||||
}),
|
||||
h(BodyEditor, {
|
||||
task: t,
|
||||
renderMarkdown: props.renderMarkdown,
|
||||
|
|
@ -1950,6 +2009,43 @@
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
// One toggle per gateway platform the user has a home channel set on
|
||||
// (telegram, discord, slack, etc.). Toggling on creates a kanban_notify_subs
|
||||
// row routed to that platform's home; toggling off removes it. Nothing
|
||||
// renders when no platforms have a home configured — this section stays
|
||||
// invisible for users who haven't set one up.
|
||||
function HomeSubsSection(props) {
|
||||
const channels = props.homeChannels || [];
|
||||
if (channels.length === 0) return null;
|
||||
const busy = props.homeBusy || {};
|
||||
return h("div", { className: "hermes-kanban-section" },
|
||||
h("div", { className: "hermes-kanban-section-head" },
|
||||
"Notify home channels"),
|
||||
h("div", { className: "hermes-kanban-home-subs" },
|
||||
channels.map(function (hc) {
|
||||
const isBusy = !!busy[hc.platform];
|
||||
const label = hc.subscribed ? "✓ " + hc.platform : hc.platform;
|
||||
const title = hc.subscribed
|
||||
? `Sending updates to ${hc.name} (${hc.chat_id}${hc.thread_id ? " / " + hc.thread_id : ""}). Click to stop.`
|
||||
: `Send completed / blocked / gave_up notifications to ${hc.name} (${hc.chat_id}${hc.thread_id ? " / " + hc.thread_id : ""}).`;
|
||||
return h(Button, {
|
||||
key: hc.platform,
|
||||
size: "sm",
|
||||
title: title,
|
||||
disabled: isBusy || !props.onToggle,
|
||||
onClick: function () {
|
||||
if (props.onToggle) props.onToggle(hc.platform, hc.subscribed);
|
||||
},
|
||||
className: hc.subscribed
|
||||
? "hermes-kanban-home-sub hermes-kanban-home-sub--on"
|
||||
: "hermes-kanban-home-sub",
|
||||
}, label);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Register
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
20
plugins/kanban/dashboard/dist/style.css
vendored
20
plugins/kanban/dashboard/dist/style.css
vendored
|
|
@ -351,6 +351,26 @@
|
|||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
/* ---- Home channel subscription toggles (per-platform, per-task) ----- */
|
||||
|
||||
.hermes-kanban-home-subs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.hermes-kanban-home-sub {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.hermes-kanban-home-sub--on {
|
||||
/* Subtly indicate the subscribed state without a hard color change so
|
||||
* dashboard themes stay coherent. Border + tinted background. */
|
||||
border-color: color-mix(in srgb, var(--color-ring) 55%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-ring) 14%, transparent);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.hermes-kanban-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -733,6 +733,155 @@ def get_config():
|
|||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Home-channel subscriptions (per-task, per-platform toggles)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Home channels are a first-class gateway concept — each configured platform
|
||||
# can have exactly one (chat_id, thread_id, name) it considers "home". The
|
||||
# dashboard surfaces these as per-task toggles so a user can opt a specific
|
||||
# task into receiving terminal notifications (completed / blocked / gave_up)
|
||||
# at their telegram/discord/slack home, without touching the CLI.
|
||||
#
|
||||
# The wire format mirrors kanban_db.add_notify_sub — (task_id, platform,
|
||||
# chat_id, thread_id) — so toggle-on creates exactly the same row the
|
||||
# `/kanban create` slash command would, and the existing gateway notifier
|
||||
# watcher delivers events without any additional plumbing.
|
||||
|
||||
|
||||
def _configured_home_channels() -> list[dict]:
|
||||
"""Return every platform that has a home_channel set, fully hydrated.
|
||||
|
||||
Reads the live GatewayConfig so env-var overlays (``TELEGRAM_HOME_CHANNEL``
|
||||
etc.) are honored alongside config.yaml. Returns platforms in a stable
|
||||
order and drops platforms without a home.
|
||||
"""
|
||||
try:
|
||||
from gateway.config import load_gateway_config
|
||||
except Exception:
|
||||
return []
|
||||
try:
|
||||
gw_cfg = load_gateway_config()
|
||||
except Exception:
|
||||
return []
|
||||
result: list[dict] = []
|
||||
for platform, pcfg in gw_cfg.platforms.items():
|
||||
if not pcfg or not pcfg.home_channel:
|
||||
continue
|
||||
hc = pcfg.home_channel
|
||||
result.append({
|
||||
"platform": platform.value,
|
||||
"chat_id": hc.chat_id,
|
||||
"thread_id": hc.thread_id or "",
|
||||
"name": hc.name or "Home",
|
||||
})
|
||||
# Stable order for deterministic UI — platform name alphabetical.
|
||||
result.sort(key=lambda r: r["platform"])
|
||||
return result
|
||||
|
||||
|
||||
def _home_sub_matches(sub: dict, home: dict) -> bool:
|
||||
"""True if a notify_subs row corresponds to the given home channel."""
|
||||
return (
|
||||
sub.get("platform") == home["platform"]
|
||||
and str(sub.get("chat_id", "")) == str(home["chat_id"])
|
||||
and str(sub.get("thread_id") or "") == str(home["thread_id"] or "")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/home-channels")
|
||||
def get_home_channels(
|
||||
task_id: Optional[str] = Query(None),
|
||||
board: Optional[str] = Query(None),
|
||||
):
|
||||
"""List every platform with a home channel, plus whether *task_id*
|
||||
(if given) is currently subscribed to that home.
|
||||
|
||||
When ``task_id`` is omitted, every entry's ``subscribed`` is ``false``
|
||||
— useful for the "no task selected" state of the UI.
|
||||
"""
|
||||
homes = _configured_home_channels()
|
||||
subscribed_homes: set[tuple[str, str, str]] = set()
|
||||
if task_id:
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
subs = kanban_db.list_notify_subs(conn, task_id)
|
||||
finally:
|
||||
conn.close()
|
||||
for sub in subs:
|
||||
key = (
|
||||
str(sub.get("platform") or ""),
|
||||
str(sub.get("chat_id") or ""),
|
||||
str(sub.get("thread_id") or ""),
|
||||
)
|
||||
subscribed_homes.add(key)
|
||||
result = []
|
||||
for home in homes:
|
||||
key = (home["platform"], home["chat_id"], home["thread_id"])
|
||||
result.append({**home, "subscribed": key in subscribed_homes})
|
||||
return {"home_channels": result}
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/home-subscribe/{platform}")
|
||||
def subscribe_home(task_id: str, platform: str, board: Optional[str] = Query(None)):
|
||||
"""Subscribe *task_id* to notifications routed to *platform*'s home channel.
|
||||
|
||||
Idempotent — re-subscribing is a no-op at the DB layer. 404 if the
|
||||
platform has no home channel configured. 404 if the task doesn't exist.
|
||||
"""
|
||||
homes = _configured_home_channels()
|
||||
home = next((h for h in homes if h["platform"] == platform), None)
|
||||
if not home:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No home channel configured for platform {platform!r}. "
|
||||
f"Set one from the messenger via /sethome, or configure "
|
||||
f"gateway.platforms.{platform}.home_channel in config.yaml.",
|
||||
)
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
task = kanban_db.get_task(conn, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
|
||||
kanban_db.add_notify_sub(
|
||||
conn,
|
||||
task_id=task_id,
|
||||
platform=platform,
|
||||
chat_id=home["chat_id"],
|
||||
thread_id=home["thread_id"] or None,
|
||||
)
|
||||
return {"ok": True, "task_id": task_id, "home_channel": home}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.delete("/tasks/{task_id}/home-subscribe/{platform}")
|
||||
def unsubscribe_home(task_id: str, platform: str, board: Optional[str] = Query(None)):
|
||||
"""Remove any notify subscription on *task_id* that matches *platform*'s home."""
|
||||
homes = _configured_home_channels()
|
||||
home = next((h for h in homes if h["platform"] == platform), None)
|
||||
if not home:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No home channel configured for platform {platform!r}.",
|
||||
)
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
kanban_db.remove_notify_sub(
|
||||
conn,
|
||||
task_id=task_id,
|
||||
platform=platform,
|
||||
chat_id=home["chat_id"],
|
||||
thread_id=home["thread_id"] or None,
|
||||
)
|
||||
return {"ok": True, "task_id": task_id, "home_channel": home}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stats (per-profile / per-status counts + oldest-ready age)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajector
|
|||
|
||||
[tool.setuptools.package-data]
|
||||
hermes_cli = ["web_dist/**/*"]
|
||||
gateway = ["assets/**/*"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*"]
|
||||
|
|
|
|||
|
|
@ -304,7 +304,8 @@ class IterationBudget:
|
|||
|
||||
@property
|
||||
def used(self) -> int:
|
||||
return self._used
|
||||
with self._lock:
|
||||
return self._used
|
||||
|
||||
@property
|
||||
def remaining(self) -> int:
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ AUTHOR_MAP = {
|
|||
"14046872+tmimmanuel@users.noreply.github.com": "tmimmanuel",
|
||||
"657290301@qq.com": "IMHaoyan",
|
||||
"revar@users.noreply.github.com": "revaraver",
|
||||
"emelyanenko.kirill@gmail.com": "EmelyanenkoK",
|
||||
# Matrix parity salvage batch (April 2026)
|
||||
"sr@samirusani": "samrusani",
|
||||
"angelclaw@AngelMacBook.local": "angel12",
|
||||
|
|
@ -707,6 +708,20 @@ AUTHOR_MAP = {
|
|||
"59465365+0xsir0000@users.noreply.github.com": "0xsir0000",
|
||||
"lisanhu2014@hotmail.com": "lisanhu",
|
||||
"0668001438@zte.com.cn": "chenyunbo411",
|
||||
"steven_chanin@alum.mit.edu": "stevenchanin",
|
||||
"fiver@example.com": "halmisen",
|
||||
"mayq0422@gmail.com": "yuqianma",
|
||||
"scott@bubble.local": "bassings",
|
||||
"highland0971@users.noreply.github.com": "highland0971",
|
||||
"sudolewis@gmail.com": "lewislulu",
|
||||
"gaurav2301v@gmail.com": "Gaurav23V",
|
||||
"tranquil_flow@protonmail.com": "Tranquil-Flow",
|
||||
"albert748@gmail.com": "albert748",
|
||||
"ntconguit@gmail.com": "0xharryriddle",
|
||||
"lhysdl@gmail.com": "lhysdl",
|
||||
"shemol@163.com": "SherlockShemol",
|
||||
"clawdia@fmercurio-macstudio.local": "fmercurio",
|
||||
"ricardoporsche001@icloud.com": "Ricardo-M-L",
|
||||
"leozeli@qq.com": "leozeli",
|
||||
"linlehao@cuhk.edu.cn": "LehaoLin",
|
||||
"liutong@isacas.ac.cn": "I3eg1nner",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
name: himalaya
|
||||
description: "Himalaya CLI: IMAP/SMTP email from terminal."
|
||||
version: 1.0.0
|
||||
version: 1.1.0
|
||||
author: community
|
||||
license: MIT
|
||||
metadata:
|
||||
|
|
@ -71,8 +71,28 @@ message.send.backend.encryption.type = "start-tls"
|
|||
message.send.backend.login = "you@example.com"
|
||||
message.send.backend.auth.type = "password"
|
||||
message.send.backend.auth.cmd = "pass show email/smtp"
|
||||
|
||||
# Folder aliases (himalaya v1.2.0+ syntax). Required whenever the
|
||||
# server's folder names don't match himalaya's canonical names
|
||||
# (inbox/sent/drafts/trash). Gmail is the common case — see
|
||||
# `references/configuration.md` for the `[Gmail]/Sent Mail` mapping.
|
||||
folder.aliases.inbox = "INBOX"
|
||||
folder.aliases.sent = "Sent"
|
||||
folder.aliases.drafts = "Drafts"
|
||||
folder.aliases.trash = "Trash"
|
||||
```
|
||||
|
||||
> **Heads up on the alias syntax.** Pre-v1.2.0 docs used a
|
||||
> `[accounts.NAME.folder.alias]` sub-section (singular `alias`).
|
||||
> v1.2.0 silently ignores that form — TOML parses fine, but the
|
||||
> alias resolver never reads it, so every lookup falls through to
|
||||
> the canonical name. On Gmail this means save-to-Sent fails *after*
|
||||
> SMTP delivery succeeds, and `himalaya message send` exits non-zero.
|
||||
> Any caller (agent, script, user) that retries on that exit code
|
||||
> will re-run the entire send — including SMTP — producing duplicate
|
||||
> emails to recipients. Always use `folder.aliases.X` (plural, dotted
|
||||
> keys, directly under `[accounts.NAME]`).
|
||||
|
||||
## Hermes Integration Notes
|
||||
|
||||
- **Reading, listing, searching, moving, deleting** all work directly through the terminal tool
|
||||
|
|
|
|||
|
|
@ -27,6 +27,13 @@ message.send.backend.encryption.type = "start-tls"
|
|||
message.send.backend.login = "user@example.com"
|
||||
message.send.backend.auth.type = "password"
|
||||
message.send.backend.auth.raw = "your-password"
|
||||
|
||||
# Folder aliases — required whenever server folder names differ
|
||||
# from himalaya's canonical names. See "Folder Aliases" below.
|
||||
folder.aliases.inbox = "INBOX"
|
||||
folder.aliases.sent = "Sent"
|
||||
folder.aliases.drafts = "Drafts"
|
||||
folder.aliases.trash = "Trash"
|
||||
```
|
||||
|
||||
## Password Options
|
||||
|
|
@ -75,6 +82,16 @@ message.send.backend.encryption.type = "start-tls"
|
|||
message.send.backend.login = "you@gmail.com"
|
||||
message.send.backend.auth.type = "password"
|
||||
message.send.backend.auth.cmd = "pass show google/app-password"
|
||||
|
||||
# Gmail folder mapping. Without these, save-to-Sent fails after
|
||||
# SMTP delivery succeeds (Gmail's Sent folder is `[Gmail]/Sent Mail`,
|
||||
# not `Sent`), and `himalaya message send` exits non-zero. Any
|
||||
# caller that retries on that error will re-run SMTP — duplicate
|
||||
# emails to recipients. Always include this block for Gmail.
|
||||
folder.aliases.inbox = "INBOX"
|
||||
folder.aliases.sent = "[Gmail]/Sent Mail"
|
||||
folder.aliases.drafts = "[Gmail]/Drafts"
|
||||
folder.aliases.trash = "[Gmail]/Trash"
|
||||
```
|
||||
|
||||
**Note:** Gmail requires an App Password if 2FA is enabled.
|
||||
|
|
@ -107,16 +124,42 @@ message.send.backend.auth.cmd = "pass show icloud/app-password"
|
|||
|
||||
## Folder Aliases
|
||||
|
||||
Map custom folder names:
|
||||
Map himalaya's canonical folder names (`inbox`, `sent`, `drafts`,
|
||||
`trash`) to whatever the server actually calls them. Use the
|
||||
v1.2.0 `folder.aliases.X` syntax (plural, dotted keys, directly
|
||||
under `[accounts.NAME]`):
|
||||
|
||||
```toml
|
||||
[accounts.default.folder.alias]
|
||||
[accounts.default]
|
||||
# ... other account config ...
|
||||
|
||||
folder.aliases.inbox = "INBOX"
|
||||
folder.aliases.sent = "Sent"
|
||||
folder.aliases.drafts = "Drafts"
|
||||
folder.aliases.trash = "Trash"
|
||||
```
|
||||
|
||||
The equivalent TOML sub-section form also works in v1.2.0:
|
||||
|
||||
```toml
|
||||
[accounts.default.folder.aliases]
|
||||
inbox = "INBOX"
|
||||
sent = "Sent"
|
||||
drafts = "Drafts"
|
||||
trash = "Trash"
|
||||
```
|
||||
|
||||
> **Don't use the singular `alias` form.** Pre-v1.2.0 docs showed
|
||||
> `[accounts.NAME.folder.alias]` (singular). v1.2.0 silently
|
||||
> ignores that sub-section — TOML parses without error, but the
|
||||
> alias resolver never reads it. Every lookup then falls through
|
||||
> to the canonical name. On Gmail (where `sent` is actually
|
||||
> `[Gmail]/Sent Mail`) this means save-to-Sent fails *after* SMTP
|
||||
> delivery succeeds, and `himalaya message send` exits non-zero.
|
||||
> Any caller (agent, script, user) that retries on that error
|
||||
> code will re-run the send — including SMTP — producing duplicate
|
||||
> emails to recipients. Always use `folder.aliases.X` (plural).
|
||||
|
||||
## Multiple Accounts
|
||||
|
||||
```toml
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
---
|
||||
name: google-workspace
|
||||
description: "Gmail, Calendar, Drive, Docs, Sheets via gws CLI or Python."
|
||||
version: 1.0.0
|
||||
version: 1.0.1
|
||||
author: Nous Research
|
||||
license: MIT
|
||||
required_credential_files:
|
||||
- path: google_token.json
|
||||
description: Google OAuth2 token (created by setup script)
|
||||
- path: google_client_secret.json
|
||||
description: Google OAuth2 client credentials (downloaded from Google Cloud Console)
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [Google, Gmail, Calendar, Drive, Sheets, Docs, Contacts, Email, OAuth]
|
||||
|
|
|
|||
|
|
@ -136,6 +136,21 @@ class TestAutoTitleSession:
|
|||
auto_title_session(db, "sess-1", "hi", "hello")
|
||||
db.set_session_title.assert_called_once_with("sess-1", "New Title")
|
||||
|
||||
def test_invokes_title_callback_after_setting_title(self):
|
||||
db = MagicMock()
|
||||
db.get_session_title.return_value = None
|
||||
seen = []
|
||||
with patch("agent.title_generator.generate_title", return_value="Readable Session"):
|
||||
auto_title_session(
|
||||
db,
|
||||
"sess-1",
|
||||
"hello",
|
||||
"hi there",
|
||||
title_callback=seen.append,
|
||||
)
|
||||
db.set_session_title.assert_called_once_with("sess-1", "Readable Session")
|
||||
assert seen == ["Readable Session"]
|
||||
|
||||
def test_skips_if_generation_fails(self):
|
||||
db = MagicMock()
|
||||
db.get_session_title.return_value = None
|
||||
|
|
@ -182,7 +197,13 @@ class TestMaybeAutoTitle:
|
|||
import time
|
||||
time.sleep(0.3)
|
||||
mock_auto.assert_called_once_with(
|
||||
db, "sess-1", "hello", "hi there", failure_callback=None, main_runtime=None
|
||||
db,
|
||||
"sess-1",
|
||||
"hello",
|
||||
"hi there",
|
||||
failure_callback=None,
|
||||
main_runtime=None,
|
||||
title_callback=None,
|
||||
)
|
||||
|
||||
def test_forwards_failure_callback_to_worker(self):
|
||||
|
|
@ -202,7 +223,13 @@ class TestMaybeAutoTitle:
|
|||
import time
|
||||
time.sleep(0.3)
|
||||
mock_auto.assert_called_once_with(
|
||||
db, "sess-1", "hello", "hi there", failure_callback=_cb, main_runtime=None
|
||||
db,
|
||||
"sess-1",
|
||||
"hello",
|
||||
"hi there",
|
||||
failure_callback=_cb,
|
||||
main_runtime=None,
|
||||
title_callback=None,
|
||||
)
|
||||
|
||||
def test_skips_if_no_response(self):
|
||||
|
|
|
|||
332
tests/cron/test_cron_no_agent.py
Normal file
332
tests/cron/test_cron_no_agent.py
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
"""Tests for cronjob no_agent mode — script-driven jobs that skip the LLM.
|
||||
|
||||
Covers:
|
||||
|
||||
* ``create_job(no_agent=True)`` shape, validation, and serialization.
|
||||
* ``cronjob(action='create', no_agent=True)`` tool-level validation.
|
||||
* ``cronjob(action='update')`` flipping no_agent on/off.
|
||||
* ``scheduler.run_job`` short-circuit path: success/silent/failure.
|
||||
* Shell script support in ``_run_job_script`` (.sh runs via bash).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hermes_env(tmp_path, monkeypatch):
|
||||
"""Isolate HERMES_HOME for each test so jobs/scripts don't leak."""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
(home / "scripts").mkdir()
|
||||
(home / "cron").mkdir()
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
# Reload modules that cache get_hermes_home() at import time.
|
||||
import importlib
|
||||
import hermes_constants
|
||||
importlib.reload(hermes_constants)
|
||||
import cron.jobs
|
||||
importlib.reload(cron.jobs)
|
||||
import cron.scheduler
|
||||
importlib.reload(cron.scheduler)
|
||||
|
||||
return home
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create_job / update_job: data-layer semantics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_job_no_agent_requires_script(hermes_env):
|
||||
from cron.jobs import create_job
|
||||
|
||||
with pytest.raises(ValueError, match="no_agent=True requires a script"):
|
||||
create_job(prompt=None, schedule="every 5m", no_agent=True)
|
||||
|
||||
|
||||
def test_create_job_no_agent_stores_field(hermes_env):
|
||||
from cron.jobs import create_job
|
||||
|
||||
script_path = hermes_env / "scripts" / "watchdog.sh"
|
||||
script_path.write_text("#!/bin/bash\necho hi\n")
|
||||
|
||||
job = create_job(
|
||||
prompt=None,
|
||||
schedule="every 5m",
|
||||
script="watchdog.sh",
|
||||
no_agent=True,
|
||||
deliver="local",
|
||||
)
|
||||
assert job["no_agent"] is True
|
||||
assert job["script"] == "watchdog.sh"
|
||||
# Prompt can be empty/None for no_agent jobs.
|
||||
assert job["prompt"] in (None, "")
|
||||
|
||||
|
||||
def test_create_job_default_is_not_no_agent(hermes_env):
|
||||
from cron.jobs import create_job
|
||||
|
||||
job = create_job(prompt="say hi", schedule="every 5m", deliver="local")
|
||||
assert job.get("no_agent") is False
|
||||
|
||||
|
||||
def test_update_job_roundtrips_no_agent_flag(hermes_env):
|
||||
from cron.jobs import create_job, update_job, get_job
|
||||
|
||||
script_path = hermes_env / "scripts" / "w.sh"
|
||||
script_path.write_text("echo hi\n")
|
||||
job = create_job(prompt=None, schedule="every 5m", script="w.sh", no_agent=True, deliver="local")
|
||||
|
||||
update_job(job["id"], {"no_agent": False})
|
||||
reloaded = get_job(job["id"])
|
||||
assert reloaded["no_agent"] is False
|
||||
|
||||
update_job(job["id"], {"no_agent": True})
|
||||
reloaded = get_job(job["id"])
|
||||
assert reloaded["no_agent"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cronjob tool: API-layer validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cronjob_tool_create_no_agent_without_script_errors(hermes_env):
|
||||
from tools.cronjob_tools import cronjob
|
||||
|
||||
result = json.loads(
|
||||
cronjob(action="create", schedule="every 5m", no_agent=True, deliver="local")
|
||||
)
|
||||
assert result.get("success") is False
|
||||
assert "no_agent=True requires a script" in result.get("error", "")
|
||||
|
||||
|
||||
def test_cronjob_tool_create_no_agent_with_script_succeeds(hermes_env):
|
||||
from tools.cronjob_tools import cronjob
|
||||
|
||||
script_path = hermes_env / "scripts" / "alert.sh"
|
||||
script_path.write_text("#!/bin/bash\necho alert\n")
|
||||
|
||||
result = json.loads(
|
||||
cronjob(
|
||||
action="create",
|
||||
schedule="every 5m",
|
||||
script="alert.sh",
|
||||
no_agent=True,
|
||||
deliver="local",
|
||||
)
|
||||
)
|
||||
assert result.get("success") is True
|
||||
assert result["job"]["no_agent"] is True
|
||||
assert result["job"]["script"] == "alert.sh"
|
||||
|
||||
|
||||
def test_cronjob_tool_update_toggles_no_agent(hermes_env):
|
||||
from tools.cronjob_tools import cronjob
|
||||
|
||||
script_path = hermes_env / "scripts" / "w.sh"
|
||||
script_path.write_text("echo hi\n")
|
||||
|
||||
created = json.loads(
|
||||
cronjob(
|
||||
action="create",
|
||||
schedule="every 5m",
|
||||
script="w.sh",
|
||||
no_agent=True,
|
||||
deliver="local",
|
||||
)
|
||||
)
|
||||
job_id = created["job_id"]
|
||||
|
||||
off = json.loads(cronjob(action="update", job_id=job_id, no_agent=False, prompt="run"))
|
||||
assert off["success"] is True
|
||||
assert off["job"].get("no_agent") in (False, None)
|
||||
|
||||
on = json.loads(cronjob(action="update", job_id=job_id, no_agent=True))
|
||||
assert on["success"] is True
|
||||
assert on["job"]["no_agent"] is True
|
||||
|
||||
|
||||
def test_cronjob_tool_update_no_agent_without_script_errors(hermes_env):
|
||||
"""Flipping no_agent=True on a job that has no script must fail."""
|
||||
from tools.cronjob_tools import cronjob
|
||||
|
||||
created = json.loads(
|
||||
cronjob(action="create", schedule="every 5m", prompt="do a thing", deliver="local")
|
||||
)
|
||||
job_id = created["job_id"]
|
||||
|
||||
result = json.loads(cronjob(action="update", job_id=job_id, no_agent=True))
|
||||
assert result.get("success") is False
|
||||
assert "without a script" in result.get("error", "")
|
||||
|
||||
|
||||
def test_cronjob_tool_create_does_not_require_prompt_when_no_agent(hermes_env):
|
||||
"""The 'prompt or skill required' rule is relaxed for no_agent jobs."""
|
||||
from tools.cronjob_tools import cronjob
|
||||
|
||||
script_path = hermes_env / "scripts" / "w.sh"
|
||||
script_path.write_text("echo hi\n")
|
||||
|
||||
result = json.loads(
|
||||
cronjob(
|
||||
action="create",
|
||||
schedule="every 5m",
|
||||
script="w.sh",
|
||||
no_agent=True,
|
||||
deliver="local",
|
||||
)
|
||||
)
|
||||
assert result.get("success") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# scheduler.run_job: short-circuit behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_run_job_no_agent_success_returns_script_stdout(hermes_env):
|
||||
"""Happy path: script exits 0 with output, delivered verbatim."""
|
||||
from cron.jobs import create_job
|
||||
from cron.scheduler import run_job
|
||||
|
||||
script_path = hermes_env / "scripts" / "alert.sh"
|
||||
script_path.write_text("#!/bin/bash\necho 'RAM 92% on host'\n")
|
||||
|
||||
job = create_job(
|
||||
prompt=None, schedule="every 5m", script="alert.sh", no_agent=True, deliver="local"
|
||||
)
|
||||
success, doc, final_response, error = run_job(job)
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert "RAM 92% on host" in final_response
|
||||
assert "RAM 92% on host" in doc
|
||||
|
||||
|
||||
def test_run_job_no_agent_empty_output_is_silent(hermes_env):
|
||||
"""Empty stdout → SILENT_MARKER, which suppresses delivery downstream."""
|
||||
from cron.jobs import create_job
|
||||
from cron.scheduler import run_job, SILENT_MARKER
|
||||
|
||||
script_path = hermes_env / "scripts" / "quiet.sh"
|
||||
script_path.write_text("#!/bin/bash\n# nothing to say\n")
|
||||
|
||||
job = create_job(
|
||||
prompt=None, schedule="every 5m", script="quiet.sh", no_agent=True, deliver="local"
|
||||
)
|
||||
success, doc, final_response, error = run_job(job)
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert final_response == SILENT_MARKER
|
||||
|
||||
|
||||
def test_run_job_no_agent_wake_gate_is_silent(hermes_env):
|
||||
"""wakeAgent=false gate in stdout triggers a silent run."""
|
||||
from cron.jobs import create_job
|
||||
from cron.scheduler import run_job, SILENT_MARKER
|
||||
|
||||
script_path = hermes_env / "scripts" / "gated.sh"
|
||||
script_path.write_text('#!/bin/bash\necho \'{"wakeAgent": false}\'\n')
|
||||
|
||||
job = create_job(
|
||||
prompt=None, schedule="every 5m", script="gated.sh", no_agent=True, deliver="local"
|
||||
)
|
||||
success, doc, final_response, error = run_job(job)
|
||||
assert success is True
|
||||
assert final_response == SILENT_MARKER
|
||||
|
||||
|
||||
def test_run_job_no_agent_script_failure_delivers_error(hermes_env):
|
||||
"""Non-zero exit → success=False, error alert is the delivered message."""
|
||||
from cron.jobs import create_job
|
||||
from cron.scheduler import run_job
|
||||
|
||||
script_path = hermes_env / "scripts" / "broken.sh"
|
||||
script_path.write_text("#!/bin/bash\necho oops >&2\nexit 3\n")
|
||||
|
||||
job = create_job(
|
||||
prompt=None, schedule="every 5m", script="broken.sh", no_agent=True, deliver="local"
|
||||
)
|
||||
success, doc, final_response, error = run_job(job)
|
||||
assert success is False
|
||||
assert error is not None
|
||||
assert "oops" in final_response or "exited with code 3" in final_response
|
||||
assert "Cron watchdog" in final_response # alert header
|
||||
|
||||
|
||||
def test_run_job_no_agent_never_invokes_aiagent(hermes_env):
|
||||
"""no_agent jobs must NOT import/construct the AIAgent."""
|
||||
from cron.jobs import create_job
|
||||
|
||||
script_path = hermes_env / "scripts" / "alert.sh"
|
||||
script_path.write_text("#!/bin/bash\necho alert\n")
|
||||
|
||||
job = create_job(
|
||||
prompt=None, schedule="every 5m", script="alert.sh", no_agent=True, deliver="local"
|
||||
)
|
||||
|
||||
with patch("run_agent.AIAgent") as ai_mock:
|
||||
from cron.scheduler import run_job
|
||||
|
||||
run_job(job)
|
||||
|
||||
ai_mock.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _run_job_script: shell-script support
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_run_job_script_shell_script_runs_via_bash(hermes_env):
|
||||
""".sh files should execute under /bin/bash even without a shebang line."""
|
||||
from cron.scheduler import _run_job_script
|
||||
|
||||
script_path = hermes_env / "scripts" / "shelly.sh"
|
||||
# No shebang — relies on the interpreter-by-extension rule.
|
||||
script_path.write_text('echo "shell: $BASH_VERSION" | head -c 7\n')
|
||||
|
||||
ok, output = _run_job_script("shelly.sh")
|
||||
assert ok is True
|
||||
assert output.startswith("shell:")
|
||||
|
||||
|
||||
def test_run_job_script_bash_extension_also_runs_via_bash(hermes_env):
|
||||
from cron.scheduler import _run_job_script
|
||||
|
||||
script_path = hermes_env / "scripts" / "thing.bash"
|
||||
script_path.write_text('printf "via bash\\n"\n')
|
||||
|
||||
ok, output = _run_job_script("thing.bash")
|
||||
assert ok is True
|
||||
assert output == "via bash"
|
||||
|
||||
|
||||
def test_run_job_script_python_still_runs_via_python(hermes_env):
|
||||
"""Regression: .py files must keep running via sys.executable."""
|
||||
from cron.scheduler import _run_job_script
|
||||
|
||||
script_path = hermes_env / "scripts" / "py.py"
|
||||
script_path.write_text("import sys\nprint(f'python {sys.version_info.major}')\n")
|
||||
|
||||
ok, output = _run_job_script("py.py")
|
||||
assert ok is True
|
||||
assert output.startswith("python ")
|
||||
|
||||
|
||||
def test_run_job_script_path_traversal_still_blocked(hermes_env):
|
||||
"""Security regression: shell-script support must NOT loosen containment."""
|
||||
from cron.scheduler import _run_job_script
|
||||
|
||||
# Absolute path outside the scripts dir should be rejected.
|
||||
ok, output = _run_job_script("/etc/passwd")
|
||||
assert ok is False
|
||||
assert "Blocked" in output or "outside" in output
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
"""Tests for cron/jobs.py — schedule parsing, job CRUD, and due-job detection."""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import pytest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
|
@ -745,6 +746,100 @@ class TestEnabledToolsets:
|
|||
assert fetched["enabled_toolsets"] == ["web", "delegation"]
|
||||
|
||||
|
||||
class TestMarkJobRunConcurrency:
|
||||
"""Regression tests for concurrent parallel job state writes.
|
||||
|
||||
tick() dispatches multiple jobs to separate threads simultaneously.
|
||||
Without _jobs_file_lock protecting the load→modify→save cycle in
|
||||
mark_job_run(), concurrent writes can clobber each other's updates
|
||||
(last-writer-wins), leaving some jobs with stale last_status / last_run_at.
|
||||
"""
|
||||
|
||||
def test_three_concurrent_mark_job_run_no_overwrites(self, tmp_cron_dir):
|
||||
"""Run mark_job_run() for 3 jobs in parallel threads; all must land correctly."""
|
||||
# Create 3 distinct recurring jobs
|
||||
job_a = create_job(prompt="Job A", schedule="every 1h")
|
||||
job_b = create_job(prompt="Job B", schedule="every 1h")
|
||||
job_c = create_job(prompt="Job C", schedule="every 1h")
|
||||
|
||||
errors: list = []
|
||||
|
||||
def run_mark(job_id: str, success: bool, error_msg=None):
|
||||
try:
|
||||
mark_job_run(job_id, success=success, error=error_msg)
|
||||
except Exception as exc: # pragma: no cover
|
||||
errors.append(exc)
|
||||
|
||||
# Fire all three concurrently
|
||||
threads = [
|
||||
threading.Thread(target=run_mark, args=(job_a["id"], True)),
|
||||
threading.Thread(target=run_mark, args=(job_b["id"], False, "timeout")),
|
||||
threading.Thread(target=run_mark, args=(job_c["id"], True)),
|
||||
]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors, f"Unexpected exceptions in worker threads: {errors}"
|
||||
|
||||
# Verify each job has the correct state — no overwrites
|
||||
a = get_job(job_a["id"])
|
||||
b = get_job(job_b["id"])
|
||||
c = get_job(job_c["id"])
|
||||
|
||||
assert a is not None, "Job A was unexpectedly deleted"
|
||||
assert b is not None, "Job B was unexpectedly deleted"
|
||||
assert c is not None, "Job C was unexpectedly deleted"
|
||||
|
||||
assert a["last_status"] == "ok", f"Job A last_status wrong: {a['last_status']}"
|
||||
assert a["last_run_at"] is not None, "Job A last_run_at not set"
|
||||
assert a["repeat"]["completed"] == 1, f"Job A completed count wrong: {a['repeat']['completed']}"
|
||||
|
||||
assert b["last_status"] == "error", f"Job B last_status wrong: {b['last_status']}"
|
||||
assert b["last_error"] == "timeout", f"Job B last_error wrong: {b['last_error']}"
|
||||
assert b["last_run_at"] is not None, "Job B last_run_at not set"
|
||||
assert b["repeat"]["completed"] == 1, f"Job B completed count wrong: {b['repeat']['completed']}"
|
||||
|
||||
assert c["last_status"] == "ok", f"Job C last_status wrong: {c['last_status']}"
|
||||
assert c["last_run_at"] is not None, "Job C last_run_at not set"
|
||||
assert c["repeat"]["completed"] == 1, f"Job C completed count wrong: {c['repeat']['completed']}"
|
||||
|
||||
def test_repeated_concurrent_runs_accumulate_completed_count(self, tmp_cron_dir):
|
||||
"""Stress test: 10 threads each call mark_job_run on a different job once.
|
||||
|
||||
The completed count for every job must be exactly 1 after all threads finish,
|
||||
confirming no thread's write was silently dropped.
|
||||
"""
|
||||
n = 10
|
||||
jobs = [create_job(prompt=f"Stress job {i}", schedule="every 1h") for i in range(n)]
|
||||
errors: list = []
|
||||
|
||||
def run_mark(job_id: str):
|
||||
try:
|
||||
mark_job_run(job_id, success=True)
|
||||
except Exception as exc: # pragma: no cover
|
||||
errors.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=run_mark, args=(j["id"],)) for j in jobs]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert not errors, f"Unexpected exceptions: {errors}"
|
||||
|
||||
for job in jobs:
|
||||
updated = get_job(job["id"])
|
||||
assert updated is not None, f"Job {job['id']} was deleted"
|
||||
assert updated["last_status"] == "ok", (
|
||||
f"Job {job['id']} has wrong last_status: {updated['last_status']}"
|
||||
)
|
||||
assert updated["repeat"]["completed"] == 1, (
|
||||
f"Job {job['id']} completed count is {updated['repeat']['completed']}, expected 1"
|
||||
)
|
||||
|
||||
|
||||
class TestSaveJobOutput:
|
||||
def test_creates_output_file(self, tmp_cron_dir):
|
||||
output_file = save_job_output("test123", "# Results\nEverything ok.")
|
||||
|
|
|
|||
|
|
@ -1307,6 +1307,103 @@ class TestRunJobConfigLogging:
|
|||
f"Expected 'failed to parse prefill messages' warning in logs, got: {[r.message for r in caplog.records]}"
|
||||
|
||||
|
||||
class TestRunJobConfigEnvVarExpansion:
|
||||
"""Verify that ${VAR} references in config.yaml are expanded when running cron jobs."""
|
||||
|
||||
_RUNTIME = {
|
||||
"api_key": "test-key",
|
||||
"base_url": "https://example.invalid/v1",
|
||||
"provider": "openrouter",
|
||||
"api_mode": "chat_completions",
|
||||
}
|
||||
|
||||
def test_model_env_ref_in_config_yaml_is_expanded(self, tmp_path, monkeypatch):
|
||||
"""${VAR} in config.yaml model: is expanded using env after .env is loaded."""
|
||||
(tmp_path / "config.yaml").write_text("model: ${_HERMES_TEST_CRON_MODEL}\n")
|
||||
monkeypatch.setenv("_HERMES_TEST_CRON_MODEL", "gpt-4o-mini-cron-test")
|
||||
|
||||
job = {"id": "env-job", "name": "env test", "prompt": "hi"}
|
||||
fake_db = MagicMock()
|
||||
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
patch("cron.scheduler._resolve_origin", return_value=None), \
|
||||
patch("dotenv.load_dotenv"), \
|
||||
patch("hermes_state.SessionDB", return_value=fake_db), \
|
||||
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=self._RUNTIME), \
|
||||
patch("run_agent.AIAgent") as mock_agent_cls:
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.run_conversation.return_value = {"final_response": "ok"}
|
||||
mock_agent_cls.return_value = mock_agent
|
||||
success, _, _, error = run_job(job)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
kwargs = mock_agent_cls.call_args.kwargs
|
||||
assert kwargs["model"] == "gpt-4o-mini-cron-test", (
|
||||
f"Expected model='gpt-4o-mini-cron-test', got {kwargs['model']!r}. "
|
||||
"config.yaml ${VAR} was not expanded in the cron execution path."
|
||||
)
|
||||
|
||||
def test_fallback_model_env_ref_in_config_yaml_is_expanded(self, tmp_path, monkeypatch):
|
||||
"""${VAR} in config.yaml fallback_providers model: is expanded."""
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"fallback_providers:\n"
|
||||
" - provider: openrouter\n"
|
||||
" model: ${_HERMES_TEST_CRON_FALLBACK}\n"
|
||||
)
|
||||
monkeypatch.setenv("_HERMES_TEST_CRON_FALLBACK", "gpt-4o-fallback-test")
|
||||
|
||||
job = {"id": "fb-job", "name": "fallback test", "prompt": "hi"}
|
||||
fake_db = MagicMock()
|
||||
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
patch("cron.scheduler._resolve_origin", return_value=None), \
|
||||
patch("dotenv.load_dotenv"), \
|
||||
patch("hermes_state.SessionDB", return_value=fake_db), \
|
||||
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=self._RUNTIME), \
|
||||
patch("run_agent.AIAgent") as mock_agent_cls:
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.run_conversation.return_value = {"final_response": "ok"}
|
||||
mock_agent_cls.return_value = mock_agent
|
||||
run_job(job)
|
||||
|
||||
kwargs = mock_agent_cls.call_args.kwargs
|
||||
fb = kwargs.get("fallback_model") or []
|
||||
fb_list = fb if isinstance(fb, list) else [fb]
|
||||
expanded = [e.get("model") for e in fb_list if isinstance(e, dict)]
|
||||
assert "gpt-4o-fallback-test" in expanded, (
|
||||
f"Expected expanded fallback model in {expanded!r}. "
|
||||
"config.yaml ${VAR} in fallback_providers was not expanded."
|
||||
)
|
||||
|
||||
def test_unexpanded_ref_passthrough_when_var_unset(self, tmp_path, monkeypatch):
|
||||
"""When the env var is not set, the literal ${VAR} is kept verbatim (not crashed)."""
|
||||
(tmp_path / "config.yaml").write_text("model: ${_HERMES_TEST_CRON_UNSET_VAR}\n")
|
||||
monkeypatch.delenv("_HERMES_TEST_CRON_UNSET_VAR", raising=False)
|
||||
|
||||
job = {"id": "unset-job", "name": "unset var test", "prompt": "hi"}
|
||||
fake_db = MagicMock()
|
||||
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
patch("cron.scheduler._resolve_origin", return_value=None), \
|
||||
patch("dotenv.load_dotenv"), \
|
||||
patch("hermes_state.SessionDB", return_value=fake_db), \
|
||||
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=self._RUNTIME), \
|
||||
patch("run_agent.AIAgent") as mock_agent_cls:
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.run_conversation.return_value = {"final_response": "ok"}
|
||||
mock_agent_cls.return_value = mock_agent
|
||||
success, _, _, error = run_job(job)
|
||||
|
||||
assert success is True
|
||||
kwargs = mock_agent_cls.call_args.kwargs
|
||||
# Unresolved refs are kept verbatim — _expand_env_vars contract
|
||||
assert kwargs["model"] == "${_HERMES_TEST_CRON_UNSET_VAR}"
|
||||
|
||||
|
||||
class TestRunJobSkillBacked:
|
||||
def test_run_job_preserves_skill_env_passthrough_into_worker_thread(self, tmp_path):
|
||||
job = {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import os
|
|||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -111,7 +112,7 @@ def adapter(monkeypatch):
|
|||
def make_attachment(
|
||||
*,
|
||||
filename: str,
|
||||
content_type: str,
|
||||
content_type: Optional[str],
|
||||
size: int = 1024,
|
||||
url: str = "https://cdn.discordapp.com/attachments/fake/file",
|
||||
) -> SimpleNamespace:
|
||||
|
|
|
|||
|
|
@ -425,6 +425,91 @@ class TestDispatchMessage(unittest.TestCase):
|
|||
self.assertEqual(event.source.user_name, "John Doe")
|
||||
self.assertEqual(event.source.chat_type, "dm")
|
||||
|
||||
def test_non_allowlisted_sender_dropped(self):
|
||||
"""Senders not in EMAIL_ALLOWED_USERS should be dropped before dispatch."""
|
||||
import asyncio
|
||||
with patch.dict(os.environ, {
|
||||
"EMAIL_ALLOWED_USERS": "hermes@test.com,admin@test.com",
|
||||
}):
|
||||
adapter = self._make_adapter()
|
||||
adapter._message_handler = MagicMock()
|
||||
|
||||
msg_data = {
|
||||
"uid": b"99",
|
||||
"sender_addr": "outsider@evil.com",
|
||||
"sender_name": "Spammer",
|
||||
"subject": "Buy now!!!",
|
||||
"message_id": "<spam@evil.com>",
|
||||
"in_reply_to": "",
|
||||
"body": "Cheap meds",
|
||||
"attachments": [],
|
||||
"date": "",
|
||||
}
|
||||
|
||||
asyncio.run(adapter._dispatch_message(msg_data))
|
||||
# Handler should NOT be called for non-allowlisted sender
|
||||
adapter._message_handler.assert_not_called()
|
||||
# Thread context should NOT be created
|
||||
self.assertNotIn("outsider@evil.com", adapter._thread_context)
|
||||
|
||||
def test_allowlisted_sender_proceeds(self):
|
||||
"""Senders in EMAIL_ALLOWED_USERS should proceed to dispatch normally."""
|
||||
import asyncio
|
||||
with patch.dict(os.environ, {
|
||||
"EMAIL_ALLOWED_USERS": "hermes@test.com,admin@test.com",
|
||||
}):
|
||||
adapter = self._make_adapter()
|
||||
captured_events = []
|
||||
|
||||
async def mock_handler(event):
|
||||
captured_events.append(event)
|
||||
return None
|
||||
|
||||
adapter._message_handler = mock_handler
|
||||
|
||||
msg_data = {
|
||||
"uid": b"100",
|
||||
"sender_addr": "admin@test.com",
|
||||
"sender_name": "Admin",
|
||||
"subject": "Important",
|
||||
"message_id": "<msg@test.com>",
|
||||
"in_reply_to": "",
|
||||
"body": "Hello",
|
||||
"attachments": [],
|
||||
"date": "",
|
||||
}
|
||||
|
||||
asyncio.run(adapter._dispatch_message(msg_data))
|
||||
self.assertEqual(len(captured_events), 1)
|
||||
self.assertEqual(captured_events[0].source.chat_id, "admin@test.com")
|
||||
|
||||
def test_empty_allowlist_allows_all(self):
|
||||
"""When EMAIL_ALLOWED_USERS is not set, all senders should proceed."""
|
||||
import asyncio
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
# Ensure EMAIL_ALLOWED_USERS is not in the env
|
||||
if "EMAIL_ALLOWED_USERS" in os.environ:
|
||||
del os.environ["EMAIL_ALLOWED_USERS"]
|
||||
|
||||
adapter = self._make_adapter()
|
||||
adapter._message_handler = MagicMock()
|
||||
|
||||
msg_data = {
|
||||
"uid": b"101",
|
||||
"sender_addr": "anyone@test.com",
|
||||
"sender_name": "Anyone",
|
||||
"subject": "Hey",
|
||||
"message_id": "<any@test.com>",
|
||||
"in_reply_to": "",
|
||||
"body": "Hi",
|
||||
"attachments": [],
|
||||
"date": "",
|
||||
}
|
||||
|
||||
asyncio.run(adapter._dispatch_message(msg_data))
|
||||
# Handler should be called when no allowlist is configured
|
||||
adapter._message_handler.assert_called()
|
||||
|
||||
|
||||
class TestThreadContext(unittest.TestCase):
|
||||
"""Test email reply threading logic."""
|
||||
|
|
|
|||
|
|
@ -3,25 +3,34 @@
|
|||
A gateway that survives ``hermes update`` keeps pre-update modules cached
|
||||
in ``sys.modules``. Later imports of names added post-update (e.g.
|
||||
``cfg_get`` from PR #17304) raise ImportError against the stale module
|
||||
object. The self-check in ``GatewayRunner._detect_stale_code()`` detects
|
||||
this by comparing boot-time sentinel-file mtimes against current ones,
|
||||
and ``_trigger_stale_code_restart()`` triggers a graceful restart.
|
||||
object.
|
||||
|
||||
The self-check compares the git HEAD SHA at boot to the current SHA on
|
||||
disk. ``hermes update`` always moves HEAD forward via ``git pull``;
|
||||
agent-driven file edits (Hermes editing ``run_agent.py`` / ``gateway/run.py``
|
||||
during a self-dev session) never move HEAD — so the SHA signal is free of
|
||||
the false-positive class that the earlier mtime-based check suffered from.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.run import (
|
||||
GatewayRunner,
|
||||
_compute_repo_mtime,
|
||||
_read_git_head_sha,
|
||||
_STALE_CODE_SENTINELS,
|
||||
_GIT_SHA_CACHE_TTL_SECS,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_tmp_repo(tmp_path: Path) -> Path:
|
||||
"""Create a fake repo with all stale-code sentinel files."""
|
||||
for rel in _STALE_CODE_SENTINELS:
|
||||
|
|
@ -31,109 +40,303 @@ def _make_tmp_repo(tmp_path: Path) -> Path:
|
|||
return tmp_path
|
||||
|
||||
|
||||
def _make_runner(repo_root: Path, *, boot_mtime: float, boot_wall: float):
|
||||
def _make_git_repo(tmp_path: Path, sha: str = "a" * 40, branch: str = "main") -> Path:
|
||||
"""Stamp a minimal .git directory so _read_git_head_sha can resolve a SHA.
|
||||
|
||||
We don't run real git — just lay down the files the reader walks
|
||||
(.git/HEAD pointing at refs/heads/<branch>, refs/heads/<branch>
|
||||
containing the SHA).
|
||||
"""
|
||||
git_dir = tmp_path / ".git"
|
||||
git_dir.mkdir(parents=True, exist_ok=True)
|
||||
(git_dir / "HEAD").write_text(f"ref: refs/heads/{branch}\n")
|
||||
refs_dir = git_dir / "refs" / "heads"
|
||||
refs_dir.mkdir(parents=True, exist_ok=True)
|
||||
(refs_dir / branch).write_text(f"{sha}\n")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _set_head_sha(repo_root: Path, sha: str, branch: str = "main") -> None:
|
||||
"""Rewrite the current branch ref to a new SHA (simulates git pull)."""
|
||||
(repo_root / ".git" / "refs" / "heads" / branch).write_text(f"{sha}\n")
|
||||
|
||||
|
||||
def _make_runner(
|
||||
repo_root: Path,
|
||||
*,
|
||||
boot_sha: str | None,
|
||||
boot_wall: float = None,
|
||||
boot_mtime: float = 0.0,
|
||||
):
|
||||
"""Bare GatewayRunner with just the stale-check attributes set."""
|
||||
if boot_wall is None:
|
||||
boot_wall = time.time()
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._repo_root_for_staleness = repo_root
|
||||
runner._boot_wall_time = boot_wall
|
||||
runner._boot_git_sha = boot_sha
|
||||
runner._boot_repo_mtime = boot_mtime
|
||||
runner._stale_code_notified = set()
|
||||
runner._stale_code_restart_triggered = False
|
||||
runner._cached_current_sha = boot_sha
|
||||
runner._cached_current_sha_at = boot_wall
|
||||
return runner
|
||||
|
||||
|
||||
def test_compute_repo_mtime_returns_newest(tmp_path):
|
||||
"""_compute_repo_mtime returns the newest mtime across sentinel files."""
|
||||
repo = _make_tmp_repo(tmp_path)
|
||||
# ---------------------------------------------------------------------------
|
||||
# _read_git_head_sha — raw SHA reader
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Stamp a baseline mtime across all sentinels
|
||||
baseline = time.time() - 100
|
||||
for rel in _STALE_CODE_SENTINELS:
|
||||
os.utime(repo / rel, (baseline, baseline))
|
||||
|
||||
# Touch one file forward
|
||||
newer = time.time()
|
||||
os.utime(repo / "hermes_cli/config.py", (newer, newer))
|
||||
|
||||
result = _compute_repo_mtime(repo)
|
||||
assert abs(result - newer) < 1.0 # within 1s (filesystem mtime resolution)
|
||||
def test_read_git_head_sha_branch_ref(tmp_path):
|
||||
"""Resolves ref: refs/heads/<branch> → SHA from refs/heads/<branch>."""
|
||||
sha = "b" * 40
|
||||
_make_git_repo(tmp_path, sha=sha, branch="main")
|
||||
assert _read_git_head_sha(tmp_path) == sha
|
||||
|
||||
|
||||
def test_compute_repo_mtime_missing_files_returns_zero(tmp_path):
|
||||
"""Missing sentinel files return 0.0 (treated as 'can't tell' upstream)."""
|
||||
# tmp_path has none of the sentinels
|
||||
assert _compute_repo_mtime(tmp_path) == 0.0
|
||||
def test_read_git_head_sha_detached_head(tmp_path):
|
||||
"""Detached HEAD: .git/HEAD contains the SHA directly."""
|
||||
sha = "c" * 40
|
||||
git_dir = tmp_path / ".git"
|
||||
git_dir.mkdir()
|
||||
(git_dir / "HEAD").write_text(f"{sha}\n")
|
||||
assert _read_git_head_sha(tmp_path) == sha
|
||||
|
||||
|
||||
def test_compute_repo_mtime_partial_files_still_works(tmp_path):
|
||||
"""Partial sentinel presence still returns newest of the readable ones."""
|
||||
(tmp_path / "hermes_cli").mkdir()
|
||||
target = tmp_path / "hermes_cli" / "config.py"
|
||||
target.write_text("# partial\n")
|
||||
target_mtime = time.time() - 50
|
||||
os.utime(target, (target_mtime, target_mtime))
|
||||
|
||||
result = _compute_repo_mtime(tmp_path)
|
||||
assert abs(result - target_mtime) < 1.0
|
||||
def test_read_git_head_sha_packed_refs(tmp_path):
|
||||
"""Falls back to packed-refs when refs/heads/<branch> is missing."""
|
||||
sha = "d" * 40
|
||||
git_dir = tmp_path / ".git"
|
||||
git_dir.mkdir()
|
||||
(git_dir / "HEAD").write_text("ref: refs/heads/main\n")
|
||||
# No refs/heads/main file — only packed-refs
|
||||
(git_dir / "packed-refs").write_text(
|
||||
f"# pack-refs with: peeled fully-peeled sorted\n"
|
||||
f"{sha} refs/heads/main\n"
|
||||
)
|
||||
assert _read_git_head_sha(tmp_path) == sha
|
||||
|
||||
|
||||
def test_detect_stale_code_false_when_no_boot_snapshot(tmp_path):
|
||||
"""No boot snapshot → can't tell → not stale (no restart loop)."""
|
||||
repo = _make_tmp_repo(tmp_path)
|
||||
runner = _make_runner(repo, boot_mtime=0.0, boot_wall=0.0)
|
||||
def test_read_git_head_sha_worktree_gitdir_file(tmp_path):
|
||||
"""Worktree: .git is a file with `gitdir: <path>` pointing to the real git dir.
|
||||
|
||||
Real git worktrees store shared refs (refs/heads/*) in the main
|
||||
checkout's .git/ and write a ``commondir`` pointer into the
|
||||
worktree-gitdir. The reader must follow commondir to resolve the
|
||||
branch ref — this is the layout Hermes dev sessions actually use.
|
||||
"""
|
||||
sha = "e" * 40
|
||||
# Main repo layout
|
||||
main_repo = tmp_path / "main-repo"
|
||||
main_git = main_repo / ".git"
|
||||
(main_git / "refs" / "heads").mkdir(parents=True)
|
||||
(main_git / "HEAD").write_text("ref: refs/heads/main\n")
|
||||
(main_git / "refs" / "heads" / "main").write_text("0" * 40 + "\n")
|
||||
|
||||
# Worktree lives in main-repo/.git/worktrees/<name>/
|
||||
worktree_git_dir = main_git / "worktrees" / "feature"
|
||||
worktree_git_dir.mkdir(parents=True)
|
||||
(worktree_git_dir / "HEAD").write_text("ref: refs/heads/feature\n")
|
||||
# commondir points back at the main .git (relative path, "../..")
|
||||
(worktree_git_dir / "commondir").write_text("../..\n")
|
||||
# Feature branch ref lives in the shared refs/heads
|
||||
(main_git / "refs" / "heads" / "feature").write_text(f"{sha}\n")
|
||||
|
||||
# Worktree checkout with .git file pointing at worktree_git_dir
|
||||
worktree = tmp_path / "wt"
|
||||
worktree.mkdir()
|
||||
(worktree / ".git").write_text(f"gitdir: {worktree_git_dir}\n")
|
||||
|
||||
assert _read_git_head_sha(worktree) == sha
|
||||
|
||||
|
||||
def test_read_git_head_sha_worktree_packed_refs_in_common(tmp_path):
|
||||
"""Worktree + packed-refs in common dir: fallback still resolves."""
|
||||
sha = "f" * 40
|
||||
main_repo = tmp_path / "main-repo"
|
||||
main_git = main_repo / ".git"
|
||||
main_git.mkdir(parents=True)
|
||||
(main_git / "HEAD").write_text("ref: refs/heads/main\n")
|
||||
# packed-refs in the common (main) .git
|
||||
(main_git / "packed-refs").write_text(
|
||||
f"# pack-refs with: peeled fully-peeled sorted\n"
|
||||
f"{sha} refs/heads/feature\n"
|
||||
)
|
||||
|
||||
worktree_git_dir = main_git / "worktrees" / "feature"
|
||||
worktree_git_dir.mkdir(parents=True)
|
||||
(worktree_git_dir / "HEAD").write_text("ref: refs/heads/feature\n")
|
||||
(worktree_git_dir / "commondir").write_text("../..\n")
|
||||
|
||||
worktree = tmp_path / "wt"
|
||||
worktree.mkdir()
|
||||
(worktree / ".git").write_text(f"gitdir: {worktree_git_dir}\n")
|
||||
|
||||
assert _read_git_head_sha(worktree) == sha
|
||||
|
||||
|
||||
def test_read_git_head_sha_no_git_returns_none(tmp_path):
|
||||
"""No .git dir → None (non-git install, safely disables the check)."""
|
||||
assert _read_git_head_sha(tmp_path) is None
|
||||
|
||||
|
||||
def test_read_git_head_sha_malformed_head_returns_none(tmp_path):
|
||||
"""Empty HEAD file → None (don't loop on corrupt repos)."""
|
||||
git_dir = tmp_path / ".git"
|
||||
git_dir.mkdir()
|
||||
(git_dir / "HEAD").write_text("")
|
||||
assert _read_git_head_sha(tmp_path) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _detect_stale_code — the main regression guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_detect_stale_code_false_when_sha_unchanged(tmp_path):
|
||||
"""Boot SHA == current SHA → not stale (no restart)."""
|
||||
sha = "a" * 40
|
||||
_make_git_repo(tmp_path, sha=sha)
|
||||
runner = _make_runner(tmp_path, boot_sha=sha)
|
||||
# Force fresh read by expiring the cache
|
||||
runner._cached_current_sha_at = 0.0
|
||||
assert runner._detect_stale_code() is False
|
||||
|
||||
|
||||
def test_detect_stale_code_false_when_files_unchanged(tmp_path):
|
||||
"""Source files at boot mtime → not stale."""
|
||||
repo = _make_tmp_repo(tmp_path)
|
||||
# Freeze all sentinels to the same mtime
|
||||
baseline = time.time() - 100
|
||||
for rel in _STALE_CODE_SENTINELS:
|
||||
os.utime(repo / rel, (baseline, baseline))
|
||||
|
||||
runner = _make_runner(repo, boot_mtime=baseline, boot_wall=baseline)
|
||||
assert runner._detect_stale_code() is False
|
||||
|
||||
|
||||
def test_detect_stale_code_true_after_update(tmp_path):
|
||||
"""Sentinel files newer than boot snapshot → stale."""
|
||||
repo = _make_tmp_repo(tmp_path)
|
||||
baseline = time.time() - 100
|
||||
for rel in _STALE_CODE_SENTINELS:
|
||||
os.utime(repo / rel, (baseline, baseline))
|
||||
|
||||
runner = _make_runner(repo, boot_mtime=baseline, boot_wall=baseline)
|
||||
|
||||
# Simulate hermes update touching config.py
|
||||
new_mtime = time.time()
|
||||
os.utime(repo / "hermes_cli/config.py", (new_mtime, new_mtime))
|
||||
|
||||
def test_detect_stale_code_true_after_git_pull(tmp_path):
|
||||
"""Boot SHA != current SHA → stale (hermes update happened)."""
|
||||
boot_sha = "a" * 40
|
||||
_make_git_repo(tmp_path, sha=boot_sha)
|
||||
runner = _make_runner(tmp_path, boot_sha=boot_sha)
|
||||
# Simulate git pull moving HEAD forward
|
||||
_set_head_sha(tmp_path, "b" * 40)
|
||||
runner._cached_current_sha_at = 0.0 # expire cache
|
||||
assert runner._detect_stale_code() is True
|
||||
|
||||
|
||||
def test_detect_stale_code_ignores_subsecond_drift(tmp_path):
|
||||
"""2-second slack prevents false positives on coarse-mtime filesystems."""
|
||||
repo = _make_tmp_repo(tmp_path)
|
||||
baseline = time.time() - 100
|
||||
def test_detect_stale_code_ignores_agent_file_edits(tmp_path):
|
||||
"""THE CORE REGRESSION: agent edits to source files do NOT trigger restart.
|
||||
|
||||
This is the motivating incident for the SHA-based check. Under the
|
||||
previous mtime-based scheme, any ``patch`` / ``write_file`` call
|
||||
against run_agent.py / gateway/run.py / hermes_cli/config.py would
|
||||
flip the stale-check to True and force a gateway restart on the
|
||||
next message — even though no update actually happened. SHA
|
||||
comparison decouples the two: git HEAD only moves on ``git pull``,
|
||||
never on file writes.
|
||||
"""
|
||||
sha = "a" * 40
|
||||
_make_git_repo(tmp_path, sha=sha)
|
||||
_make_tmp_repo(tmp_path) # lay down sentinel files too
|
||||
runner = _make_runner(tmp_path, boot_sha=sha)
|
||||
|
||||
# Simulate the agent editing run_agent.py and gateway/run.py with
|
||||
# mtimes far into the future — exactly the scenario that used to
|
||||
# false-positive the old mtime check.
|
||||
future = time.time() + 10_000
|
||||
for rel in _STALE_CODE_SENTINELS:
|
||||
os.utime(repo / rel, (baseline, baseline))
|
||||
p = tmp_path / rel
|
||||
if p.is_file():
|
||||
p.write_text("# agent just edited this\n")
|
||||
os.utime(p, (future, future))
|
||||
|
||||
runner = _make_runner(repo, boot_mtime=baseline, boot_wall=baseline)
|
||||
|
||||
# Touch config.py 1s newer — within the 2s slack → not stale
|
||||
os.utime(repo / "hermes_cli/config.py", (baseline + 1.0, baseline + 1.0))
|
||||
# HEAD SHA has NOT moved — check must stay False.
|
||||
runner._cached_current_sha_at = 0.0 # expire cache
|
||||
assert runner._detect_stale_code() is False
|
||||
|
||||
# Touch 5s newer → stale
|
||||
os.utime(repo / "hermes_cli/config.py", (baseline + 5.0, baseline + 5.0))
|
||||
assert runner._detect_stale_code() is True
|
||||
|
||||
def test_detect_stale_code_false_for_non_git_install(tmp_path):
|
||||
"""Non-git install (no .git dir) → check disabled, never fires."""
|
||||
# No .git dir at all; runner's boot_sha is None
|
||||
runner = _make_runner(tmp_path, boot_sha=None)
|
||||
# Even if we pretended the current SHA differed, the check should
|
||||
# short-circuit on boot_sha=None and return False.
|
||||
assert runner._detect_stale_code() is False
|
||||
|
||||
|
||||
def test_detect_stale_code_false_when_no_boot_wall_time(tmp_path):
|
||||
"""No boot snapshot at all → can't tell → not stale (no restart loop)."""
|
||||
runner = _make_runner(tmp_path, boot_sha="a" * 40, boot_wall=0.0)
|
||||
assert runner._detect_stale_code() is False
|
||||
|
||||
|
||||
def test_detect_stale_code_handles_disappearing_git_dir(tmp_path):
|
||||
""".git vanishes mid-run → current_sha = None → not stale (don't loop)."""
|
||||
sha = "a" * 40
|
||||
_make_git_repo(tmp_path, sha=sha)
|
||||
runner = _make_runner(tmp_path, boot_sha=sha)
|
||||
# Nuke the git dir after boot
|
||||
import shutil
|
||||
shutil.rmtree(tmp_path / ".git")
|
||||
runner._cached_current_sha_at = 0.0 # expire cache
|
||||
assert runner._detect_stale_code() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SHA cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_current_sha_cache_collapses_bursts(tmp_path, monkeypatch):
|
||||
"""Consecutive calls inside the TTL window reuse the cached SHA."""
|
||||
sha = "a" * 40
|
||||
_make_git_repo(tmp_path, sha=sha)
|
||||
runner = _make_runner(tmp_path, boot_sha=sha)
|
||||
|
||||
read_calls = {"n": 0}
|
||||
real_reader = _read_git_head_sha
|
||||
|
||||
def counting_reader(repo_root):
|
||||
read_calls["n"] += 1
|
||||
return real_reader(repo_root)
|
||||
|
||||
from gateway import run as run_mod
|
||||
monkeypatch.setattr(run_mod, "_read_git_head_sha", counting_reader)
|
||||
|
||||
# Force cache expiry so the first call definitely reads
|
||||
runner._cached_current_sha_at = 0.0
|
||||
runner._current_git_sha_cached()
|
||||
first_count = read_calls["n"]
|
||||
|
||||
# Immediate second/third calls should hit cache (no new read)
|
||||
runner._current_git_sha_cached()
|
||||
runner._current_git_sha_cached()
|
||||
assert read_calls["n"] == first_count
|
||||
|
||||
|
||||
def test_current_sha_cache_expires_after_ttl(tmp_path, monkeypatch):
|
||||
"""After _GIT_SHA_CACHE_TTL_SECS elapses, a fresh read happens."""
|
||||
sha = "a" * 40
|
||||
_make_git_repo(tmp_path, sha=sha)
|
||||
runner = _make_runner(tmp_path, boot_sha=sha)
|
||||
|
||||
read_calls = {"n": 0}
|
||||
real_reader = _read_git_head_sha
|
||||
|
||||
def counting_reader(repo_root):
|
||||
read_calls["n"] += 1
|
||||
return real_reader(repo_root)
|
||||
|
||||
from gateway import run as run_mod
|
||||
monkeypatch.setattr(run_mod, "_read_git_head_sha", counting_reader)
|
||||
|
||||
runner._cached_current_sha_at = 0.0
|
||||
runner._current_git_sha_cached()
|
||||
first = read_calls["n"]
|
||||
|
||||
# Age the cache past the TTL
|
||||
runner._cached_current_sha_at = time.time() - (_GIT_SHA_CACHE_TTL_SECS + 1.0)
|
||||
runner._current_git_sha_cached()
|
||||
assert read_calls["n"] == first + 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _trigger_stale_code_restart — idempotency preserved
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_trigger_stale_code_restart_is_idempotent(tmp_path):
|
||||
"""Calling _trigger_stale_code_restart twice only requests restart once."""
|
||||
repo = _make_tmp_repo(tmp_path)
|
||||
runner = _make_runner(repo, boot_mtime=1.0, boot_wall=1.0)
|
||||
sha = "a" * 40
|
||||
_make_git_repo(tmp_path, sha=sha)
|
||||
runner = _make_runner(tmp_path, boot_sha=sha)
|
||||
|
||||
calls = []
|
||||
|
||||
|
|
@ -153,8 +356,9 @@ def test_trigger_stale_code_restart_is_idempotent(tmp_path):
|
|||
|
||||
def test_trigger_stale_code_restart_survives_request_failure(tmp_path):
|
||||
"""If request_restart raises, we swallow and mark as triggered anyway."""
|
||||
repo = _make_tmp_repo(tmp_path)
|
||||
runner = _make_runner(repo, boot_mtime=1.0, boot_wall=1.0)
|
||||
sha = "a" * 40
|
||||
_make_git_repo(tmp_path, sha=sha)
|
||||
runner = _make_runner(tmp_path, boot_sha=sha)
|
||||
|
||||
def boom(*, detached=False, via_service=False):
|
||||
raise RuntimeError("no event loop")
|
||||
|
|
@ -168,56 +372,41 @@ def test_trigger_stale_code_restart_survives_request_failure(tmp_path):
|
|||
assert runner._stale_code_restart_triggered is True
|
||||
|
||||
|
||||
def test_detect_stale_code_handles_disappearing_repo_root(tmp_path):
|
||||
"""If the repo root vanishes after boot, return False (don't loop)."""
|
||||
repo = _make_tmp_repo(tmp_path)
|
||||
baseline = time.time() - 100
|
||||
for rel in _STALE_CODE_SENTINELS:
|
||||
os.utime(repo / rel, (baseline, baseline))
|
||||
|
||||
runner = _make_runner(repo, boot_mtime=baseline, boot_wall=baseline)
|
||||
|
||||
# Remove all sentinel files — _compute_repo_mtime returns 0.0
|
||||
for rel in _STALE_CODE_SENTINELS:
|
||||
(repo / rel).unlink(missing_ok=True)
|
||||
|
||||
assert runner._detect_stale_code() is False
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Class-level defaults — tests that build bare runners via object.__new__
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_class_level_defaults_prevent_uninitialized_access():
|
||||
"""Partial construction via object.__new__ must not crash _detect_stale_code."""
|
||||
runner = object.__new__(GatewayRunner)
|
||||
# Don't set any instance attrs — class-level defaults should kick in
|
||||
runner._repo_root_for_staleness = Path(".")
|
||||
# _boot_wall_time / _boot_repo_mtime fall through to class defaults (0.0)
|
||||
# _boot_wall_time / _boot_git_sha fall through to class defaults
|
||||
# (0.0 and None respectively)
|
||||
assert runner._detect_stale_code() is False
|
||||
# _stale_code_restart_triggered falls through to class default (False)
|
||||
assert runner._stale_code_restart_triggered is False
|
||||
|
||||
|
||||
def test_init_captures_boot_snapshot(monkeypatch, tmp_path):
|
||||
"""GatewayRunner.__init__ captures a usable stale-code baseline."""
|
||||
# Stub out the heavy parts of __init__ we don't need. We only want
|
||||
# to prove the stale-code snapshot is captured before anything else.
|
||||
from gateway import run as run_mod
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy mtime reader kept for compatibility — light sanity check only
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
calls = {}
|
||||
def test_compute_repo_mtime_still_returns_newest(tmp_path):
|
||||
"""_compute_repo_mtime remains available for any legacy callers."""
|
||||
repo = _make_tmp_repo(tmp_path)
|
||||
|
||||
def fake_compute(repo_root):
|
||||
calls["repo_root"] = repo_root
|
||||
return 1234567890.0
|
||||
baseline = time.time() - 100
|
||||
for rel in _STALE_CODE_SENTINELS:
|
||||
os.utime(repo / rel, (baseline, baseline))
|
||||
|
||||
monkeypatch.setattr(run_mod, "_compute_repo_mtime", fake_compute)
|
||||
newer = time.time()
|
||||
os.utime(repo / "hermes_cli/config.py", (newer, newer))
|
||||
|
||||
# Build a runner without running the full __init__ — then manually
|
||||
# exercise the stale-check init block that __init__ contains.
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._boot_wall_time = time.time()
|
||||
runner._repo_root_for_staleness = Path(run_mod.__file__).resolve().parent.parent
|
||||
runner._boot_repo_mtime = run_mod._compute_repo_mtime(runner._repo_root_for_staleness)
|
||||
runner._stale_code_notified = set()
|
||||
runner._stale_code_restart_triggered = False
|
||||
result = _compute_repo_mtime(repo)
|
||||
assert abs(result - newer) < 1.0
|
||||
|
||||
assert runner._boot_repo_mtime == 1234567890.0
|
||||
assert calls["repo_root"] == runner._repo_root_for_staleness
|
||||
assert runner._boot_wall_time > 0
|
||||
|
||||
def test_compute_repo_mtime_missing_files_returns_zero(tmp_path):
|
||||
"""Legacy sanity: missing sentinels → 0.0."""
|
||||
assert _compute_repo_mtime(tmp_path) == 0.0
|
||||
|
|
|
|||
1115
tests/gateway/test_telegram_topic_mode.py
Normal file
1115
tests/gateway/test_telegram_topic_mode.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -173,3 +173,78 @@ class TestCmdUpdateBranchFallback:
|
|||
mock_input.assert_not_called()
|
||||
captured = capsys.readouterr()
|
||||
assert "Non-interactive session" in captured.out
|
||||
|
||||
|
||||
class TestCmdUpdateProfileSkillSync:
|
||||
"""cmd_update syncs bundled skills to all profiles, including the active one.
|
||||
|
||||
Regression guard for #16176: previously the active profile was excluded
|
||||
from the seed_profile_skills loop, leaving it on stale skill content.
|
||||
"""
|
||||
|
||||
@patch("shutil.which", return_value=None)
|
||||
@patch("subprocess.run")
|
||||
def test_active_profile_included_in_skill_sync(
|
||||
self, mock_run, _mock_which, mock_args, capsys
|
||||
):
|
||||
from pathlib import Path
|
||||
|
||||
mock_run.side_effect = _make_run_side_effect(
|
||||
branch="main", verify_ok=True, commit_count="1"
|
||||
)
|
||||
|
||||
default_p = SimpleNamespace(name="default", path=Path("/fake/.hermes"))
|
||||
active_p = SimpleNamespace(name="bit", path=Path("/fake/.hermes/profiles/bit"))
|
||||
other_p = SimpleNamespace(name="work", path=Path("/fake/.hermes/profiles/work"))
|
||||
all_profiles = [default_p, active_p, other_p]
|
||||
|
||||
synced_paths = []
|
||||
|
||||
def fake_seed(path, quiet=False):
|
||||
synced_paths.append(path)
|
||||
return {"copied": [], "updated": [], "user_modified": []}
|
||||
|
||||
empty_sync = {"copied": [], "updated": [], "user_modified": [], "cleaned": []}
|
||||
|
||||
with (
|
||||
patch("hermes_cli.profiles.list_profiles", return_value=all_profiles),
|
||||
patch("hermes_cli.profiles.seed_profile_skills", side_effect=fake_seed),
|
||||
patch("tools.skills_sync.sync_skills", return_value=empty_sync),
|
||||
):
|
||||
cmd_update(mock_args)
|
||||
|
||||
assert active_p.path in synced_paths, (
|
||||
f"Active profile 'bit' must be included in skill sync; got: {synced_paths}"
|
||||
)
|
||||
assert set(synced_paths) == {p.path for p in all_profiles}, (
|
||||
f"All profiles must be synced; got: {synced_paths}"
|
||||
)
|
||||
|
||||
@patch("shutil.which", return_value=None)
|
||||
@patch("subprocess.run")
|
||||
def test_single_profile_default_is_synced(
|
||||
self, mock_run, _mock_which, mock_args, capsys
|
||||
):
|
||||
from pathlib import Path
|
||||
|
||||
mock_run.side_effect = _make_run_side_effect(
|
||||
branch="main", verify_ok=True, commit_count="1"
|
||||
)
|
||||
|
||||
default_p = SimpleNamespace(name="default", path=Path("/fake/.hermes"))
|
||||
synced_paths = []
|
||||
|
||||
def fake_seed(path, quiet=False):
|
||||
synced_paths.append(path)
|
||||
return {"copied": [], "updated": [], "user_modified": []}
|
||||
|
||||
empty_sync = {"copied": [], "updated": [], "user_modified": [], "cleaned": []}
|
||||
|
||||
with (
|
||||
patch("hermes_cli.profiles.list_profiles", return_value=[default_p]),
|
||||
patch("hermes_cli.profiles.seed_profile_skills", side_effect=fake_seed),
|
||||
patch("tools.skills_sync.sync_skills", return_value=empty_sync),
|
||||
):
|
||||
cmd_update(mock_args)
|
||||
|
||||
assert default_p.path in synced_paths
|
||||
|
|
|
|||
|
|
@ -109,6 +109,12 @@ class TestResolveCommand:
|
|||
assert resolve_command("reload_mcp").name == "reload-mcp"
|
||||
assert resolve_command("tasks").name == "agents"
|
||||
|
||||
def test_topic_is_gateway_command(self):
|
||||
topic = resolve_command("topic")
|
||||
assert topic is not None
|
||||
assert topic.name == "topic"
|
||||
assert "topic" in GATEWAY_KNOWN_COMMANDS
|
||||
|
||||
def test_leading_slash_stripped(self):
|
||||
assert resolve_command("/help").name == "help"
|
||||
assert resolve_command("/bg").name == "background"
|
||||
|
|
|
|||
|
|
@ -663,3 +663,79 @@ def test_run_doctor_opencode_go_skips_invalid_models_probe(monkeypatch, tmp_path
|
|||
)
|
||||
assert not any(url == "https://opencode.ai/zen/go/v1/models" for url, _, _ in calls)
|
||||
assert not any("opencode" in url.lower() and "models" in url.lower() for url, _, _ in calls)
|
||||
|
||||
|
||||
class TestGitHubTokenCheck:
|
||||
"""Tests for GitHub token / gh auth detection in doctor."""
|
||||
|
||||
def test_no_token_and_not_gh_authenticated_shows_warn(self, monkeypatch, tmp_path):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("PATH", "/nonexistent") # gh not found
|
||||
|
||||
from hermes_cli.doctor import run_doctor, _DHH
|
||||
import io, contextlib
|
||||
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
run_doctor(Namespace(fix=False))
|
||||
out = buf.getvalue()
|
||||
|
||||
assert "No GITHUB_TOKEN" in out
|
||||
assert "60 req/hr" in out
|
||||
|
||||
def test_token_env_present_shows_ok(self, monkeypatch, tmp_path):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("GITHUB_TOKEN", "ghp_test123")
|
||||
monkeypatch.setenv("PATH", "/nonexistent") # gh not found
|
||||
|
||||
from hermes_cli.doctor import run_doctor
|
||||
import io, contextlib
|
||||
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
run_doctor(Namespace(fix=False))
|
||||
out = buf.getvalue()
|
||||
|
||||
assert "GitHub token configured" in out
|
||||
|
||||
def test_gh_authenticated_without_env_token_shows_ok(self, monkeypatch, tmp_path):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
# No GITHUB_TOKEN or GH_TOKEN
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
|
||||
# Mock gh to return success
|
||||
import shutil
|
||||
real_which = shutil.which
|
||||
def mock_which(cmd):
|
||||
return "/usr/local/bin/gh" if cmd == "gh" else real_which(cmd)
|
||||
monkeypatch.setattr(shutil, "which", mock_which)
|
||||
|
||||
call_log = []
|
||||
def mock_run(cmd, **kwargs):
|
||||
call_log.append(cmd)
|
||||
if cmd[:2] == ["gh", "auth"]:
|
||||
result = types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
else:
|
||||
result = types.SimpleNamespace(returncode=1, stdout="", stderr="")
|
||||
return result
|
||||
|
||||
import subprocess
|
||||
monkeypatch.setattr(subprocess, "run", mock_run)
|
||||
|
||||
from hermes_cli.doctor import run_doctor
|
||||
import io, contextlib
|
||||
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
run_doctor(Namespace(fix=False))
|
||||
out = buf.getvalue()
|
||||
|
||||
assert "gh auth" in str(call_log) or any(c[0] == "gh" for c in call_log), f"gh not called: {call_log}"
|
||||
assert "GitHub authenticated via gh CLI" in out or "token configured" in out
|
||||
|
|
|
|||
|
|
@ -182,6 +182,43 @@ class TestGeneratedSystemdUnits:
|
|||
|
||||
assert "/home/test/.nvm/versions/node/v24.14.0/bin" in unit
|
||||
|
||||
def test_user_unit_includes_wsl_windows_interop_paths(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway_cli, "is_wsl", lambda: True)
|
||||
monkeypatch.setenv(
|
||||
"PATH",
|
||||
"/usr/local/bin:/mnt/c/WINDOWS/system32:/mnt/c/WINDOWS/System32/WindowsPowerShell/v1.0/",
|
||||
)
|
||||
monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: None)
|
||||
|
||||
unit = gateway_cli.generate_systemd_unit(system=False)
|
||||
|
||||
assert "/mnt/c/WINDOWS/system32" in unit
|
||||
assert "/mnt/c/WINDOWS/System32/WindowsPowerShell/v1.0/" in unit
|
||||
|
||||
def test_user_unit_omits_windows_interop_paths_outside_wsl(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway_cli, "is_wsl", lambda: False)
|
||||
monkeypatch.setenv("PATH", "/usr/local/bin:/mnt/c/WINDOWS/system32")
|
||||
monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: None)
|
||||
|
||||
unit = gateway_cli.generate_systemd_unit(system=False)
|
||||
|
||||
assert "/mnt/c/WINDOWS/system32" not in unit
|
||||
|
||||
def test_system_unit_includes_wsl_windows_interop_paths(self, monkeypatch):
|
||||
monkeypatch.setattr(gateway_cli, "is_wsl", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"_system_service_identity",
|
||||
lambda run_as_user=None: ("alice", "alice", "/home/alice"),
|
||||
)
|
||||
monkeypatch.setattr(gateway_cli, "_hermes_home_for_target_user", lambda home: "/home/alice/.hermes")
|
||||
monkeypatch.setenv("PATH", "/usr/local/bin:/mnt/c/WINDOWS/system32")
|
||||
monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: None)
|
||||
|
||||
unit = gateway_cli.generate_systemd_unit(system=True, run_as_user="alice")
|
||||
|
||||
assert "/mnt/c/WINDOWS/system32" in unit
|
||||
|
||||
def test_system_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(self):
|
||||
unit = gateway_cli.generate_systemd_unit(system=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -401,6 +401,103 @@ class TestOllamaCloudProvidersNew:
|
|||
assert pdef.transport == "openai_chat"
|
||||
|
||||
|
||||
# ── Cloud Suffix Stripping ──
|
||||
|
||||
class TestOllamaCloudSuffixStripping:
|
||||
"""models.dev appends :cloud / -cloud suffixes that the live API omits.
|
||||
|
||||
fetch_ollama_cloud_models() must normalise these before the dedup merge so
|
||||
users never see broken IDs like 'kimi-k2.6:cloud' in the model picker.
|
||||
"""
|
||||
|
||||
def test_strips_colon_cloud_suffix(self, tmp_path, monkeypatch):
|
||||
""":cloud suffix from models.dev is stripped before merge."""
|
||||
from hermes_cli.models import fetch_ollama_cloud_models
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
|
||||
|
||||
mock_mdev = {
|
||||
"ollama-cloud": {
|
||||
"models": {"kimi-k2.6:cloud": {"tool_call": True}}
|
||||
}
|
||||
}
|
||||
with patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev):
|
||||
result = fetch_ollama_cloud_models(force_refresh=True)
|
||||
|
||||
assert "kimi-k2.6" in result
|
||||
assert "kimi-k2.6:cloud" not in result
|
||||
|
||||
def test_strips_dash_cloud_suffix(self, tmp_path, monkeypatch):
|
||||
"""-cloud suffix from models.dev is stripped before merge."""
|
||||
from hermes_cli.models import fetch_ollama_cloud_models
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
|
||||
|
||||
mock_mdev = {
|
||||
"ollama-cloud": {
|
||||
"models": {"qwen3-coder:480b-cloud": {"tool_call": True}}
|
||||
}
|
||||
}
|
||||
with patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev):
|
||||
result = fetch_ollama_cloud_models(force_refresh=True)
|
||||
|
||||
assert "qwen3-coder:480b" in result
|
||||
assert "qwen3-coder:480b-cloud" not in result
|
||||
|
||||
def test_no_duplicate_when_live_clean_and_mdev_suffixed(self, tmp_path, monkeypatch):
|
||||
"""Live API returns clean ID; mdev has :cloud variant — result has exactly one entry."""
|
||||
from hermes_cli.models import fetch_ollama_cloud_models
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setenv("OLLAMA_API_KEY", "test-key")
|
||||
|
||||
mock_mdev = {
|
||||
"ollama-cloud": {
|
||||
"models": {
|
||||
"kimi-k2.6:cloud": {"tool_call": True},
|
||||
"glm-5.1:cloud": {"tool_call": True},
|
||||
}
|
||||
}
|
||||
}
|
||||
with patch("hermes_cli.models.fetch_api_models", return_value=["kimi-k2.6", "glm-5.1"]), \
|
||||
patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev):
|
||||
result = fetch_ollama_cloud_models(force_refresh=True)
|
||||
|
||||
assert result.count("kimi-k2.6") == 1
|
||||
assert result.count("glm-5.1") == 1
|
||||
assert "kimi-k2.6:cloud" not in result
|
||||
assert "glm-5.1:cloud" not in result
|
||||
|
||||
def test_unsuffixed_model_id_unchanged(self, tmp_path, monkeypatch):
|
||||
"""Model IDs without :cloud / -cloud suffix are passed through unchanged."""
|
||||
from hermes_cli.models import fetch_ollama_cloud_models
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("OLLAMA_API_KEY", raising=False)
|
||||
|
||||
mock_mdev = {
|
||||
"ollama-cloud": {
|
||||
"models": {"nemotron-3-nano:30b": {"tool_call": True}}
|
||||
}
|
||||
}
|
||||
with patch("agent.models_dev.fetch_models_dev", return_value=mock_mdev):
|
||||
result = fetch_ollama_cloud_models(force_refresh=True)
|
||||
|
||||
assert "nemotron-3-nano:30b" in result
|
||||
|
||||
def test_strip_suffix_helper(self):
|
||||
"""Unit test for the _strip_ollama_cloud_suffix helper."""
|
||||
from hermes_cli.models import _strip_ollama_cloud_suffix
|
||||
|
||||
assert _strip_ollama_cloud_suffix("kimi-k2.6:cloud") == "kimi-k2.6"
|
||||
assert _strip_ollama_cloud_suffix("glm-5.1:cloud") == "glm-5.1"
|
||||
assert _strip_ollama_cloud_suffix("qwen3-coder:480b-cloud") == "qwen3-coder:480b"
|
||||
assert _strip_ollama_cloud_suffix("nemotron-3-nano:30b") == "nemotron-3-nano:30b"
|
||||
assert _strip_ollama_cloud_suffix("") == ""
|
||||
|
||||
|
||||
# ── Auxiliary Model ──
|
||||
|
||||
class TestOllamaCloudAuxiliary:
|
||||
|
|
|
|||
|
|
@ -914,3 +914,161 @@ def test_create_task_probe_error_does_not_break_create(client, monkeypatch):
|
|||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["task"]["title"] == "resilient"
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Home-channel subscription endpoints (#19534 follow-up: GUI opt-in)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Dashboard surface for per-task, per-platform notification toggles. The
|
||||
# backend endpoints read the live GatewayConfig, so tests set env vars
|
||||
# (BOT_TOKEN + HOME_CHANNEL) to simulate a user who has run /sethome on
|
||||
# telegram and discord.
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def with_home_channels(monkeypatch):
|
||||
"""Simulate a user with home channels set on telegram and discord."""
|
||||
monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "abc:fake")
|
||||
monkeypatch.setenv("TELEGRAM_HOME_CHANNEL", "1234567")
|
||||
monkeypatch.setenv("TELEGRAM_HOME_CHANNEL_THREAD_ID", "42")
|
||||
monkeypatch.setenv("TELEGRAM_HOME_CHANNEL_NAME", "Main TG")
|
||||
monkeypatch.setenv("DISCORD_BOT_TOKEN", "disc_fake")
|
||||
monkeypatch.setenv("DISCORD_HOME_CHANNEL", "9999999")
|
||||
monkeypatch.setenv("DISCORD_HOME_CHANNEL_NAME", "Main Discord")
|
||||
# Slack has a token but NO home — should be excluded from the list.
|
||||
monkeypatch.setenv("SLACK_BOT_TOKEN", "slack_fake")
|
||||
|
||||
|
||||
def test_home_channels_lists_only_platforms_with_home(client, with_home_channels):
|
||||
"""GET /home-channels returns entries only for platforms where the
|
||||
user has set a home; untoggled-subscribed bool is false by default."""
|
||||
r = client.get("/api/plugins/kanban/home-channels")
|
||||
assert r.status_code == 200
|
||||
platforms = {h["platform"] for h in r.json()["home_channels"]}
|
||||
assert platforms == {"telegram", "discord"}, (
|
||||
f"slack has a token but no home — must not appear. got {platforms}"
|
||||
)
|
||||
for h in r.json()["home_channels"]:
|
||||
assert h["subscribed"] is False
|
||||
|
||||
|
||||
def test_home_channels_no_task_id_all_unsubscribed(client, with_home_channels):
|
||||
"""Without task_id, every entry's subscribed=false (UI "no task" state)."""
|
||||
r = client.get("/api/plugins/kanban/home-channels")
|
||||
assert r.status_code == 200
|
||||
assert all(not h["subscribed"] for h in r.json()["home_channels"])
|
||||
|
||||
|
||||
def test_home_subscribe_creates_notify_sub_row(client, with_home_channels):
|
||||
"""POST .../home-subscribe/telegram writes a kanban_notify_subs row
|
||||
keyed to the telegram home's (chat_id, thread_id)."""
|
||||
from hermes_cli import kanban_db as kb
|
||||
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
||||
|
||||
r = client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["ok"] is True
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
subs = kb.list_notify_subs(conn, t["id"])
|
||||
finally:
|
||||
conn.close()
|
||||
assert len(subs) == 1
|
||||
assert subs[0]["platform"] == "telegram"
|
||||
assert subs[0]["chat_id"] == "1234567"
|
||||
assert subs[0]["thread_id"] == "42"
|
||||
|
||||
|
||||
def test_home_subscribe_flips_subscribed_flag_in_subsequent_get(client, with_home_channels):
|
||||
"""After subscribe, the GET endpoint reports subscribed=true for that
|
||||
platform and false for the others."""
|
||||
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
||||
client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram")
|
||||
|
||||
r = client.get(f"/api/plugins/kanban/home-channels?task_id={t['id']}")
|
||||
flags = {h["platform"]: h["subscribed"] for h in r.json()["home_channels"]}
|
||||
assert flags == {"telegram": True, "discord": False}
|
||||
|
||||
|
||||
def test_home_subscribe_is_idempotent(client, with_home_channels):
|
||||
"""Re-subscribing keeps a single row at the DB layer."""
|
||||
from hermes_cli import kanban_db as kb
|
||||
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
||||
client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram")
|
||||
client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram")
|
||||
client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram")
|
||||
conn = kb.connect()
|
||||
try:
|
||||
assert len(kb.list_notify_subs(conn, t["id"])) == 1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_home_subscribe_unknown_platform_returns_404(client, with_home_channels):
|
||||
"""Platforms without a home configured (slack in the fixture) return 404."""
|
||||
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
||||
r = client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/slack")
|
||||
assert r.status_code == 404
|
||||
assert "slack" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_home_subscribe_unknown_task_returns_404(client, with_home_channels):
|
||||
r = client.post("/api/plugins/kanban/tasks/t_nonexistent/home-subscribe/telegram")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_home_unsubscribe_removes_notify_sub_row(client, with_home_channels):
|
||||
"""DELETE .../home-subscribe/telegram removes the matching row."""
|
||||
from hermes_cli import kanban_db as kb
|
||||
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
||||
client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram")
|
||||
r = client.delete(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram")
|
||||
assert r.status_code == 200
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
assert kb.list_notify_subs(conn, t["id"]) == []
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_home_subscribe_multiple_platforms_independent(client, with_home_channels):
|
||||
"""Subscribing on telegram does not affect discord and vice versa."""
|
||||
from hermes_cli import kanban_db as kb
|
||||
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
|
||||
|
||||
client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram")
|
||||
client.post(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/discord")
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
subs = {s["platform"]: s for s in kb.list_notify_subs(conn, t["id"])}
|
||||
finally:
|
||||
conn.close()
|
||||
assert set(subs) == {"telegram", "discord"}
|
||||
|
||||
# Unsubscribe telegram only.
|
||||
client.delete(f"/api/plugins/kanban/tasks/{t['id']}/home-subscribe/telegram")
|
||||
conn = kb.connect()
|
||||
try:
|
||||
subs = {s["platform"]: s for s in kb.list_notify_subs(conn, t["id"])}
|
||||
finally:
|
||||
conn.close()
|
||||
assert set(subs) == {"discord"}
|
||||
|
||||
|
||||
def test_home_channels_empty_when_no_homes_configured(client, monkeypatch):
|
||||
"""Zero platforms with a home -> empty list (UI hides the section)."""
|
||||
# No BOT_TOKEN env vars set → load_gateway_config().platforms is empty.
|
||||
for var in [
|
||||
"TELEGRAM_BOT_TOKEN", "TELEGRAM_HOME_CHANNEL",
|
||||
"DISCORD_BOT_TOKEN", "DISCORD_HOME_CHANNEL",
|
||||
"SLACK_BOT_TOKEN",
|
||||
]:
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
r = client.get("/api/plugins/kanban/home-channels")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["home_channels"] == []
|
||||
|
|
|
|||
109
tests/run_agent/test_iteration_budget_race.py
Normal file
109
tests/run_agent/test_iteration_budget_race.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""Tests for IterationBudget thread safety.
|
||||
|
||||
The `used` property must acquire the lock before reading `_used` to prevent
|
||||
data races with concurrent `consume()` / `refund()` calls.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_iteration_budget_used_is_thread_safe():
|
||||
"""Iterating `used` while other threads consume/refund must not crash.
|
||||
|
||||
Before the fix, `used` returned `_used` directly without holding the lock,
|
||||
so a concurrent `consume()` could observe a partially-updated value or
|
||||
cause the C-level `list.append` to raise a ValueError ("list size changed").
|
||||
"""
|
||||
from run_agent import IterationBudget
|
||||
|
||||
budget = IterationBudget(max_total=1000)
|
||||
num_threads = 10
|
||||
operations_per_thread = 200
|
||||
|
||||
errors = []
|
||||
|
||||
def worker(consume: bool):
|
||||
try:
|
||||
for _ in range(operations_per_thread):
|
||||
if consume:
|
||||
budget.consume()
|
||||
else:
|
||||
budget.refund()
|
||||
# Also read `used` to exercise the property
|
||||
_ = budget.used
|
||||
except Exception as exc:
|
||||
errors.append(exc)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=num_threads * 2) as executor:
|
||||
# Half the threads consume, half refund
|
||||
futures = []
|
||||
for i in range(num_threads):
|
||||
consume = i < num_threads // 2
|
||||
futures.append(executor.submit(worker, consume))
|
||||
futures.append(executor.submit(worker, consume))
|
||||
|
||||
for f in futures:
|
||||
f.result()
|
||||
|
||||
assert not errors, f"Thread safety violation: {errors}"
|
||||
# Final value should be within expected bounds
|
||||
assert 0 <= budget.used <= budget.max_total
|
||||
|
||||
|
||||
def test_iteration_budget_consume_returns_false_when_exhausted():
|
||||
"""consume() must return False once the budget is exhausted."""
|
||||
from run_agent import IterationBudget
|
||||
|
||||
budget = IterationBudget(max_total=3)
|
||||
assert budget.consume() is True
|
||||
assert budget.consume() is True
|
||||
assert budget.consume() is True
|
||||
assert budget.consume() is False
|
||||
|
||||
|
||||
def test_iteration_budget_refund_restores_consume():
|
||||
"""refund() after consume() must allow one more consume()."""
|
||||
from run_agent import IterationBudget
|
||||
|
||||
budget = IterationBudget(max_total=2)
|
||||
assert budget.consume() is True
|
||||
assert budget.consume() is True
|
||||
assert budget.consume() is False # exhausted
|
||||
budget.refund()
|
||||
assert budget.consume() is True
|
||||
|
||||
|
||||
def test_iteration_budget_used_reflects_consume_and_refund():
|
||||
"""used property must accurately reflect consume() and refund() calls."""
|
||||
from run_agent import IterationBudget
|
||||
|
||||
budget = IterationBudget(max_total=10)
|
||||
|
||||
assert budget.used == 0
|
||||
budget.consume()
|
||||
assert budget.used == 1
|
||||
budget.consume()
|
||||
assert budget.used == 2
|
||||
budget.refund()
|
||||
assert budget.used == 1
|
||||
budget.refund()
|
||||
assert budget.used == 0
|
||||
|
||||
|
||||
def test_iteration_budget_remaining():
|
||||
"""remaining property must equal max_total - used."""
|
||||
from run_agent import IterationBudget
|
||||
|
||||
budget = IterationBudget(max_total=5)
|
||||
|
||||
assert budget.remaining == 5
|
||||
budget.consume()
|
||||
assert budget.remaining == 4
|
||||
budget.consume()
|
||||
budget.consume()
|
||||
assert budget.remaining == 2
|
||||
budget.refund()
|
||||
assert budget.remaining == 3
|
||||
102
tests/skills/test_google_workspace_credential_files.py
Normal file
102
tests/skills/test_google_workspace_credential_files.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""Regression test: google-workspace SKILL.md must declare required_credential_files.
|
||||
|
||||
PR #9931 accidentally removed the required_credential_files header, which broke
|
||||
credential file mounting in Docker/Modal remote backends (#16452). This test
|
||||
prevents the regression from silently reappearing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
SKILL_MD = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "skills/productivity/google-workspace/SKILL.md"
|
||||
)
|
||||
|
||||
_EXPECTED_PATHS = {"google_token.json", "google_client_secret.json"}
|
||||
|
||||
|
||||
def _parse_frontmatter(content: str) -> dict:
|
||||
from agent.skill_utils import parse_frontmatter
|
||||
|
||||
fm, _ = parse_frontmatter(content)
|
||||
return fm
|
||||
|
||||
|
||||
class TestGoogleWorkspaceCredentialFiles:
|
||||
def test_required_credential_files_present_in_skill_md(self):
|
||||
content = SKILL_MD.read_text(encoding="utf-8")
|
||||
fm = _parse_frontmatter(content)
|
||||
entries = fm.get("required_credential_files")
|
||||
assert entries, "required_credential_files missing from google-workspace SKILL.md"
|
||||
assert isinstance(entries, list), "required_credential_files must be a list"
|
||||
paths = {
|
||||
(e["path"] if isinstance(e, dict) else e)
|
||||
for e in entries
|
||||
}
|
||||
assert _EXPECTED_PATHS <= paths, (
|
||||
f"Missing entries in required_credential_files: {_EXPECTED_PATHS - paths}"
|
||||
)
|
||||
|
||||
def test_entries_are_registered_when_files_exist(self, tmp_path):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "google_token.json").write_text("{}")
|
||||
(hermes_home / "google_client_secret.json").write_text("{}")
|
||||
|
||||
from tools.credential_files import (
|
||||
clear_credential_files,
|
||||
get_credential_file_mounts,
|
||||
register_credential_files,
|
||||
)
|
||||
|
||||
clear_credential_files()
|
||||
try:
|
||||
content = SKILL_MD.read_text(encoding="utf-8")
|
||||
fm = _parse_frontmatter(content)
|
||||
entries = fm.get("required_credential_files", [])
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
|
||||
missing = register_credential_files(entries)
|
||||
|
||||
assert missing == [], f"Unexpected missing files: {missing}"
|
||||
mounts = get_credential_file_mounts()
|
||||
container_paths = {m["container_path"] for m in mounts}
|
||||
assert "/root/.hermes/google_token.json" in container_paths
|
||||
assert "/root/.hermes/google_client_secret.json" in container_paths
|
||||
finally:
|
||||
clear_credential_files()
|
||||
|
||||
def test_missing_token_is_reported(self, tmp_path):
|
||||
"""google_token.json absent (first-time setup) — reported as missing, client secret still mounts."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "google_client_secret.json").write_text("{}")
|
||||
|
||||
from tools.credential_files import (
|
||||
clear_credential_files,
|
||||
get_credential_file_mounts,
|
||||
register_credential_files,
|
||||
)
|
||||
|
||||
clear_credential_files()
|
||||
try:
|
||||
content = SKILL_MD.read_text(encoding="utf-8")
|
||||
fm = _parse_frontmatter(content)
|
||||
entries = fm.get("required_credential_files", [])
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
|
||||
missing = register_credential_files(entries)
|
||||
|
||||
assert "google_token.json" in missing
|
||||
mounts = get_credential_file_mounts()
|
||||
container_paths = {m["container_path"] for m in mounts}
|
||||
assert "/root/.hermes/google_client_secret.json" in container_paths
|
||||
assert "/root/.hermes/google_token.json" not in container_paths
|
||||
finally:
|
||||
clear_credential_files()
|
||||
|
|
@ -35,6 +35,7 @@ class TestSessionLifecycle:
|
|||
assert session["model"] == "test-model"
|
||||
assert session["ended_at"] is None
|
||||
|
||||
|
||||
def test_get_nonexistent_session(self, db):
|
||||
assert db.get_session("nonexistent") is None
|
||||
|
||||
|
|
@ -1421,6 +1422,242 @@ class TestSchemaInit:
|
|||
columns = {row[1] for row in cursor.fetchall()}
|
||||
assert "title" in columns
|
||||
|
||||
def test_topic_mode_schema_is_not_auto_migrated_on_open(self, tmp_path):
|
||||
"""Opening an old DB should not add topic-mode columns until /topic opts in.
|
||||
|
||||
The gateway must remain rollback-safe: simply upgrading Hermes and starting
|
||||
the old bot should not eagerly mutate the state DB for this feature.
|
||||
"""
|
||||
old_db = tmp_path / "old.db"
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(old_db)
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE schema_version (version INTEGER NOT NULL);
|
||||
INSERT INTO schema_version VALUES (11);
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
model TEXT,
|
||||
model_config TEXT,
|
||||
system_prompt TEXT,
|
||||
parent_session_id TEXT,
|
||||
started_at REAL NOT NULL,
|
||||
ended_at REAL,
|
||||
end_reason TEXT,
|
||||
message_count INTEGER DEFAULT 0,
|
||||
tool_call_count INTEGER DEFAULT 0,
|
||||
input_tokens INTEGER DEFAULT 0,
|
||||
output_tokens INTEGER DEFAULT 0,
|
||||
cache_read_tokens INTEGER DEFAULT 0,
|
||||
cache_write_tokens INTEGER DEFAULT 0,
|
||||
reasoning_tokens INTEGER DEFAULT 0,
|
||||
billing_provider TEXT,
|
||||
billing_base_url TEXT,
|
||||
billing_mode TEXT,
|
||||
estimated_cost_usd REAL,
|
||||
actual_cost_usd REAL,
|
||||
cost_status TEXT,
|
||||
cost_source TEXT,
|
||||
pricing_version TEXT,
|
||||
title TEXT,
|
||||
api_call_count INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
|
||||
);
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id),
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
tool_call_id TEXT,
|
||||
tool_calls TEXT,
|
||||
tool_name TEXT,
|
||||
timestamp REAL NOT NULL,
|
||||
token_count INTEGER,
|
||||
finish_reason TEXT,
|
||||
reasoning TEXT,
|
||||
reasoning_content TEXT,
|
||||
reasoning_details TEXT,
|
||||
codex_reasoning_items TEXT,
|
||||
codex_message_items TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.close()
|
||||
|
||||
db = SessionDB(db_path=old_db)
|
||||
cursor = db._conn.execute("PRAGMA table_info(sessions)")
|
||||
columns = {row[1] for row in cursor.fetchall()}
|
||||
assert {"chat_id", "chat_type", "thread_id", "session_key"}.isdisjoint(columns)
|
||||
db.close()
|
||||
|
||||
def test_apply_telegram_topic_migration_creates_topic_tables_explicitly(self, tmp_path):
|
||||
"""The /topic opt-in path owns the DB migration for Telegram topic mode."""
|
||||
old_db = tmp_path / "old.db"
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(old_db)
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE schema_version (version INTEGER NOT NULL);
|
||||
INSERT INTO schema_version VALUES (11);
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
model TEXT,
|
||||
model_config TEXT,
|
||||
system_prompt TEXT,
|
||||
parent_session_id TEXT,
|
||||
started_at REAL NOT NULL,
|
||||
ended_at REAL,
|
||||
end_reason TEXT,
|
||||
message_count INTEGER DEFAULT 0,
|
||||
tool_call_count INTEGER DEFAULT 0,
|
||||
input_tokens INTEGER DEFAULT 0,
|
||||
output_tokens INTEGER DEFAULT 0,
|
||||
cache_read_tokens INTEGER DEFAULT 0,
|
||||
cache_write_tokens INTEGER DEFAULT 0,
|
||||
reasoning_tokens INTEGER DEFAULT 0,
|
||||
billing_provider TEXT,
|
||||
billing_base_url TEXT,
|
||||
billing_mode TEXT,
|
||||
estimated_cost_usd REAL,
|
||||
actual_cost_usd REAL,
|
||||
cost_status TEXT,
|
||||
cost_source TEXT,
|
||||
pricing_version TEXT,
|
||||
title TEXT,
|
||||
api_call_count INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
|
||||
);
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id),
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
tool_call_id TEXT,
|
||||
tool_calls TEXT,
|
||||
tool_name TEXT,
|
||||
timestamp REAL NOT NULL,
|
||||
token_count INTEGER,
|
||||
finish_reason TEXT,
|
||||
reasoning TEXT,
|
||||
reasoning_content TEXT,
|
||||
reasoning_details TEXT,
|
||||
codex_reasoning_items TEXT,
|
||||
codex_message_items TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.close()
|
||||
|
||||
db = SessionDB(db_path=old_db)
|
||||
db.apply_telegram_topic_migration()
|
||||
|
||||
tables = {
|
||||
row[0]
|
||||
for row in db._conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
||||
).fetchall()
|
||||
}
|
||||
assert "telegram_dm_topic_mode" in tables
|
||||
assert "telegram_dm_topic_bindings" in tables
|
||||
assert db.get_meta("telegram_dm_topic_schema_version") == "2"
|
||||
db.close()
|
||||
|
||||
def test_telegram_topic_binding_roundtrip_requires_explicit_schema(self, tmp_path):
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
db.create_session(
|
||||
session_id="topic-session",
|
||||
source="telegram",
|
||||
user_id="208214988",
|
||||
)
|
||||
|
||||
assert db.get_telegram_topic_binding(chat_id="208214988", thread_id="17585") is None
|
||||
|
||||
db.bind_telegram_topic(
|
||||
chat_id="208214988",
|
||||
thread_id="17585",
|
||||
user_id="208214988",
|
||||
session_key="telegram:dm:208214988:thread:17585",
|
||||
session_id="topic-session",
|
||||
)
|
||||
|
||||
binding = db.get_telegram_topic_binding(chat_id="208214988", thread_id="17585")
|
||||
assert binding is not None
|
||||
assert binding["chat_id"] == "208214988"
|
||||
assert binding["thread_id"] == "17585"
|
||||
assert binding["user_id"] == "208214988"
|
||||
assert binding["session_key"] == "telegram:dm:208214988:thread:17585"
|
||||
assert binding["session_id"] == "topic-session"
|
||||
assert db.get_meta("telegram_dm_topic_schema_version") == "2"
|
||||
db.close()
|
||||
|
||||
def test_telegram_topic_binding_refuses_to_relink_session_to_another_topic(self, tmp_path):
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
db.create_session(
|
||||
session_id="topic-session",
|
||||
source="telegram",
|
||||
user_id="208214988",
|
||||
)
|
||||
db.bind_telegram_topic(
|
||||
chat_id="208214988",
|
||||
thread_id="17585",
|
||||
user_id="208214988",
|
||||
session_key="key-17585",
|
||||
session_id="topic-session",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="already linked"):
|
||||
db.bind_telegram_topic(
|
||||
chat_id="208214988",
|
||||
thread_id="99999",
|
||||
user_id="208214988",
|
||||
session_key="key-99999",
|
||||
session_id="topic-session",
|
||||
)
|
||||
db.close()
|
||||
|
||||
def test_list_unlinked_telegram_sessions_for_user_excludes_bound_and_other_users(self, tmp_path):
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
db.create_session(
|
||||
session_id="old-unlinked",
|
||||
source="telegram",
|
||||
user_id="208214988",
|
||||
)
|
||||
db.set_session_title("old-unlinked", "Old research")
|
||||
db.append_message("old-unlinked", "user", "first prompt")
|
||||
db.create_session(
|
||||
session_id="already-linked",
|
||||
source="telegram",
|
||||
user_id="208214988",
|
||||
)
|
||||
db.bind_telegram_topic(
|
||||
chat_id="208214988",
|
||||
thread_id="17585",
|
||||
user_id="208214988",
|
||||
session_key="key-17585",
|
||||
session_id="already-linked",
|
||||
)
|
||||
db.create_session(
|
||||
session_id="other-user",
|
||||
source="telegram",
|
||||
user_id="someone-else",
|
||||
)
|
||||
|
||||
sessions = db.list_unlinked_telegram_sessions_for_user(
|
||||
chat_id="208214988",
|
||||
user_id="208214988",
|
||||
)
|
||||
|
||||
assert [s["id"] for s in sessions] == ["old-unlinked"]
|
||||
assert sessions[0]["title"] == "Old research"
|
||||
assert sessions[0]["preview"] == "first prompt"
|
||||
db.close()
|
||||
|
||||
def test_migration_from_v2(self, tmp_path):
|
||||
"""Simulate a v2 database and verify migration adds title column."""
|
||||
import sqlite3
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import os
|
||||
import pytest
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
|
@ -388,6 +389,66 @@ class TestSearchPathValidation:
|
|||
assert "search failed" in result.error.lower() or "Search error" in result.error
|
||||
|
||||
|
||||
class TestSearchFilesFallbackHiddenPaths:
|
||||
def _make_env(self):
|
||||
env = MagicMock()
|
||||
env.cwd = "/"
|
||||
|
||||
def execute(command, **kwargs):
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return {
|
||||
"output": completed.stdout,
|
||||
"returncode": completed.returncode,
|
||||
}
|
||||
|
||||
env.execute = execute
|
||||
return env
|
||||
|
||||
def test_hidden_root_with_hidden_ancestor_includes_files(self, tmp_path, monkeypatch):
|
||||
"""Fallback find should include visible files when path is inside hidden root."""
|
||||
root = tmp_path / ".hermes" / "logs"
|
||||
root.mkdir(parents=True)
|
||||
visible_file = root / "agent.log"
|
||||
hidden_dir_file = root / ".hidden" / "secret.log"
|
||||
nested_hidden_file = root / "nested" / ".secret.log"
|
||||
visible_nested_file = root / "nested" / "visible.log"
|
||||
|
||||
for p in [visible_file, nested_hidden_file, visible_nested_file, hidden_dir_file]:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text("x")
|
||||
|
||||
ops = ShellFileOperations(self._make_env())
|
||||
monkeypatch.setattr(ops, "_has_command", lambda command: command == "find")
|
||||
result = ops._search_files("*.log", str(root), limit=50, offset=0)
|
||||
|
||||
assert result.error is None
|
||||
assert set(result.files) == {str(visible_file), str(visible_nested_file)}
|
||||
|
||||
def test_normal_root_still_excludes_hidden_descendants(self, tmp_path, monkeypatch):
|
||||
"""Fallback find should still exclude hidden descendant paths for normal roots."""
|
||||
root = tmp_path / "repo"
|
||||
root.mkdir()
|
||||
visible_file = root / "agent.log"
|
||||
visible_nested_file = root / "nested" / "visible.log"
|
||||
hidden_dir_file = root / ".hidden" / "secret.log"
|
||||
|
||||
for p in [visible_file, visible_nested_file, hidden_dir_file]:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text("x")
|
||||
|
||||
ops = ShellFileOperations(self._make_env())
|
||||
monkeypatch.setattr(ops, "_has_command", lambda command: command == "find")
|
||||
result = ops._search_files("*.log", str(root), limit=50, offset=0)
|
||||
|
||||
assert result.error is None
|
||||
assert set(result.files) == {str(visible_file), str(visible_nested_file)}
|
||||
|
||||
|
||||
class TestShellFileOpsWriteDenied:
|
||||
def test_write_file_denied_path(self, file_ops):
|
||||
result = file_ops.write_file("~/.ssh/authorized_keys", "evil key")
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ Covers:
|
|||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tools.file_operations import ShellFileOperations
|
||||
from tools.file_operations import ShellFileOperations, _parse_search_context_line
|
||||
|
||||
|
||||
# =========================================================================
|
||||
|
|
@ -204,3 +204,67 @@ class TestPaginationBounds:
|
|||
rg_commands = [cmd for cmd in commands if cmd.startswith("rg --files")]
|
||||
assert rg_commands
|
||||
assert "| head -n 1" in rg_commands[0]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Search context parsing
|
||||
# =========================================================================
|
||||
|
||||
|
||||
class TestSearchContextParsing:
|
||||
def test_parse_search_context_line_prefers_rightmost_numeric_separator(self):
|
||||
parsed = _parse_search_context_line("dir/file-12-name.py-8-context here")
|
||||
|
||||
assert parsed == ("dir/file-12-name.py", 8, "context here")
|
||||
|
||||
def test_search_with_rg_context_handles_filename_with_dash_digits(self):
|
||||
env = MagicMock()
|
||||
env.cwd = "/tmp"
|
||||
ops = ShellFileOperations(env)
|
||||
|
||||
with patch.object(ops, "_exec") as mock_exec:
|
||||
mock_exec.return_value = MagicMock(
|
||||
exit_code=0,
|
||||
stdout="dir/file-12-name.py-8-context here\n",
|
||||
)
|
||||
result = ops._search_with_rg(
|
||||
"needle",
|
||||
path=".",
|
||||
file_glob=None,
|
||||
limit=10,
|
||||
offset=0,
|
||||
output_mode="content",
|
||||
context=1,
|
||||
)
|
||||
|
||||
assert result.error is None
|
||||
assert result.total_count == 1
|
||||
assert result.matches[0].path == "dir/file-12-name.py"
|
||||
assert result.matches[0].line_number == 8
|
||||
assert result.matches[0].content == "context here"
|
||||
|
||||
def test_search_with_grep_context_handles_filename_with_dash_digits(self):
|
||||
env = MagicMock()
|
||||
env.cwd = "/tmp"
|
||||
ops = ShellFileOperations(env)
|
||||
|
||||
with patch.object(ops, "_exec") as mock_exec:
|
||||
mock_exec.return_value = MagicMock(
|
||||
exit_code=0,
|
||||
stdout="dir/file-12-name.py-8-context here\n",
|
||||
)
|
||||
result = ops._search_with_grep(
|
||||
"needle",
|
||||
path=".",
|
||||
file_glob=None,
|
||||
limit=10,
|
||||
offset=0,
|
||||
output_mode="content",
|
||||
context=1,
|
||||
)
|
||||
|
||||
assert result.error is None
|
||||
assert result.total_count == 1
|
||||
assert result.matches[0].path == "dir/file-12-name.py"
|
||||
assert result.matches[0].line_number == 8
|
||||
assert result.matches[0].content == "context here"
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ class TestOpenaiTtsSpeed:
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MiniMax TTS speed (global fallback wired)
|
||||
# MiniMax TTS (new API: raw audio, no speed/voice_setting)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMinimaxTtsSpeed:
|
||||
|
|
@ -118,28 +118,29 @@ class TestMinimaxTtsSpeed:
|
|||
monkeypatch.setenv("MINIMAX_API_KEY", "test-key")
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"data": {"audio": "deadbeef"},
|
||||
"base_resp": {"status_code": 0, "status_msg": "success"},
|
||||
"extra_info": {"audio_size": 8},
|
||||
}
|
||||
mock_response.headers = {"Content-Type": "audio/mpeg"}
|
||||
mock_response.content = b"\x00\x01\x02\x03"
|
||||
|
||||
# requests is imported locally inside _generate_minimax_tts
|
||||
with patch("requests.post", return_value=mock_response) as mock_post:
|
||||
from tools.tts_tool import _generate_minimax_tts
|
||||
_generate_minimax_tts("Hello", str(tmp_path / "out.mp3"), tts_config)
|
||||
return mock_post
|
||||
output = _generate_minimax_tts("Hello", str(tmp_path / "out.mp3"), tts_config)
|
||||
return mock_post, output
|
||||
|
||||
def test_global_speed_fallback(self, tmp_path, monkeypatch):
|
||||
"""Global tts.speed used when minimax.speed not set."""
|
||||
mock_post = self._run({"speed": 1.5}, tmp_path, monkeypatch)
|
||||
def test_simple_payload(self, tmp_path, monkeypatch):
|
||||
"""New API uses flat payload with model, text, voice_id."""
|
||||
mock_post, _ = self._run({}, tmp_path, monkeypatch)
|
||||
payload = mock_post.call_args[1]["json"]
|
||||
assert payload["voice_setting"]["speed"] == 1.5
|
||||
assert "model" in payload
|
||||
assert "text" in payload
|
||||
assert "voice_id" in payload
|
||||
assert "voice_setting" not in payload
|
||||
assert "audio_setting" not in payload
|
||||
assert "stream" not in payload
|
||||
|
||||
def test_provider_speed_overrides_global(self, tmp_path, monkeypatch):
|
||||
"""tts.minimax.speed takes precedence over tts.speed."""
|
||||
mock_post = self._run(
|
||||
{"speed": 1.5, "minimax": {"speed": 2.0}}, tmp_path, monkeypatch
|
||||
)
|
||||
payload = mock_post.call_args[1]["json"]
|
||||
assert payload["voice_setting"]["speed"] == 2.0
|
||||
def test_writes_raw_audio(self, tmp_path, monkeypatch):
|
||||
"""New API returns raw bytes written directly to file."""
|
||||
_, output = self._run({}, tmp_path, monkeypatch)
|
||||
assert output == str(tmp_path / "out.mp3")
|
||||
with open(output, "rb") as f:
|
||||
assert f.read() == b"\x00\x01\x02\x03"
|
||||
|
|
|
|||
101
tests/tui_gateway/test_entry_sys_path.py
Normal file
101
tests/tui_gateway/test_entry_sys_path.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"""Tests for tui_gateway/entry.py sys.path hardening (issue #15989).
|
||||
|
||||
When the TUI backend is spawned by Node.js, the Python interpreter may have
|
||||
'' or '.' at the front of sys.path, allowing a local utils/ directory in CWD
|
||||
to shadow the installed utils module. entry.py must sanitize sys.path before
|
||||
any non-stdlib import is resolved.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
def _reload_entry_with_env(env_overrides: dict) -> None:
|
||||
"""Re-execute entry.py's module-level path setup under a controlled env."""
|
||||
# We only want to exercise the sys.path fixup block, not the signal/import
|
||||
# machinery that follows. We do this by running the fixup code verbatim in
|
||||
# a fresh copy of sys.path rather than importing the real module (which
|
||||
# would trigger tui_gateway.server imports requiring heavy mocks).
|
||||
original_path = sys.path[:]
|
||||
original_env = {k: os.environ.get(k) for k in env_overrides}
|
||||
try:
|
||||
with patch.dict(os.environ, env_overrides, clear=False):
|
||||
_src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "")
|
||||
if _src_root and _src_root not in sys.path:
|
||||
sys.path.insert(0, _src_root)
|
||||
sys.path = [p for p in sys.path if p not in ("", ".")]
|
||||
return sys.path[:]
|
||||
finally:
|
||||
sys.path = original_path
|
||||
for k, v in original_env.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
|
||||
|
||||
def test_empty_string_and_dot_removed_from_sys_path():
|
||||
original = sys.path[:]
|
||||
try:
|
||||
sys.path.insert(0, "")
|
||||
sys.path.insert(0, ".")
|
||||
assert "" in sys.path
|
||||
assert "." in sys.path
|
||||
|
||||
# Run the entry.py fixup logic directly
|
||||
sys.path = [p for p in sys.path if p not in ("", ".")]
|
||||
|
||||
assert "" not in sys.path
|
||||
assert "." not in sys.path
|
||||
finally:
|
||||
sys.path = original
|
||||
|
||||
|
||||
def test_hermes_src_root_inserted_at_front():
|
||||
original = sys.path[:]
|
||||
try:
|
||||
fake_root = "/fake/hermes/src"
|
||||
with patch.dict(os.environ, {"HERMES_PYTHON_SRC_ROOT": fake_root}):
|
||||
_src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "")
|
||||
if _src_root and _src_root not in sys.path:
|
||||
sys.path.insert(0, _src_root)
|
||||
sys.path = [p for p in sys.path if p not in ("", ".")]
|
||||
|
||||
assert sys.path[0] == fake_root
|
||||
finally:
|
||||
sys.path = original
|
||||
|
||||
|
||||
def test_src_root_not_duplicated_if_already_present():
|
||||
original = sys.path[:]
|
||||
try:
|
||||
fake_root = "/already/present"
|
||||
sys.path.insert(0, fake_root)
|
||||
count_before = sys.path.count(fake_root)
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_PYTHON_SRC_ROOT": fake_root}):
|
||||
_src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "")
|
||||
if _src_root and _src_root not in sys.path:
|
||||
sys.path.insert(0, _src_root)
|
||||
sys.path = [p for p in sys.path if p not in ("", ".")]
|
||||
|
||||
assert sys.path.count(fake_root) == count_before
|
||||
finally:
|
||||
sys.path = original
|
||||
|
||||
|
||||
def test_no_src_root_env_does_not_crash():
|
||||
original = sys.path[:]
|
||||
try:
|
||||
env = {k: v for k, v in os.environ.items() if k != "HERMES_PYTHON_SRC_ROOT"}
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
os.environ.update(env)
|
||||
_src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "")
|
||||
if _src_root and _src_root not in sys.path:
|
||||
sys.path.insert(0, _src_root)
|
||||
sys.path = [p for p in sys.path if p not in ("", ".")]
|
||||
# No exception raised
|
||||
finally:
|
||||
sys.path = original
|
||||
|
|
@ -245,6 +245,8 @@ def _format_job(job: Dict[str, Any]) -> Dict[str, Any]:
|
|||
}
|
||||
if job.get("script"):
|
||||
result["script"] = job["script"]
|
||||
if job.get("no_agent"):
|
||||
result["no_agent"] = True
|
||||
if job.get("enabled_toolsets"):
|
||||
result["enabled_toolsets"] = job["enabled_toolsets"]
|
||||
if job.get("workdir"):
|
||||
|
|
@ -271,6 +273,7 @@ def cronjob(
|
|||
context_from: Optional[Union[str, List[str]]] = None,
|
||||
enabled_toolsets: Optional[List[str]] = None,
|
||||
workdir: Optional[str] = None,
|
||||
no_agent: Optional[bool] = None,
|
||||
task_id: str = None,
|
||||
) -> str:
|
||||
"""Unified cron job management tool."""
|
||||
|
|
@ -283,8 +286,22 @@ def cronjob(
|
|||
if not schedule:
|
||||
return tool_error("schedule is required for create", success=False)
|
||||
canonical_skills = _canonical_skills(skill, skills)
|
||||
if not prompt and not canonical_skills:
|
||||
return tool_error("create requires either prompt or at least one skill", success=False)
|
||||
_no_agent = bool(no_agent)
|
||||
# Job-shape validation differs by mode:
|
||||
# - no_agent=True → script is the job; prompt/skills are optional
|
||||
# (and irrelevant to execution).
|
||||
# - no_agent=False (default) → at least one of prompt/skills must
|
||||
# be set, same as before.
|
||||
if _no_agent:
|
||||
if not script:
|
||||
return tool_error(
|
||||
"create with no_agent=True requires a script — "
|
||||
"the script is the job.",
|
||||
success=False,
|
||||
)
|
||||
else:
|
||||
if not prompt and not canonical_skills:
|
||||
return tool_error("create requires either prompt or at least one skill", success=False)
|
||||
if prompt:
|
||||
scan_error = _scan_cron_prompt(prompt)
|
||||
if scan_error:
|
||||
|
|
@ -323,6 +340,7 @@ def cronjob(
|
|||
context_from=context_from,
|
||||
enabled_toolsets=enabled_toolsets or None,
|
||||
workdir=_normalize_optional_job_value(workdir),
|
||||
no_agent=_no_agent,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
|
|
@ -436,6 +454,20 @@ def cronjob(
|
|||
# Empty string clears the field (restores old behaviour);
|
||||
# otherwise pass raw — update_job() validates / normalizes.
|
||||
updates["workdir"] = _normalize_optional_job_value(workdir) or None
|
||||
if no_agent is not None:
|
||||
# Toggling no_agent on/off at update time. If flipping to True,
|
||||
# we need a script to already exist on the job (or be part of
|
||||
# the same update) — otherwise the next tick would error out.
|
||||
target_no_agent = bool(no_agent)
|
||||
if target_no_agent:
|
||||
effective_script = updates.get("script") if "script" in updates else job.get("script")
|
||||
if not effective_script:
|
||||
return tool_error(
|
||||
"Cannot set no_agent=True on a job without a script. "
|
||||
"Set `script` in the same update, or on the job first.",
|
||||
success=False,
|
||||
)
|
||||
updates["no_agent"] = target_no_agent
|
||||
if repeat is not None:
|
||||
# Normalize: treat 0 or negative as None (infinite)
|
||||
normalized_repeat = None if repeat <= 0 else repeat
|
||||
|
|
@ -533,7 +565,25 @@ Important safety rule: cron-run sessions should not recursively schedule more cr
|
|||
},
|
||||
"script": {
|
||||
"type": "string",
|
||||
"description": f"Optional path to a Python script that runs before each cron job execution. Its stdout is injected into the prompt as context. Use for data collection and change detection. Relative paths resolve under {display_hermes_home()}/scripts/. On update, pass empty string to clear."
|
||||
"description": f"Optional path to a script that runs each tick. In the default mode its stdout is injected into the agent's prompt as context (data-collection / change-detection pattern). With no_agent=True, the script IS the job and its stdout is delivered verbatim (classic watchdog pattern). Relative paths resolve under {display_hermes_home()}/scripts/. ``.sh``/``.bash`` extensions run via bash, everything else via Python. On update, pass empty string to clear."
|
||||
},
|
||||
"no_agent": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": (
|
||||
"Default: False (LLM-driven job — the agent runs the prompt each tick). "
|
||||
"Set True to skip the LLM entirely: the scheduler just runs ``script`` on schedule and delivers its stdout verbatim. No tokens, no agent loop, no model override honoured. "
|
||||
"\n\n"
|
||||
"REQUIREMENTS when True: ``script`` MUST be set (``prompt`` and ``skills`` are ignored). "
|
||||
"\n\n"
|
||||
"DELIVERY SEMANTICS when True: "
|
||||
"(a) non-empty stdout is sent verbatim as the message; "
|
||||
"(b) EMPTY stdout means SILENT — nothing is sent to the user and they won't see anything happened, so design your script to stay quiet when there's nothing to report (the watchdog pattern); "
|
||||
"(c) non-zero exit / timeout sends an error alert so a broken watchdog can't fail silently. "
|
||||
"\n\n"
|
||||
"WHEN TO USE True: recurring script-only pings where the script itself produces the exact message text (memory/disk/GPU watchdogs, threshold alerts, heartbeats, CI notifications, API pollers with a fixed output shape). "
|
||||
"WHEN TO USE False (default): anything that needs reasoning — summarize a feed, draft a daily briefing, pick interesting items, rephrase data for a human, follow conditional logic based on content."
|
||||
),
|
||||
},
|
||||
"context_from": {
|
||||
"type": "array",
|
||||
|
|
@ -604,6 +654,7 @@ registry.register(
|
|||
context_from=args.get("context_from"),
|
||||
enabled_toolsets=args.get("enabled_toolsets"),
|
||||
workdir=args.get("workdir"),
|
||||
no_agent=args.get("no_agent"),
|
||||
task_id=kw.get("task_id"),
|
||||
))(),
|
||||
check_fn=check_cronjob_requirements,
|
||||
|
|
|
|||
|
|
@ -215,6 +215,31 @@ class ExecuteResult:
|
|||
exit_code: int = 0
|
||||
|
||||
|
||||
def _parse_search_context_line(line: str) -> tuple[str, int, str] | None:
|
||||
"""Parse grep/rg context output in ``path-line-content`` format.
|
||||
|
||||
Context lines are ambiguous because filenames may legitimately contain
|
||||
``-<digits>-`` segments. Prefer the rightmost numeric separator so a path
|
||||
like ``dir/file-12-name.py-8-context`` resolves to
|
||||
``dir/file-12-name.py`` line ``8`` instead of truncating at ``file``.
|
||||
"""
|
||||
if not line or line == "--":
|
||||
return None
|
||||
|
||||
match = None
|
||||
for candidate in re.finditer(r'-(\d+)-', line):
|
||||
match = candidate
|
||||
|
||||
if match is None:
|
||||
return None
|
||||
|
||||
path = line[:match.start()]
|
||||
if not path:
|
||||
return None
|
||||
|
||||
return path, int(match.group(1)), line[match.end():]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Abstract Interface
|
||||
# =============================================================================
|
||||
|
|
@ -987,6 +1012,12 @@ class ShellFileOperations(FileOperations):
|
|||
else:
|
||||
search_pattern = pattern.split('/')[-1]
|
||||
|
||||
search_root = Path(path)
|
||||
has_hidden_path_ancestor = any(
|
||||
part not in (".", "..") and part.startswith(".")
|
||||
for part in search_root.parts
|
||||
)
|
||||
|
||||
# Prefer ripgrep: respects .gitignore, excludes hidden dirs by
|
||||
# default, and has parallel directory traversal (~200x faster than
|
||||
# find on wide trees). Mirrors _search_content which already uses rg.
|
||||
|
|
@ -1002,17 +1033,25 @@ class ShellFileOperations(FileOperations):
|
|||
)
|
||||
|
||||
# Exclude hidden directories (matching ripgrep's default behavior).
|
||||
hidden_exclude = "-not -path '*/.*'"
|
||||
hidden_exclude = "-not -path '*/.*'" if not has_hidden_path_ancestor else ""
|
||||
hidden_filter_expr = f" {hidden_exclude}" if hidden_exclude else ""
|
||||
|
||||
cmd = f"find {self._escape_shell_arg(path)} {hidden_exclude} -type f -name {self._escape_shell_arg(search_pattern)} " \
|
||||
f"-printf '%T@ %p\\n' 2>/dev/null | sort -rn | tail -n +{offset + 1} | head -n {limit}"
|
||||
# Use shell pagination for standard roots. For hidden roots, gather full
|
||||
# output so we can re-apply hidden-descendant filtering while allowing
|
||||
# explicit hidden-root searches.
|
||||
pagination_expr = ""
|
||||
if not has_hidden_path_ancestor:
|
||||
pagination_expr = f" | tail -n +{offset + 1} | head -n {limit}"
|
||||
|
||||
cmd = f"find {self._escape_shell_arg(path)}{hidden_filter_expr} -type f -name {self._escape_shell_arg(search_pattern)} " \
|
||||
f"-printf '%T@ %p\\n' 2>/dev/null | sort -rn{pagination_expr}"
|
||||
|
||||
result = self._exec(cmd, timeout=60)
|
||||
|
||||
if not result.stdout.strip():
|
||||
# Try without -printf (BSD find compatibility -- macOS)
|
||||
cmd_simple = f"find {self._escape_shell_arg(path)} {hidden_exclude} -type f -name {self._escape_shell_arg(search_pattern)} " \
|
||||
f"2>/dev/null | head -n {limit + offset} | tail -n +{offset + 1}"
|
||||
cmd_simple = f"find {self._escape_shell_arg(path)}{hidden_filter_expr} -type f -name {self._escape_shell_arg(search_pattern)} " \
|
||||
f"2>/dev/null | sort -rn{pagination_expr}"
|
||||
result = self._exec(cmd_simple, timeout=60)
|
||||
|
||||
files = []
|
||||
|
|
@ -1025,6 +1064,23 @@ class ShellFileOperations(FileOperations):
|
|||
else:
|
||||
files.append(line)
|
||||
|
||||
# For explicit hidden roots, find's path-based filtering excludes every
|
||||
# file under the hidden path. Apply descendant filtering after command
|
||||
# execution so only the explicit root ancestry is bypassed.
|
||||
if has_hidden_path_ancestor:
|
||||
normalized_root = search_root.resolve()
|
||||
filtered_files = []
|
||||
for file_path in files:
|
||||
try:
|
||||
rel_parts = Path(file_path).resolve().relative_to(normalized_root).parts
|
||||
except ValueError:
|
||||
rel_parts = Path(file_path).parts
|
||||
if any(part not in (".", "..") and part.startswith(".") for part in rel_parts):
|
||||
continue
|
||||
filtered_files.append(file_path)
|
||||
files = filtered_files[offset:offset + limit]
|
||||
# pagination for standard roots is already applied in shell
|
||||
|
||||
return SearchResult(
|
||||
files=files,
|
||||
total_count=len(files)
|
||||
|
|
@ -1154,7 +1210,6 @@ class ShellFileOperations(FileOperations):
|
|||
# Note: on Windows, paths contain drive letters (e.g. C:\path),
|
||||
# so naive split(":") breaks. Use regex to handle both platforms.
|
||||
_match_re = re.compile(r'^([A-Za-z]:)?(.*?):(\d+):(.*)$')
|
||||
_ctx_re = re.compile(r'^([A-Za-z]:)?(.*?)-(\d+)-(.*)$')
|
||||
matches = []
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if not line or line == "--":
|
||||
|
|
@ -1173,12 +1228,12 @@ class ShellFileOperations(FileOperations):
|
|||
# Try context line (dash-separated: file-line-content)
|
||||
# Only attempt if context was requested to avoid false positives
|
||||
if context > 0:
|
||||
m = _ctx_re.match(line)
|
||||
if m:
|
||||
parsed = _parse_search_context_line(line)
|
||||
if parsed:
|
||||
matches.append(SearchMatch(
|
||||
path=(m.group(1) or '') + m.group(2),
|
||||
line_number=int(m.group(3)),
|
||||
content=m.group(4)[:500]
|
||||
path=parsed[0],
|
||||
line_number=parsed[1],
|
||||
content=parsed[2][:500]
|
||||
))
|
||||
|
||||
total = len(matches)
|
||||
|
|
@ -1253,7 +1308,6 @@ class ShellFileOperations(FileOperations):
|
|||
# Note: on Windows, paths contain drive letters (e.g. C:\path),
|
||||
# so naive split(":") breaks. Use regex to handle both platforms.
|
||||
_match_re = re.compile(r'^([A-Za-z]:)?(.*?):(\d+):(.*)$')
|
||||
_ctx_re = re.compile(r'^([A-Za-z]:)?(.*?)-(\d+)-(.*)$')
|
||||
matches = []
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if not line or line == "--":
|
||||
|
|
@ -1269,12 +1323,12 @@ class ShellFileOperations(FileOperations):
|
|||
continue
|
||||
|
||||
if context > 0:
|
||||
m = _ctx_re.match(line)
|
||||
if m:
|
||||
parsed = _parse_search_context_line(line)
|
||||
if parsed:
|
||||
matches.append(SearchMatch(
|
||||
path=(m.group(1) or '') + m.group(2),
|
||||
line_number=int(m.group(3)),
|
||||
content=m.group(4)[:500]
|
||||
path=parsed[0],
|
||||
line_number=parsed[1],
|
||||
content=parsed[2][:500]
|
||||
))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -136,9 +136,9 @@ DEFAULT_KITTENTTS_VOICE = "Jasper"
|
|||
DEFAULT_PIPER_VOICE = "en_US-lessac-medium" # balanced size/quality
|
||||
DEFAULT_OPENAI_VOICE = "alloy"
|
||||
DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
|
||||
DEFAULT_MINIMAX_MODEL = "speech-2.8-hd"
|
||||
DEFAULT_MINIMAX_VOICE_ID = "English_Graceful_Lady"
|
||||
DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1/t2a_v2"
|
||||
DEFAULT_MINIMAX_MODEL = "speech-01"
|
||||
DEFAULT_MINIMAX_VOICE_ID = "female-shaonv"
|
||||
DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.chat/v1/text_to_speech"
|
||||
DEFAULT_MISTRAL_TTS_MODEL = "voxtral-mini-tts-2603"
|
||||
DEFAULT_MISTRAL_TTS_VOICE_ID = "c69964a6-ab8b-4f8a-9465-ec0925096ec8" # Paul - Neutral
|
||||
DEFAULT_XAI_VOICE_ID = "eve"
|
||||
|
|
@ -925,10 +925,11 @@ def _generate_xai_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -
|
|||
# ===========================================================================
|
||||
def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Generate audio using MiniMax TTS API.
|
||||
Generate audio using MiniMax TTS API (v1/text_to_speech).
|
||||
|
||||
MiniMax returns hex-encoded audio data. Supports streaming (SSE) and
|
||||
non-streaming modes. This implementation uses non-streaming for simplicity.
|
||||
The current API (api.minimax.chat/v1/text_to_speech) uses a simple payload
|
||||
and returns raw audio bytes directly (Content-Type: audio/mpeg), unlike
|
||||
the deprecated v1/t2a_v2 endpoint which returned JSON with hex-encoded audio.
|
||||
|
||||
Args:
|
||||
text: Text to convert (max 10,000 characters).
|
||||
|
|
@ -947,35 +948,12 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any
|
|||
mm_config = tts_config.get("minimax", {})
|
||||
model = mm_config.get("model", DEFAULT_MINIMAX_MODEL)
|
||||
voice_id = mm_config.get("voice_id", DEFAULT_MINIMAX_VOICE_ID)
|
||||
speed = mm_config.get("speed", tts_config.get("speed", 1))
|
||||
vol = mm_config.get("vol", 1)
|
||||
pitch = mm_config.get("pitch", 0)
|
||||
base_url = mm_config.get("base_url", DEFAULT_MINIMAX_BASE_URL)
|
||||
|
||||
# Determine audio format from output extension
|
||||
if output_path.endswith(".wav"):
|
||||
audio_format = "wav"
|
||||
elif output_path.endswith(".flac"):
|
||||
audio_format = "flac"
|
||||
else:
|
||||
audio_format = "mp3"
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"text": text,
|
||||
"stream": False,
|
||||
"voice_setting": {
|
||||
"voice_id": voice_id,
|
||||
"speed": speed,
|
||||
"vol": vol,
|
||||
"pitch": pitch,
|
||||
},
|
||||
"audio_setting": {
|
||||
"sample_rate": 32000,
|
||||
"bitrate": 128000,
|
||||
"format": audio_format,
|
||||
"channel": 1,
|
||||
},
|
||||
"voice_id": voice_id,
|
||||
}
|
||||
|
||||
headers = {
|
||||
|
|
@ -984,9 +962,25 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any
|
|||
}
|
||||
|
||||
response = requests.post(base_url, json=payload, headers=headers, timeout=60)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
|
||||
if "audio/" in content_type:
|
||||
# New API: returns raw audio directly
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(response.content)
|
||||
return output_path
|
||||
|
||||
# Legacy / fallback: try parsing as JSON with hex-encoded audio
|
||||
try:
|
||||
result = response.json()
|
||||
except Exception:
|
||||
response.raise_for_status()
|
||||
raise RuntimeError(
|
||||
f"MiniMax TTS returned unexpected Content-Type '{content_type}' "
|
||||
f"({len(response.content)} bytes)"
|
||||
)
|
||||
|
||||
base_resp = result.get("base_resp", {})
|
||||
status_code = base_resp.get("status_code", -1)
|
||||
|
||||
|
|
@ -998,7 +992,7 @@ def _generate_minimax_tts(text: str, output_path: str, tts_config: Dict[str, Any
|
|||
if not hex_audio:
|
||||
raise RuntimeError("MiniMax TTS returned empty audio data")
|
||||
|
||||
# MiniMax returns hex-encoded audio (not base64)
|
||||
# Legacy: hex-encoded audio
|
||||
audio_bytes = bytes.fromhex(hex_audio)
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,18 @@
|
|||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
|
||||
# Guard against a local utils/ (or other package) in CWD shadowing installed
|
||||
# hermes modules. hermes_cli sets HERMES_PYTHON_SRC_ROOT before spawning this
|
||||
# subprocess; inserting it first ensures the installed packages win.
|
||||
_src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "")
|
||||
if _src_root and _src_root not in sys.path:
|
||||
sys.path.insert(0, _src_root)
|
||||
# Strip '' and '.' — both resolve to CWD at import time and can let a local
|
||||
# directory shadow installed packages.
|
||||
sys.path = [p for p in sys.path if p not in ("", ".")]
|
||||
|
||||
import json
|
||||
import signal
|
||||
import time
|
||||
import traceback
|
||||
|
||||
|
|
|
|||
41
ui-tui/package-lock.json
generated
41
ui-tui/package-lock.json
generated
|
|
@ -125,7 +125,6 @@
|
|||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
|
|
@ -503,6 +502,31 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
|
|
@ -1677,7 +1701,6 @@
|
|||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
}
|
||||
|
|
@ -1688,7 +1711,6 @@
|
|||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
|
|
@ -1699,7 +1721,6 @@
|
|||
"integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.58.1",
|
||||
|
|
@ -1729,7 +1750,6 @@
|
|||
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.58.1",
|
||||
"@typescript-eslint/types": "8.58.1",
|
||||
|
|
@ -2047,7 +2067,6 @@
|
|||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -2450,7 +2469,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
|
|
@ -3186,7 +3204,6 @@
|
|||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
|
|
@ -3318,7 +3335,6 @@
|
|||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
|
@ -4227,7 +4243,6 @@
|
|||
"resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz",
|
||||
"integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"type-fest": "^4.18.2"
|
||||
|
|
@ -5663,7 +5678,6 @@
|
|||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -5773,7 +5787,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
||||
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -6598,7 +6611,6 @@
|
|||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
|
|
@ -6725,7 +6737,6 @@
|
|||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -6835,7 +6846,6 @@
|
|||
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
|
|
@ -7251,7 +7261,6 @@
|
|||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ For the full feature reference, see [Scheduled Tasks (Cron)](/docs/user-guide/fe
|
|||
Cron jobs run in fresh agent sessions with no memory of your current chat. Prompts must be **completely self-contained** — include everything the agent needs to know.
|
||||
:::
|
||||
|
||||
:::tip Don't need the LLM? Use no-agent mode.
|
||||
For recurring watchdogs where the script already produces the exact message you want to send (memory alerts, disk alerts, CI pings, heartbeats), skip the LLM entirely with [script-only cron jobs](/docs/guides/cron-script-only). Zero tokens, same scheduler. You can ask Hermes to set one up for you in chat — the `cronjob` tool knows when to pick `no_agent=True` and writes the script for you.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## Pattern 1: Website Change Monitor
|
||||
|
|
|
|||
246
website/docs/guides/cron-script-only.md
Normal file
246
website/docs/guides/cron-script-only.md
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
---
|
||||
sidebar_position: 13
|
||||
title: "Script-Only Cron Jobs (No LLM)"
|
||||
description: "Classic watchdog cron jobs that skip the LLM entirely — a script runs on schedule and its stdout gets delivered to your messaging platform. Memory alerts, disk alerts, CI pings, periodic health checks."
|
||||
---
|
||||
|
||||
# Script-Only Cron Jobs
|
||||
|
||||
Sometimes you already know exactly what message you want to send. You don't need an agent to reason about it — you just need a script to run on a timer, and its output (if any) to land in Telegram / Discord / Slack / Signal.
|
||||
|
||||
Hermes calls this **no-agent mode**. It's the cron system minus the LLM.
|
||||
|
||||
```
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ scheduler tick │ every │ run script │
|
||||
│ (every N minutes)│ ──────▶ │ (bash or python) │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
│
|
||||
│ stdout
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ delivery router │
|
||||
│ (telegram/disc…) │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
- **No LLM call.** Zero tokens, zero agent loop, zero model spend.
|
||||
- **Script is the job.** The script decides whether to alert. Emit output → message gets sent. Emit nothing → silent tick.
|
||||
- **Bash or Python.** `.sh` / `.bash` files run under `/bin/bash`; any other extension runs under the current Python interpreter. Anything in `~/.hermes/scripts/` is accepted.
|
||||
- **Same scheduler.** Lives in `cronjob` alongside LLM jobs — pausing, resuming, listing, logs, and delivery targeting all work the same way.
|
||||
|
||||
## When to Use It
|
||||
|
||||
Use no-agent mode for:
|
||||
|
||||
- **Memory / disk / GPU watchdogs.** Run every 5 minutes, alert only when a threshold is breached.
|
||||
- **CI hooks.** Deploy finished → post the commit SHA. Build failed → send the last 100 lines of the log.
|
||||
- **Periodic metrics.** "Daily Stripe revenue at 9am" as a simple API call + pretty-print.
|
||||
- **External event pollers.** Check an API, alert on state change.
|
||||
- **Heartbeats.** Ping a dashboard every N minutes to prove the host is alive.
|
||||
|
||||
Use a normal (LLM-driven) cron job when you need the agent to **decide** what to say — summarize a long document, pick interesting items from a feed, draft a human-friendly message. The no-agent path is for cases where the script's stdout already IS the message.
|
||||
|
||||
## Create One from Chat
|
||||
|
||||
The real win of no-agent mode is that the agent itself can set up the watchdog for you — no editor, no shell, no remembering CLI flags. You describe what you want, Hermes writes the script, schedules it, and tells you when it'll fire.
|
||||
|
||||
### Example transcript
|
||||
|
||||
> **You:** ping me on telegram if RAM is over 85% every 5 minutes
|
||||
>
|
||||
> **Hermes:** *(writes `~/.hermes/scripts/memory-watchdog.sh`, then calls `cronjob(...)` with `no_agent=true`)*
|
||||
>
|
||||
> Set up. Runs every 5 min, alerts Telegram only when RAM is over 85%. Script: `memory-watchdog.sh`. Job ID: `abc123`.
|
||||
|
||||
Under the hood, the agent makes two tool calls:
|
||||
|
||||
```python
|
||||
# 1. Write the check script
|
||||
write_file(
|
||||
path="~/.hermes/scripts/memory-watchdog.sh",
|
||||
content='''#!/usr/bin/env bash
|
||||
ram_pct=$(free | awk '/^Mem:/ {printf "%d", $3 * 100 / $2}')
|
||||
if [ "$ram_pct" -ge 85 ]; then
|
||||
echo "RAM ${ram_pct}% on $(hostname)"
|
||||
fi
|
||||
# Empty stdout = silent tick; no message sent.
|
||||
''',
|
||||
)
|
||||
|
||||
# 2. Schedule it — no_agent=True skips the LLM on every tick
|
||||
cronjob(
|
||||
action="create",
|
||||
schedule="every 5m",
|
||||
script="memory-watchdog.sh",
|
||||
no_agent=True,
|
||||
deliver="telegram",
|
||||
name="memory-watchdog",
|
||||
)
|
||||
```
|
||||
|
||||
From that point on every tick is free: the scheduler runs the script, pipes its stdout to Telegram if non-empty, and never touches a model.
|
||||
|
||||
### What the agent decides for you
|
||||
|
||||
When you phrase a request like "alert me when X" or "every N minutes check Y and tell me if Z", Hermes' `cronjob` tool description tells it to reach for `no_agent=True` whenever the message content is fully determined by the script. It falls back to the normal LLM-driven path when the request needs reasoning (*"summarize the new issues"*, *"pick the most interesting headlines"*, *"draft a friendly reminder"*).
|
||||
|
||||
You don't have to specify `--no-agent` yourself. Just describe the behavior.
|
||||
|
||||
### Managing watchdogs from chat
|
||||
|
||||
The agent can pause, resume, edit, and remove jobs the same way it creates them:
|
||||
|
||||
> **You:** stop the memory watchdog for tonight
|
||||
>
|
||||
> **Hermes:** *(calls `cronjob(action='pause', job_id='abc123')`)*
|
||||
>
|
||||
> Paused. Resume with "turn it back on" or via `hermes cron resume abc123`.
|
||||
|
||||
> **You:** change it to every 15 minutes
|
||||
>
|
||||
> **Hermes:** *(calls `cronjob(action='update', job_id='abc123', schedule='every 15m')`)*
|
||||
|
||||
The full lifecycle (create / list / update / pause / resume / run-now / remove) is available to the agent without you learning any CLI commands.
|
||||
|
||||
## Create One from the CLI
|
||||
|
||||
Prefer the shell? The CLI path gives you the same result with three commands:
|
||||
|
||||
```bash
|
||||
# 1. Write your script
|
||||
cat > ~/.hermes/scripts/memory-watchdog.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
# Alert when RAM usage is over 85%. Silent otherwise.
|
||||
RAM_PCT=$(free | awk '/^Mem:/ {printf "%d", $3 * 100 / $2}')
|
||||
if [ "$RAM_PCT" -ge 85 ]; then
|
||||
echo "⚠ RAM ${RAM_PCT}% on $(hostname)"
|
||||
fi
|
||||
# Empty stdout = silent run; no message sent.
|
||||
EOF
|
||||
chmod +x ~/.hermes/scripts/memory-watchdog.sh
|
||||
|
||||
# 2. Schedule it
|
||||
hermes cron create "every 5m" \
|
||||
--no-agent \
|
||||
--script memory-watchdog.sh \
|
||||
--deliver telegram \
|
||||
--name "memory-watchdog"
|
||||
|
||||
# 3. Verify
|
||||
hermes cron list
|
||||
hermes cron run <job_id> # fire it once to test
|
||||
```
|
||||
|
||||
That's the whole thing. No prompt, no skill, no model.
|
||||
|
||||
|
||||
## How Script Output Maps to Delivery
|
||||
|
||||
| Script behavior | Result |
|
||||
|-----------------|--------|
|
||||
| Exit 0, non-empty stdout | stdout is delivered verbatim |
|
||||
| Exit 0, empty stdout | Silent tick — no delivery |
|
||||
| Exit 0, stdout contains `{"wakeAgent": false}` on the last line | Silent tick (shared gate with LLM jobs) |
|
||||
| Non-zero exit code | Error alert is delivered (so a broken watchdog doesn't fail silently) |
|
||||
| Script timeout | Error alert is delivered |
|
||||
|
||||
The "silent when empty" behavior is the key to the classic watchdog pattern: the script is free to run every minute, but the channel only sees a message when something actually needs attention.
|
||||
|
||||
## Script Rules
|
||||
|
||||
Scripts must live in `~/.hermes/scripts/`. This is enforced at both job-creation time and run time — absolute paths, `~/` expansion, and path-traversal patterns (`../`) are rejected. The same directory is shared with the pre-check script gate used by LLM jobs.
|
||||
|
||||
Interpreter choice is by file extension:
|
||||
|
||||
| Extension | Interpreter |
|
||||
|-----------|-------------|
|
||||
| `.sh`, `.bash` | `/bin/bash` |
|
||||
| anything else | `sys.executable` (current Python) |
|
||||
|
||||
We intentionally do NOT honour `#!/...` shebangs — keeping the interpreter set explicit and small reduces the surface the scheduler trusts.
|
||||
|
||||
## Schedule Syntax
|
||||
|
||||
Same as all other cron jobs:
|
||||
|
||||
```bash
|
||||
hermes cron create "every 5m" # interval
|
||||
hermes cron create "every 2h"
|
||||
hermes cron create "0 9 * * *" # standard cron: 9am daily
|
||||
hermes cron create "30m" # one-shot: run once in 30 minutes
|
||||
```
|
||||
|
||||
See the [cron feature reference](/docs/user-guide/features/cron) for the full syntax.
|
||||
|
||||
## Delivery Targets
|
||||
|
||||
`--deliver` accepts everything the gateway knows about. Some common shapes:
|
||||
|
||||
```bash
|
||||
--deliver telegram # platform home channel
|
||||
--deliver telegram:-1001234567890 # specific chat
|
||||
--deliver telegram:-1001234567890:17585 # specific Telegram forum topic
|
||||
--deliver discord:#ops
|
||||
--deliver slack:#engineering
|
||||
--deliver signal:+15551234567
|
||||
--deliver local # just save to ~/.hermes/cron/output/
|
||||
```
|
||||
|
||||
No running gateway is required at script-run time for bot-token platforms (Telegram, Discord, Slack, Signal, SMS, WhatsApp) — the tool calls each platform's REST endpoint directly using the credentials already in `~/.hermes/.env` / `~/.hermes/config.yaml`.
|
||||
|
||||
## Editing and Lifecycle
|
||||
|
||||
```bash
|
||||
hermes cron list # see all jobs
|
||||
hermes cron pause <job_id> # stop firing, keep definition
|
||||
hermes cron resume <job_id>
|
||||
hermes cron edit <job_id> --schedule "every 10m" # adjust cadence
|
||||
hermes cron edit <job_id> --agent # flip to LLM mode
|
||||
hermes cron edit <job_id> --no-agent --script … # flip back
|
||||
hermes cron remove <job_id> # delete it
|
||||
```
|
||||
|
||||
Everything that works on LLM jobs (pause, resume, manual trigger, delivery target changes) works on no-agent jobs too.
|
||||
|
||||
## Worked Example: Disk Space Alert
|
||||
|
||||
```bash
|
||||
cat > ~/.hermes/scripts/disk-alert.sh <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
# Alert when / or /home is over 90% full.
|
||||
THRESHOLD=90
|
||||
df -h / /home 2>/dev/null | awk -v t="$THRESHOLD" '
|
||||
NR > 1 && $5+0 >= t {
|
||||
printf "⚠ Disk %s full on %s\n", $5, $6
|
||||
}
|
||||
'
|
||||
EOF
|
||||
chmod +x ~/.hermes/scripts/disk-alert.sh
|
||||
|
||||
hermes cron create "*/15 * * * *" \
|
||||
--no-agent \
|
||||
--script disk-alert.sh \
|
||||
--deliver telegram \
|
||||
--name "disk-alert"
|
||||
```
|
||||
|
||||
Silent when both filesystems are under 90%; fires exactly one line per over-threshold filesystem when one fills up.
|
||||
|
||||
## Comparison with Other Patterns
|
||||
|
||||
| Approach | What runs | When to use |
|
||||
|----------|-----------|-------------|
|
||||
| `hermes send` (one-shot) | Any shell command piping into it | Ad-hoc delivery or as the action of an external scheduler (systemd, launchd) |
|
||||
| `cronjob --no-agent` (this page) | Your script on Hermes' schedule | Recurring watchdogs / alerts / metrics that don't need reasoning |
|
||||
| `cronjob` (default, LLM) | Agent with optional pre-check script | When the message content requires reasoning over data |
|
||||
| OS cron + `hermes send` | Your script on the OS schedule | When Hermes might be unhealthy (the thing you're monitoring) |
|
||||
|
||||
For critical system-health watchdogs that must fire *even when the gateway is down*, keep using OS-level cron + a plain `curl` or `hermes send` call — those run as independent OS processes and don't depend on Hermes being up. The in-gateway scheduler is the right choice when the thing being monitored is external.
|
||||
|
||||
## Related
|
||||
|
||||
- [Automate Anything with Cron](/docs/guides/automate-with-cron) — LLM-driven cron patterns.
|
||||
- [Scheduled Tasks (Cron) reference](/docs/user-guide/features/cron) — full schedule syntax, lifecycle, delivery routing.
|
||||
- [Pipe Script Output with `hermes send`](/docs/guides/pipe-script-output) — the one-shot counterpart for ad-hoc scripts.
|
||||
- [Gateway Internals](/docs/developer-guide/gateway-internals) — delivery-router internals.
|
||||
|
|
@ -145,6 +145,7 @@ The messaging gateway supports the following built-in commands inside Telegram,
|
|||
| `/undo` | Remove the last exchange. |
|
||||
| `/sethome` (alias: `/set-home`) | Mark the current chat as the platform home channel for deliveries. |
|
||||
| `/compress [focus topic]` | Manually compress conversation context. Optional focus topic narrows what the summary preserves. |
|
||||
| `/topic [off\|help\|session-id]` | **Telegram DM only.** Manage user-managed multi-session topic mode. `/topic` enables it or shows status; `/topic off` disables it and clears bindings; `/topic help` shows usage; `/topic <session-id>` inside a topic restores a previous session. See [Multi-session DM mode](/docs/user-guide/messaging/telegram#multi-session-dm-mode-topic). |
|
||||
| `/title [name]` | Set or show the session title. |
|
||||
| `/resume [name]` | Resume a previously named session. |
|
||||
| `/usage` | Show token usage, estimated cost breakdown (input/output), context window state, session duration, and — when available from the active provider — an **Account limits** section with remaining quota / credits pulled live from the provider's API. |
|
||||
|
|
@ -174,6 +175,6 @@ The messaging gateway supports the following built-in commands inside Telegram,
|
|||
|
||||
- `/skin`, `/snapshot`, `/gquota`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/skills`, `/platforms`, `/paste`, `/image`, `/statusbar`, `/plugins`, `/busy`, `/indicator`, `/redraw`, `/clear`, `/history`, `/save`, `/copy`, and `/quit` are **CLI-only** commands.
|
||||
- `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config.
|
||||
- `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, and `/commands` are **messaging-only** commands.
|
||||
- `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, `/topic`, and `/commands` are **messaging-only** commands.
|
||||
- `/status`, `/background`, `/queue`, `/steer`, `/voice`, `/reload-mcp`, `/rollback`, `/debug`, `/fast`, `/footer`, `/curator`, `/kanban`, and `/yolo` work in **both** the CLI and the messaging gateway.
|
||||
- `/voice join`, `/voice channel`, and `/voice leave` are only meaningful on Discord.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ Cron jobs can:
|
|||
- attach zero, one, or multiple skills to a job
|
||||
- deliver results back to the origin chat, local files, or configured platform targets
|
||||
- run in fresh agent sessions with the normal static tool list
|
||||
- run in **no-agent mode** — a script on a schedule, its stdout delivered verbatim, zero LLM involvement (see the [no-agent mode](#no-agent-mode-script-only-jobs) section below)
|
||||
|
||||
All of this is available to Hermes itself through the `cronjob` tool, so you can create, pause, edit, and remove jobs by asking in plain language — no CLI required.
|
||||
|
||||
:::warning
|
||||
Cron-run sessions cannot recursively create more cron jobs. Hermes disables cron management tools inside cron executions to prevent runaway scheduling loops.
|
||||
|
|
@ -286,6 +289,48 @@ cron:
|
|||
|
||||
Or set the `HERMES_CRON_SCRIPT_TIMEOUT` environment variable. The resolution order is: env var → config.yaml → 120s default.
|
||||
|
||||
## No-agent mode (script-only jobs)
|
||||
|
||||
For recurring jobs that don't need LLM reasoning — classic watchdogs, disk/memory alerts, heartbeats, CI pings — pass `no_agent=True` at creation time. The scheduler runs your script on schedule and delivers its stdout directly, skipping the agent entirely:
|
||||
|
||||
```bash
|
||||
hermes cron create "every 5m" \
|
||||
--no-agent \
|
||||
--script memory-watchdog.sh \
|
||||
--deliver telegram \
|
||||
--name "memory-watchdog"
|
||||
```
|
||||
|
||||
Semantics:
|
||||
|
||||
- Script stdout (trimmed) → delivered verbatim as the message.
|
||||
- **Empty stdout → silent tick**, no delivery. This is the watchdog pattern: "only say something when something is wrong".
|
||||
- Non-zero exit or timeout → an error alert is delivered, so a broken watchdog can't fail silently.
|
||||
- `{"wakeAgent": false}` on the last line → silent tick (same gate LLM jobs use).
|
||||
- No tokens, no model, no provider fallback — the job never touches the inference layer.
|
||||
|
||||
`.sh` / `.bash` files run under `/bin/bash`; anything else under the current Python interpreter (`sys.executable`). Scripts must live in `~/.hermes/scripts/` (same sandboxing rule as the pre-run script gate).
|
||||
|
||||
### The agent sets these up for you
|
||||
|
||||
The `cronjob` tool's schema exposes `no_agent` to Hermes directly, so you can describe a watchdog in chat and let the agent wire it up:
|
||||
|
||||
```text
|
||||
Ping me on Telegram if RAM is over 85%, every 5 minutes.
|
||||
```
|
||||
|
||||
Hermes will write the check script to `~/.hermes/scripts/` via `write_file`, then call:
|
||||
|
||||
```python
|
||||
cronjob(action="create", schedule="every 5m",
|
||||
script="memory-watchdog.sh", no_agent=True,
|
||||
deliver="telegram", name="memory-watchdog")
|
||||
```
|
||||
|
||||
It picks `no_agent=True` automatically when the message content is fully determined by the script (watchdogs, threshold alerts, heartbeats). The same tool also lets the agent pause, resume, edit, and remove jobs — so the whole lifecycle is chat-driven without anyone touching the CLI.
|
||||
|
||||
See the [Script-Only Cron Jobs guide](/docs/guides/cron-script-only) for worked examples.
|
||||
|
||||
## Provider recovery
|
||||
|
||||
Cron jobs inherit your configured fallback providers and credential pool rotation. If the primary API key is rate-limited or the provider returns an error, the cron agent can:
|
||||
|
|
|
|||
|
|
@ -396,6 +396,130 @@ For example, a topic with `skill: arxiv` will have the arxiv skill pre-loaded wh
|
|||
Topics created outside of the config (e.g., by manually calling the Telegram API) are discovered automatically when a `forum_topic_created` service message arrives. You can also add topics to the config while the gateway is running — they'll be picked up on the next cache miss.
|
||||
:::
|
||||
|
||||
## Multi-session DM mode (`/topic`)
|
||||
|
||||
A ChatGPT-style multi-session DM — one bot, many parallel conversations. Unlike the operator-curated `extra.dm_topics` above, this mode is **user-driven**: no config, no pre-declared topic names. The end user flips it on with `/topic`, then taps the Telegram **+** button to create as many topics as they want, each one a fully independent Hermes session.
|
||||
|
||||
### `/topic` subcommands
|
||||
|
||||
| Form | Context | Effect |
|
||||
|------|---------|--------|
|
||||
| `/topic` | Root DM, not yet enabled | Check BotFather capabilities, enable multi-session mode, create pinned System topic |
|
||||
| `/topic` | Root DM, already enabled | Show status: unlinked sessions available for restore |
|
||||
| `/topic` | Inside a topic | Show the current topic's session binding |
|
||||
| `/topic help` | Any | Inline usage |
|
||||
| `/topic off` | Root DM | Disable multi-session mode and clear all topic bindings for this chat |
|
||||
| `/topic <session-id>` | Inside a topic | Restore a previous Telegram session into the current topic |
|
||||
|
||||
Only authorized users (allowlist via `TELEGRAM_ALLOWED_USERS` / platform auth config) can run `/topic`. An unauthorized sender gets a refusal instead of activation.
|
||||
|
||||
### DM Topics vs Multi-session DM mode
|
||||
|
||||
| | `extra.dm_topics` (config-driven) | `/topic` (user-driven) |
|
||||
|---|---|---|
|
||||
| Who activates it | Operator, in `config.yaml` | End user, by sending `/topic` |
|
||||
| Topic list | Fixed set declared in config | User creates/deletes topics freely |
|
||||
| Topic names | Chosen by operator | Chosen by user; auto-renamed to match Hermes session title |
|
||||
| Root DM behavior | Unchanged — normal chat | Becomes a system lobby (non-command messages are rejected) |
|
||||
| Primary use case | Permanent workspaces with optional skill binding | Ad-hoc parallel sessions |
|
||||
| Persistence | `extra.dm_topics` in config | `telegram_dm_topic_mode` + `telegram_dm_topic_bindings` SQLite tables |
|
||||
|
||||
Both features can coexist on the same bot — you'd run `/topic` from a user's DM, and `extra.dm_topics` continues to manage operator-declared topics for other chats.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
In **@BotFather**, open your bot → **Bot Settings → Threads Settings**:
|
||||
|
||||
1. Turn on **Threaded Mode** (enables `has_topics_enabled`)
|
||||
2. Do **not** disable users creating topics (keeps `allows_users_to_create_topics` on)
|
||||
|
||||
When the user first runs `/topic`, Hermes calls `getMe` to verify both flags. If either is off, Hermes sends a screenshot of the BotFather Threads Settings page and explains what to toggle — no activation happens until prerequisites are met.
|
||||
|
||||
### Activation flow
|
||||
|
||||
From the root DM, send:
|
||||
|
||||
```
|
||||
/topic
|
||||
```
|
||||
|
||||
Hermes will:
|
||||
|
||||
1. Check `getMe().has_topics_enabled` and `allows_users_to_create_topics`
|
||||
2. If both are true, enable multi-session topic mode for this DM
|
||||
3. Create and pin a **System** topic for status/commands (best-effort)
|
||||
4. Reply with a list of previous unlinked Telegram sessions the user can restore
|
||||
|
||||
After activation, the **root DM is a lobby**: normal prompts are rejected with guidance pointing at **All Messages**. System commands (`/status`, `/sessions`, `/usage`, `/help`, etc.) still work in the root.
|
||||
|
||||
### Creating a new topic (end-user flow)
|
||||
|
||||
1. Open the bot DM in Telegram
|
||||
2. Tap **All Messages** at the top of the bot interface, then send any message
|
||||
3. Telegram creates a new topic for that message
|
||||
4. Hermes responds inside that topic — the topic is now a standalone session
|
||||
|
||||
Every topic gets its own conversation history, model state, tool execution, and session ID. The isolation key is `agent:main:telegram:dm:{chat_id}:{thread_id}` — identical to the config-driven DM topics isolation.
|
||||
|
||||
### Auto-renamed topics
|
||||
|
||||
When Hermes generates a session title for a topic (via the auto-title pipeline, after the first exchange), the Telegram topic itself is renamed to match — e.g. "New Topic" becomes "Database migration plan". The rename is best-effort: failures are logged but don't break the session.
|
||||
|
||||
### `/new` inside a topic
|
||||
|
||||
Resets the current topic's session (new session ID, fresh history) without touching other topics. Hermes replies with a reminder that for parallel work, creating another topic (via **All Messages**) is usually what you want.
|
||||
|
||||
### Restoring a previous session
|
||||
|
||||
Inside a topic, send:
|
||||
|
||||
```
|
||||
/topic <session-id>
|
||||
```
|
||||
|
||||
This binds the current topic to an existing Hermes session instead of starting fresh. Useful for continuing a conversation that started before topic mode was enabled. Restrictions:
|
||||
|
||||
- The target session must belong to the same Telegram user
|
||||
- The target session must not already be bound to another topic
|
||||
|
||||
Hermes confirms with the session title and replays the last assistant message for context.
|
||||
|
||||
To discover session IDs, send `/topic` (no argument) in the root DM — Hermes lists the user's unlinked Telegram sessions.
|
||||
|
||||
### `/topic` inside a topic (no argument)
|
||||
|
||||
Shows the current topic's binding: session title, session ID, and hints for `/new` vs creating another topic.
|
||||
|
||||
### Under the hood
|
||||
|
||||
- Activation persists to `telegram_dm_topic_mode(chat_id, user_id, enabled, ...)` in `state.db`
|
||||
- Each topic binding persists to `telegram_dm_topic_bindings(chat_id, thread_id, session_id, ...)` with `ON DELETE CASCADE` on `session_id` — pruning a session automatically clears its topic binding
|
||||
- The topic-mode SQLite migration is **opt-in**: it runs on the first `/topic` call, never on gateway startup. Until a user runs `/topic` in this profile, `state.db` is unchanged
|
||||
- Each inbound DM message looks up its `(chat_id, thread_id)` binding. If present, the lookup routes the message to the bound session via `SessionStore.switch_session()` so the session-key-to-session-id mapping stays consistent on disk
|
||||
- `/new` inside a topic rewrites the binding row to point at the new session ID, so the next message stays on the fresh session
|
||||
- Topics declared in `extra.dm_topics` are **never auto-renamed** — the operator-chosen name is preserved even when multi-session mode is enabled
|
||||
- The General (pinned top) topic in a forum-enabled DM is treated as the root lobby, regardless of whether Telegram delivers its messages with `message_thread_id=1` or with no thread_id
|
||||
- Root-lobby reminders are rate-limited to one message per 30 seconds per chat — a user who forgets topic mode is on and types ten prompts in the root won't get ten replies
|
||||
- BotFather setup screenshots are rate-limited to one send per 5 minutes per chat — repeated `/topic` attempts while Threads Settings are still disabled won't re-upload the same image
|
||||
- `/background <prompt>` started inside a topic delivers its result back to the same topic; background sessions don't trigger auto-rename of the owning topic
|
||||
- `/topic` itself is gated by the bot's user authorization check — unauthorized DMs get a refusal instead of activation
|
||||
|
||||
### Disabling multi-session mode
|
||||
|
||||
Send `/topic off` in the root DM. Hermes flips the row off, clears the chat's `(thread_id → session_id)` bindings, and the root DM reverts to a normal Hermes chat. Existing topics in Telegram aren't deleted — they just stop being gated as independent sessions. Re-run `/topic` later to turn it back on.
|
||||
|
||||
If you need to clean up by hand (e.g. a bulk reset across many chats), remove the rows directly:
|
||||
|
||||
```bash
|
||||
sqlite3 ~/.hermes/state.db \
|
||||
"UPDATE telegram_dm_topic_mode SET enabled = 0 WHERE chat_id = '<your_chat_id>'; \
|
||||
DELETE FROM telegram_dm_topic_bindings WHERE chat_id = '<your_chat_id>';"
|
||||
```
|
||||
|
||||
### Downgrading Hermes
|
||||
|
||||
If you downgrade to a Hermes version that predates `/topic`, the feature simply stops working — the `telegram_dm_topic_mode` and `telegram_dm_topic_bindings` tables remain in `state.db` but are ignored by older code. DMs revert to the native per-thread isolation (each `message_thread_id` still gets its own session via `build_session_key`), so your existing Telegram topics keep working as parallel sessions. The root DM is no longer a lobby — messages there go into the agent like they used to. Re-upgrading reactivates multi-session mode exactly where it was.
|
||||
|
||||
## Group Forum Topic Skill Binding
|
||||
|
||||
Supergroups with **Topics mode** enabled (also called "forum topics") already get session isolation per topic — each `thread_id` maps to its own conversation. But you may want to **auto-load a skill** when messages arrive in a specific group topic, just like DM topic skill binding works.
|
||||
|
|
@ -463,7 +587,7 @@ To find a topic's `thread_id`, open the topic in Telegram Web or Desktop and loo
|
|||
|
||||
## Recent Bot API Features
|
||||
|
||||
- **Bot API 9.4 (Feb 2026):** Private Chat Topics — bots can create forum topics in 1-on-1 DM chats via `createForumTopic`. See [Private Chat Topics](#private-chat-topics-bot-api-94) above.
|
||||
- **Bot API 9.4 (Feb 2026):** Private Chat Topics — bots can create forum topics in 1-on-1 DM chats via `createForumTopic`. Hermes uses this for two distinct features: operator-curated [Private Chat Topics](#private-chat-topics-bot-api-94) (config-driven, fixed topic list) and user-driven [Multi-session DM mode](#multi-session-dm-mode-topic) (activated by `/topic`, unlimited user-created topics).
|
||||
- **Privacy policy:** Telegram now requires bots to have a privacy policy. Set one via BotFather with `/setprivacy_policy`, or Telegram may auto-generate a placeholder. This is particularly important if your bot is public-facing.
|
||||
- **Message streaming:** Bot API 9.x added support for streaming long responses, which can improve perceived latency for lengthy agent replies.
|
||||
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ const sidebars: SidebarsConfig = {
|
|||
'guides/use-voice-mode-with-hermes',
|
||||
'guides/build-a-hermes-plugin',
|
||||
'guides/automate-with-cron',
|
||||
'guides/cron-script-only',
|
||||
'guides/automation-templates',
|
||||
'guides/cron-troubleshooting',
|
||||
'guides/work-with-skills',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue