mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 02:53:19 +08:00
Compare commits
1 Commits
hermes/her
...
claude-cod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff8ec0d9cf |
@@ -123,8 +123,6 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
"moonshotai/Kimi-K2-Thinking": 262144,
|
||||
"MiniMaxAI/MiniMax-M2.5": 204800,
|
||||
"XiaomiMiMo/MiMo-V2-Flash": 32768,
|
||||
"mimo-v2-pro": 1048576,
|
||||
"mimo-v2-omni": 1048576,
|
||||
"zai-org/GLM-5": 202752,
|
||||
}
|
||||
|
||||
|
||||
139
cli.py
139
cli.py
@@ -983,28 +983,6 @@ def _build_compact_banner() -> str:
|
||||
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Slash-command detection helper
|
||||
# ============================================================================
|
||||
|
||||
def _looks_like_slash_command(text: str) -> bool:
|
||||
"""Return True if *text* looks like a slash command, not a file path.
|
||||
|
||||
Slash commands are ``/help``, ``/model gpt-4``, ``/q``, etc.
|
||||
File paths like ``/Users/ironin/file.md:45-46 can you fix this?``
|
||||
also start with ``/`` but contain additional ``/`` characters in
|
||||
the first whitespace-delimited word. This helper distinguishes
|
||||
the two so that pasted paths are sent to the agent instead of
|
||||
triggering "Unknown command".
|
||||
"""
|
||||
if not text or not text.startswith("/"):
|
||||
return False
|
||||
first_word = text.split()[0]
|
||||
# After stripping the leading /, a command name has no slashes.
|
||||
# A path like /Users/foo/bar.md always does.
|
||||
return "/" not in first_word[1:]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Skill Slash Commands — dynamic commands generated from installed skills
|
||||
# ============================================================================
|
||||
@@ -3320,6 +3298,117 @@ class HermesCLI:
|
||||
else:
|
||||
_cprint(f" ↻ Resumed session {target_id}{title_part} — no messages, starting fresh.")
|
||||
|
||||
def _handle_branch_command(self, cmd_original: str) -> None:
|
||||
"""Handle /branch [name] — fork the current session into a new independent copy.
|
||||
|
||||
Copies the full conversation history to a new session so the user can
|
||||
explore a different approach without losing the original session state.
|
||||
Inspired by Claude Code's /branch command.
|
||||
"""
|
||||
if not self.conversation_history:
|
||||
_cprint(" No conversation to branch — send a message first.")
|
||||
return
|
||||
|
||||
if not self._session_db:
|
||||
_cprint(" Session database not available.")
|
||||
return
|
||||
|
||||
parts = cmd_original.split(None, 1)
|
||||
branch_name = parts[1].strip() if len(parts) > 1 else ""
|
||||
|
||||
# Generate the new session ID
|
||||
now = datetime.now()
|
||||
timestamp_str = now.strftime("%Y%m%d_%H%M%S")
|
||||
short_uuid = uuid.uuid4().hex[:6]
|
||||
new_session_id = f"{timestamp_str}_{short_uuid}"
|
||||
|
||||
# Determine branch title
|
||||
if branch_name:
|
||||
branch_title = branch_name
|
||||
else:
|
||||
# Auto-generate from the current session title
|
||||
current_title = None
|
||||
if self._session_db:
|
||||
current_title = self._session_db.get_session_title(self.session_id)
|
||||
base = current_title or "branch"
|
||||
branch_title = self._session_db.get_next_title_in_lineage(base)
|
||||
|
||||
# Save the current session's state before branching
|
||||
parent_session_id = self.session_id
|
||||
|
||||
# End the old session
|
||||
try:
|
||||
self._session_db.end_session(self.session_id, "branched")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Create the new session with parent link
|
||||
try:
|
||||
self._session_db.create_session(
|
||||
session_id=new_session_id,
|
||||
source=os.environ.get("HERMES_SESSION_SOURCE", "cli"),
|
||||
model=self.model,
|
||||
model_config={
|
||||
"max_iterations": self.max_turns,
|
||||
"reasoning_config": self.reasoning_config,
|
||||
},
|
||||
parent_session_id=parent_session_id,
|
||||
)
|
||||
except Exception as e:
|
||||
_cprint(f" Failed to create branch session: {e}")
|
||||
return
|
||||
|
||||
# Copy conversation history to the new session
|
||||
for msg in self.conversation_history:
|
||||
try:
|
||||
self._session_db.append_message(
|
||||
session_id=new_session_id,
|
||||
role=msg.get("role", "user"),
|
||||
content=msg.get("content"),
|
||||
tool_name=msg.get("tool_name") or msg.get("name"),
|
||||
tool_calls=msg.get("tool_calls"),
|
||||
tool_call_id=msg.get("tool_call_id"),
|
||||
reasoning=msg.get("reasoning"),
|
||||
)
|
||||
except Exception:
|
||||
pass # Best-effort copy
|
||||
|
||||
# Set title on the branch
|
||||
try:
|
||||
self._session_db.set_session_title(new_session_id, branch_title)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Switch to the new session
|
||||
self.session_id = new_session_id
|
||||
self.session_start = now
|
||||
self._pending_title = None
|
||||
self._resumed = True # Prevents auto-title generation
|
||||
|
||||
# Sync the agent
|
||||
if self.agent:
|
||||
self.agent.session_id = new_session_id
|
||||
self.agent.session_start = now
|
||||
self.agent.reset_session_state()
|
||||
if hasattr(self.agent, "_last_flushed_db_idx"):
|
||||
self.agent._last_flushed_db_idx = len(self.conversation_history)
|
||||
if hasattr(self.agent, "_todo_store"):
|
||||
try:
|
||||
from tools.todo_tool import TodoStore
|
||||
self.agent._todo_store = TodoStore()
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(self.agent, "_invalidate_system_prompt"):
|
||||
self.agent._invalidate_system_prompt()
|
||||
|
||||
msg_count = len([m for m in self.conversation_history if m.get("role") == "user"])
|
||||
_cprint(
|
||||
f" ⑂ Branched session \"{branch_title}\""
|
||||
f" ({msg_count} user message{'s' if msg_count != 1 else ''})"
|
||||
)
|
||||
_cprint(f" Original session: {parent_session_id}")
|
||||
_cprint(f" Branch session: {new_session_id}")
|
||||
|
||||
def reset_conversation(self):
|
||||
"""Reset the conversation by starting a new session."""
|
||||
# Shut down memory provider before resetting — actual session boundary
|
||||
@@ -4040,6 +4129,8 @@ class HermesCLI:
|
||||
self._pending_input.put(retry_msg)
|
||||
elif canonical == "undo":
|
||||
self.undo_last()
|
||||
elif canonical == "branch":
|
||||
self._handle_branch_command(cmd_original)
|
||||
elif canonical == "save":
|
||||
self.save_conversation()
|
||||
elif canonical == "cron":
|
||||
@@ -6734,7 +6825,7 @@ class HermesCLI:
|
||||
event.app.invalidate()
|
||||
# Bundle text + images as a tuple when images are present
|
||||
payload = (text, images) if images else text
|
||||
if self._agent_running and not (text and _looks_like_slash_command(text)):
|
||||
if self._agent_running and not (text and text.startswith("/")):
|
||||
if self.busy_input_mode == "queue":
|
||||
# Queue for the next turn instead of interrupting
|
||||
self._pending_input.put(payload)
|
||||
@@ -7718,7 +7809,7 @@ class HermesCLI:
|
||||
+ (f"\n{_remainder}" if _remainder else "")
|
||||
)
|
||||
|
||||
if not _file_drop and isinstance(user_input, str) and _looks_like_slash_command(user_input):
|
||||
if not _file_drop and isinstance(user_input, str) and user_input.startswith("/"):
|
||||
_cprint(f"\n⚙️ {user_input}")
|
||||
if not self.process_command(user_input):
|
||||
self._should_exit = True
|
||||
|
||||
@@ -1022,32 +1022,6 @@ class BasePlatformAdapter(ABC):
|
||||
|
||||
# Check if there's already an active handler for this session
|
||||
if session_key in self._active_sessions:
|
||||
# /approve and /deny must bypass the active-session guard.
|
||||
# The agent thread is blocked on threading.Event.wait() inside
|
||||
# tools/approval.py — queuing these commands creates a deadlock:
|
||||
# the agent waits for approval, approval waits for agent to finish.
|
||||
# Dispatch directly to the message handler without touching session
|
||||
# lifecycle (no competing background task, no session guard removal).
|
||||
cmd = event.get_command()
|
||||
if cmd in ("approve", "deny"):
|
||||
logger.debug(
|
||||
"[%s] Approval command '/%s' bypassing active-session guard for %s",
|
||||
self.name, cmd, session_key,
|
||||
)
|
||||
try:
|
||||
_thread_meta = {"thread_id": event.source.thread_id} if event.source.thread_id else None
|
||||
response = await self._message_handler(event)
|
||||
if response:
|
||||
await self._send_with_retry(
|
||||
chat_id=event.source.chat_id,
|
||||
content=response,
|
||||
reply_to=event.message_id,
|
||||
metadata=_thread_meta,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("[%s] Approval dispatch failed: %s", self.name, e, exc_info=True)
|
||||
return
|
||||
|
||||
# Special case: photo bursts/albums frequently arrive as multiple near-
|
||||
# simultaneous messages. Queue them without interrupting the active run,
|
||||
# then process them immediately after the current task finishes.
|
||||
|
||||
@@ -449,11 +449,6 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
self._bot_task: Optional[asyncio.Task] = None
|
||||
# Cap to prevent unbounded growth (Discord threads get archived).
|
||||
self._MAX_TRACKED_THREADS = 500
|
||||
# Dedup cache: message_id → timestamp. Prevents duplicate bot
|
||||
# responses when Discord RESUME replays events after reconnects.
|
||||
self._seen_messages: Dict[str, float] = {}
|
||||
self._SEEN_TTL = 300 # 5 minutes
|
||||
self._SEEN_MAX = 2000 # prune threshold
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to Discord and start receiving events."""
|
||||
@@ -544,19 +539,6 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
|
||||
@self._client.event
|
||||
async def on_message(message: DiscordMessage):
|
||||
# Dedup: Discord RESUME replays events after reconnects (#4777)
|
||||
msg_id = str(message.id)
|
||||
now = time.time()
|
||||
if msg_id in adapter_self._seen_messages:
|
||||
return
|
||||
adapter_self._seen_messages[msg_id] = now
|
||||
if len(adapter_self._seen_messages) > adapter_self._SEEN_MAX:
|
||||
cutoff = now - adapter_self._SEEN_TTL
|
||||
adapter_self._seen_messages = {
|
||||
k: v for k, v in adapter_self._seen_messages.items()
|
||||
if v > cutoff
|
||||
}
|
||||
|
||||
# Always ignore our own messages
|
||||
if message.author == self._client.user:
|
||||
return
|
||||
|
||||
@@ -13,7 +13,6 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Dict, Optional, Any
|
||||
|
||||
try:
|
||||
@@ -79,11 +78,6 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
self._team_clients: Dict[str, AsyncWebClient] = {} # team_id → WebClient
|
||||
self._team_bot_user_ids: Dict[str, str] = {} # team_id → bot_user_id
|
||||
self._channel_team: Dict[str, str] = {} # channel_id → team_id
|
||||
# Dedup cache: event_ts → timestamp. Prevents duplicate bot
|
||||
# responses when Socket Mode reconnects redeliver events.
|
||||
self._seen_messages: Dict[str, float] = {}
|
||||
self._SEEN_TTL = 300 # 5 minutes
|
||||
self._SEEN_MAX = 2000 # prune threshold
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to Slack via Socket Mode."""
|
||||
@@ -716,20 +710,6 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
|
||||
async def _handle_slack_message(self, event: dict) -> None:
|
||||
"""Handle an incoming Slack message event."""
|
||||
# Dedup: Slack Socket Mode can redeliver events after reconnects (#4777)
|
||||
event_ts = event.get("ts", "")
|
||||
if event_ts:
|
||||
now = time.time()
|
||||
if event_ts in self._seen_messages:
|
||||
return
|
||||
self._seen_messages[event_ts] = now
|
||||
if len(self._seen_messages) > self._SEEN_MAX:
|
||||
cutoff = now - self._SEEN_TTL
|
||||
self._seen_messages = {
|
||||
k: v for k, v in self._seen_messages.items()
|
||||
if v > cutoff
|
||||
}
|
||||
|
||||
# Ignore bot messages (including our own)
|
||||
if event.get("bot_id") or event.get("subtype") == "bot_message":
|
||||
return
|
||||
|
||||
@@ -2101,19 +2101,6 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
if not chat_topic:
|
||||
chat_topic = created_name
|
||||
|
||||
elif chat_type == "group" and thread_id_str:
|
||||
# Group/supergroup forum topic skill binding via config.extra['group_topics']
|
||||
group_topics_config: list = self.config.extra.get("group_topics", [])
|
||||
for chat_entry in group_topics_config:
|
||||
if str(chat_entry.get("chat_id", "")) == str(chat.id):
|
||||
for topic in chat_entry.get("topics", []):
|
||||
tid = topic.get("thread_id")
|
||||
if tid is not None and str(tid) == thread_id_str:
|
||||
chat_topic = topic.get("name")
|
||||
topic_skill = topic.get("skill")
|
||||
break
|
||||
break
|
||||
|
||||
# Build source
|
||||
source = self.build_source(
|
||||
chat_id=str(chat.id),
|
||||
|
||||
142
gateway/run.py
142
gateway/run.py
@@ -1985,6 +1985,9 @@ class GatewayRunner:
|
||||
if canonical == "resume":
|
||||
return await self._handle_resume_command(event)
|
||||
|
||||
if canonical == "branch":
|
||||
return await self._handle_branch_command(event)
|
||||
|
||||
if canonical == "rollback":
|
||||
return await self._handle_rollback_command(event)
|
||||
|
||||
@@ -4359,9 +4362,9 @@ class GatewayRunner:
|
||||
cycle = ["off", "new", "all", "verbose"]
|
||||
descriptions = {
|
||||
"off": "⚙️ Tool progress: **OFF** — no tool activity shown.",
|
||||
"new": "⚙️ Tool progress: **NEW** — shown when tool changes (short previews).",
|
||||
"all": "⚙️ Tool progress: **ALL** — every tool call shown (short previews).",
|
||||
"verbose": "⚙️ Tool progress: **VERBOSE** — every tool call with full arguments.",
|
||||
"new": "⚙️ Tool progress: **NEW** — shown when tool changes.",
|
||||
"all": "⚙️ Tool progress: **ALL** — every tool call shown.",
|
||||
"verbose": "⚙️ Tool progress: **VERBOSE** — full args and results.",
|
||||
}
|
||||
|
||||
raw_progress = user_config.get("display", {}).get("tool_progress", "all")
|
||||
@@ -4582,6 +4585,96 @@ class GatewayRunner:
|
||||
|
||||
return f"↻ Resumed session **{title}**{msg_part}. Conversation restored."
|
||||
|
||||
async def _handle_branch_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /branch [name] — fork the current session into a new independent copy.
|
||||
|
||||
Copies conversation history to a new session so the user can explore
|
||||
a different approach without losing the original.
|
||||
Inspired by Claude Code's /branch command.
|
||||
"""
|
||||
import uuid as _uuid
|
||||
|
||||
if not self._session_db:
|
||||
return "Session database not available."
|
||||
|
||||
source = event.source
|
||||
session_key = self._session_key_for_source(source)
|
||||
|
||||
# Load the current session and its transcript
|
||||
current_entry = self.session_store.get_or_create_session(source)
|
||||
history = self.session_store.load_transcript(current_entry.session_id)
|
||||
if not history:
|
||||
return "No conversation to branch — send a message first."
|
||||
|
||||
branch_name = event.get_command_args().strip()
|
||||
|
||||
# Generate the new session ID
|
||||
from datetime import datetime as _dt
|
||||
now = _dt.now()
|
||||
timestamp_str = now.strftime("%Y%m%d_%H%M%S")
|
||||
short_uuid = _uuid.uuid4().hex[:6]
|
||||
new_session_id = f"{timestamp_str}_{short_uuid}"
|
||||
|
||||
# Determine branch title
|
||||
if branch_name:
|
||||
branch_title = branch_name
|
||||
else:
|
||||
current_title = self._session_db.get_session_title(current_entry.session_id)
|
||||
base = current_title or "branch"
|
||||
branch_title = self._session_db.get_next_title_in_lineage(base)
|
||||
|
||||
parent_session_id = current_entry.session_id
|
||||
|
||||
# Create the new session with parent link
|
||||
try:
|
||||
self._session_db.create_session(
|
||||
session_id=new_session_id,
|
||||
source=source.platform.value if source.platform else "gateway",
|
||||
model=(self.config.get("model", {}) or {}).get("default") if isinstance(self.config, dict) else None,
|
||||
parent_session_id=parent_session_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to create branch session: %s", e)
|
||||
return f"Failed to create branch: {e}"
|
||||
|
||||
# Copy conversation history to the new session
|
||||
for msg in history:
|
||||
try:
|
||||
self._session_db.append_message(
|
||||
session_id=new_session_id,
|
||||
role=msg.get("role", "user"),
|
||||
content=msg.get("content"),
|
||||
tool_name=msg.get("tool_name") or msg.get("name"),
|
||||
tool_calls=msg.get("tool_calls"),
|
||||
tool_call_id=msg.get("tool_call_id"),
|
||||
reasoning=msg.get("reasoning"),
|
||||
)
|
||||
except Exception:
|
||||
pass # Best-effort copy
|
||||
|
||||
# Set title
|
||||
try:
|
||||
self._session_db.set_session_title(new_session_id, branch_title)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Switch the session store entry to the new session
|
||||
new_entry = self.session_store.switch_session(session_key, new_session_id)
|
||||
if not new_entry:
|
||||
return "Branch created but failed to switch to it."
|
||||
|
||||
# Evict any cached agent for this session
|
||||
self._evict_cached_agent(session_key)
|
||||
|
||||
msg_count = len([m for m in history if m.get("role") == "user"])
|
||||
return (
|
||||
f"⑂ Branched to **{branch_title}**"
|
||||
f" ({msg_count} message{'s' if msg_count != 1 else ''} copied)\n"
|
||||
f"Original: `{parent_session_id}`\n"
|
||||
f"Branch: `{new_session_id}`\n"
|
||||
f"Use `/resume` to switch back to the original."
|
||||
)
|
||||
|
||||
async def _handle_usage_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /usage command -- show token usage for the session's last agent run."""
|
||||
source = event.source
|
||||
@@ -4872,9 +4965,7 @@ class GatewayRunner:
|
||||
"user_id": event.source.user_id,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
_tmp_pending = pending_path.with_suffix(".tmp")
|
||||
_tmp_pending.write_text(json.dumps(pending))
|
||||
_tmp_pending.replace(pending_path)
|
||||
pending_path.write_text(json.dumps(pending))
|
||||
exit_code_path.unlink(missing_ok=True)
|
||||
|
||||
# Spawn `hermes update` detached so it survives gateway restart.
|
||||
@@ -5419,28 +5510,22 @@ class GatewayRunner:
|
||||
from agent.display import get_tool_emoji
|
||||
emoji = get_tool_emoji(tool_name, default="⚙️")
|
||||
|
||||
# Verbose mode: show detailed arguments, respects tool_preview_length
|
||||
if progress_mode == "verbose":
|
||||
if args:
|
||||
from agent.display import get_tool_preview_max_len
|
||||
_pl = get_tool_preview_max_len()
|
||||
import json as _json
|
||||
args_str = _json.dumps(args, ensure_ascii=False, default=str)
|
||||
_cap = _pl if _pl > 0 else 200
|
||||
if len(args_str) > _cap:
|
||||
args_str = args_str[:_cap - 3] + "..."
|
||||
msg = f"{emoji} {tool_name}({list(args.keys())})\n{args_str}"
|
||||
elif preview:
|
||||
msg = f"{emoji} {tool_name}: \"{preview}\""
|
||||
else:
|
||||
msg = f"{emoji} {tool_name}..."
|
||||
# Verbose mode: show detailed arguments
|
||||
if progress_mode == "verbose" and args:
|
||||
import json as _json
|
||||
args_str = _json.dumps(args, ensure_ascii=False, default=str)
|
||||
if len(args_str) > 200:
|
||||
args_str = args_str[:197] + "..."
|
||||
msg = f"{emoji} {tool_name}({list(args.keys())})\n{args_str}"
|
||||
progress_queue.put(msg)
|
||||
return
|
||||
|
||||
# "all" / "new" modes: short preview, always truncated (40 chars)
|
||||
if preview:
|
||||
if len(preview) > 40:
|
||||
preview = preview[:37] + "..."
|
||||
# Truncate preview unless config says unlimited
|
||||
from agent.display import get_tool_preview_max_len
|
||||
_pl = get_tool_preview_max_len()
|
||||
if _pl > 0 and len(preview) > _pl:
|
||||
preview = preview[:_pl - 3] + "..."
|
||||
msg = f"{emoji} {tool_name}: \"{preview}\""
|
||||
else:
|
||||
msg = f"{emoji} {tool_name}..."
|
||||
@@ -5857,12 +5942,7 @@ class GatewayRunner:
|
||||
# command approval blocks the agent thread (mirrors CLI input()).
|
||||
# The callback bridges sync→async to send the approval request
|
||||
# to the user immediately.
|
||||
from tools.approval import (
|
||||
register_gateway_notify,
|
||||
reset_current_session_key,
|
||||
set_current_session_key,
|
||||
unregister_gateway_notify,
|
||||
)
|
||||
from tools.approval import register_gateway_notify, unregister_gateway_notify
|
||||
|
||||
def _approval_notify_sync(approval_data: dict) -> None:
|
||||
"""Send the approval request to the user from the agent thread.
|
||||
@@ -5918,13 +5998,11 @@ class GatewayRunner:
|
||||
logger.error("Failed to send approval request: %s", _e)
|
||||
|
||||
_approval_session_key = session_key or ""
|
||||
_approval_session_token = set_current_session_key(_approval_session_key)
|
||||
register_gateway_notify(_approval_session_key, _approval_notify_sync)
|
||||
try:
|
||||
result = agent.run_conversation(message, conversation_history=agent_history, task_id=session_id)
|
||||
finally:
|
||||
unregister_gateway_notify(_approval_session_key)
|
||||
reset_current_session_key(_approval_session_token)
|
||||
result_holder[0] = result
|
||||
|
||||
# Signal the stream consumer that the agent is done
|
||||
|
||||
@@ -57,6 +57,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("undo", "Remove the last user/assistant exchange", "Session"),
|
||||
CommandDef("title", "Set a title for the current session", "Session",
|
||||
args_hint="[name]"),
|
||||
CommandDef("branch", "Branch the current session (explore a different path)", "Session",
|
||||
aliases=("fork",), args_hint="[name]"),
|
||||
CommandDef("compress", "Manually compress conversation context", "Session"),
|
||||
CommandDef("rollback", "List or restore filesystem checkpoints", "Session",
|
||||
args_hint="[number]"),
|
||||
|
||||
@@ -201,10 +201,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"opencode-go": [
|
||||
"glm-5",
|
||||
"kimi-k2.5",
|
||||
"mimo-v2-pro",
|
||||
"mimo-v2-omni",
|
||||
"minimax-m2.7",
|
||||
"minimax-m2.5",
|
||||
],
|
||||
"ai-gateway": [
|
||||
"anthropic/claude-opus-4.6",
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from hermes_cli import auth as auth_mod
|
||||
@@ -169,13 +168,6 @@ def _resolve_runtime_from_pool_entry(
|
||||
elif base_url.rstrip("/").endswith("/anthropic"):
|
||||
api_mode = "anthropic_messages"
|
||||
|
||||
# OpenCode base URLs end with /v1 for OpenAI-compatible models, but the
|
||||
# Anthropic SDK prepends its own /v1/messages to the base_url. Strip the
|
||||
# trailing /v1 so the SDK constructs the correct path (e.g.
|
||||
# https://opencode.ai/zen/go/v1/messages instead of .../v1/v1/messages).
|
||||
if api_mode == "anthropic_messages" and provider in ("opencode-zen", "opencode-go"):
|
||||
base_url = re.sub(r"/v1/?$", "", base_url)
|
||||
|
||||
return {
|
||||
"provider": provider,
|
||||
"api_mode": api_mode,
|
||||
@@ -708,9 +700,6 @@ def resolve_runtime_provider(
|
||||
# (e.g. https://api.minimax.io/anthropic, https://dashscope.../anthropic)
|
||||
elif base_url.rstrip("/").endswith("/anthropic"):
|
||||
api_mode = "anthropic_messages"
|
||||
# Strip trailing /v1 for OpenCode Anthropic models (see comment above).
|
||||
if api_mode == "anthropic_messages" and provider in ("opencode-zen", "opencode-go"):
|
||||
base_url = re.sub(r"/v1/?$", "", base_url)
|
||||
return {
|
||||
"provider": provider,
|
||||
"api_mode": api_mode,
|
||||
|
||||
@@ -115,7 +115,7 @@ _DEFAULT_PROVIDER_MODELS = {
|
||||
"ai-gateway": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5", "google/gemini-3-flash"],
|
||||
"kilocode": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5.4", "google/gemini-3-pro-preview", "google/gemini-3-flash-preview"],
|
||||
"opencode-zen": ["gpt-5.4", "gpt-5.3-codex", "claude-sonnet-4-6", "gemini-3-flash", "glm-5", "kimi-k2.5", "minimax-m2.7"],
|
||||
"opencode-go": ["glm-5", "kimi-k2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.5", "minimax-m2.7"],
|
||||
"opencode-go": ["glm-5", "kimi-k2.5", "minimax-m2.5", "minimax-m2.7"],
|
||||
"huggingface": [
|
||||
"Qwen/Qwen3.5-397B-A17B", "Qwen/Qwen3-235B-A22B-Thinking-2507",
|
||||
"Qwen/Qwen3-Coder-480B-A35B-Instruct", "deepseek-ai/DeepSeek-R1-0528",
|
||||
|
||||
@@ -10,8 +10,6 @@ lifecycle instead of read-only search endpoints.
|
||||
Config via environment variables (profile-scoped via each profile's .env):
|
||||
OPENVIKING_ENDPOINT — Server URL (default: http://127.0.0.1:1933)
|
||||
OPENVIKING_API_KEY — API key (required for authenticated servers)
|
||||
OPENVIKING_ACCOUNT — Tenant account (default: root)
|
||||
OPENVIKING_USER — Tenant user (default: default)
|
||||
|
||||
Capabilities:
|
||||
- Automatic memory extraction on session commit (6 categories)
|
||||
@@ -53,22 +51,15 @@ def _get_httpx():
|
||||
class _VikingClient:
|
||||
"""Thin HTTP client for the OpenViking REST API."""
|
||||
|
||||
def __init__(self, endpoint: str, api_key: str = "",
|
||||
account: str = "", user: str = ""):
|
||||
def __init__(self, endpoint: str, api_key: str = ""):
|
||||
self._endpoint = endpoint.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._account = account or os.environ.get("OPENVIKING_ACCOUNT", "root")
|
||||
self._user = user or os.environ.get("OPENVIKING_USER", "default")
|
||||
self._httpx = _get_httpx()
|
||||
if self._httpx is None:
|
||||
raise ImportError("httpx is required for OpenViking: pip install httpx")
|
||||
|
||||
def _headers(self) -> dict:
|
||||
h = {
|
||||
"Content-Type": "application/json",
|
||||
"X-OpenViking-Account": self._account,
|
||||
"X-OpenViking-User": self._user,
|
||||
}
|
||||
h = {"Content-Type": "application/json"}
|
||||
if self._api_key:
|
||||
h["X-API-Key"] = self._api_key
|
||||
return h
|
||||
|
||||
37
run_agent.py
37
run_agent.py
@@ -4488,29 +4488,6 @@ class AIAgent:
|
||||
pass
|
||||
raise InterruptedError("Agent interrupted during streaming API call")
|
||||
if result["error"] is not None:
|
||||
if deltas_were_sent["yes"]:
|
||||
# Streaming failed AFTER some tokens were already delivered to
|
||||
# the platform. Re-raising would let the outer retry loop make
|
||||
# a new API call, creating a duplicate message. Return a
|
||||
# partial "stop" response instead so the outer loop treats this
|
||||
# turn as complete (no retry, no fallback).
|
||||
logger.warning(
|
||||
"Partial stream delivered before error; returning stub "
|
||||
"response to prevent duplicate messages: %s",
|
||||
result["error"],
|
||||
)
|
||||
_stub_msg = SimpleNamespace(
|
||||
role="assistant", content=None, tool_calls=None,
|
||||
reasoning_content=None,
|
||||
)
|
||||
return SimpleNamespace(
|
||||
id="partial-stream-stub",
|
||||
model=getattr(self, "model", "unknown"),
|
||||
choices=[SimpleNamespace(
|
||||
index=0, message=_stub_msg, finish_reason="stop",
|
||||
)],
|
||||
usage=None,
|
||||
)
|
||||
raise result["error"]
|
||||
return result["response"]
|
||||
|
||||
@@ -6689,12 +6666,10 @@ class AIAgent:
|
||||
# External memory provider: prefetch once before the tool loop.
|
||||
# Reuse the cached result on every iteration to avoid re-calling
|
||||
# prefetch_all() on each tool call (10 tool calls = 10x latency + cost).
|
||||
# Use original_user_message (clean input) — user_message may contain
|
||||
# injected skill content that bloats / breaks provider queries.
|
||||
_ext_prefetch_cache = ""
|
||||
if self._memory_manager:
|
||||
try:
|
||||
_query = original_user_message if isinstance(original_user_message, str) else ""
|
||||
_query = user_message if isinstance(user_message, str) else ""
|
||||
_ext_prefetch_cache = self._memory_manager.prefetch_all(_query) or ""
|
||||
except Exception:
|
||||
pass
|
||||
@@ -8668,13 +8643,11 @@ class AIAgent:
|
||||
_should_review_skills = True
|
||||
self._iters_since_skill = 0
|
||||
|
||||
# External memory provider: sync the completed turn + queue next prefetch.
|
||||
# Use original_user_message (clean input) — user_message may contain
|
||||
# injected skill content that bloats / breaks provider queries.
|
||||
if self._memory_manager and final_response and original_user_message:
|
||||
# External memory provider: sync the completed turn + queue next prefetch
|
||||
if self._memory_manager and final_response and user_message:
|
||||
try:
|
||||
self._memory_manager.sync_all(original_user_message, final_response)
|
||||
self._memory_manager.queue_prefetch_all(original_user_message)
|
||||
self._memory_manager.sync_all(user_message, final_response)
|
||||
self._memory_manager.queue_prefetch_all(user_message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -125,9 +125,8 @@ Should print `AUTHENTICATED`. Setup is complete — token refreshes automaticall
|
||||
|
||||
### Notes
|
||||
|
||||
- Token is stored at `google_token.json` under the active profile's `HERMES_HOME` and auto-refreshes.
|
||||
- Pending OAuth session state/verifier are stored temporarily at `google_oauth_pending.json` under the active profile's `HERMES_HOME` until exchange completes.
|
||||
- Hermes now refuses to overwrite a full Google Workspace token with a narrower re-auth token missing Gmail scopes, so one profile's partial consent cannot silently break email actions later.
|
||||
- Token is stored at `~/.hermes/google_token.json` and auto-refreshes.
|
||||
- Pending OAuth session state/verifier are stored temporarily at `~/.hermes/google_oauth_pending.json` until exchange completes.
|
||||
- To revoke: `$GSETUP --revoke`
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -22,14 +22,13 @@ Usage:
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email.mime.text import MIMEText
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import display_hermes_home, get_hermes_home
|
||||
|
||||
HERMES_HOME = get_hermes_home()
|
||||
HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
TOKEN_PATH = HERMES_HOME / "google_token.json"
|
||||
|
||||
SCOPES = [
|
||||
@@ -44,18 +43,6 @@ SCOPES = [
|
||||
]
|
||||
|
||||
|
||||
def _missing_scopes() -> list[str]:
|
||||
try:
|
||||
payload = json.loads(TOKEN_PATH.read_text())
|
||||
except Exception:
|
||||
return []
|
||||
raw = payload.get("scopes") or payload.get("scope")
|
||||
if not raw:
|
||||
return []
|
||||
granted = {s.strip() for s in (raw.split() if isinstance(raw, str) else raw) if s.strip()}
|
||||
return sorted(scope for scope in SCOPES if scope not in granted)
|
||||
|
||||
|
||||
def get_credentials():
|
||||
"""Load and refresh credentials from token file."""
|
||||
if not TOKEN_PATH.exists():
|
||||
@@ -73,20 +60,6 @@ def get_credentials():
|
||||
if not creds.valid:
|
||||
print("Token is invalid. Re-run setup.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
missing_scopes = _missing_scopes()
|
||||
if missing_scopes:
|
||||
print(
|
||||
"Token is valid but missing Google Workspace scopes required by this skill.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for scope in missing_scopes:
|
||||
print(f" - {scope}", file=sys.stderr)
|
||||
print(
|
||||
f"Re-run setup.py from the active Hermes profile ({display_hermes_home()}) to restore full access.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
return creds
|
||||
|
||||
|
||||
|
||||
@@ -23,13 +23,12 @@ Agent workflow:
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import display_hermes_home, get_hermes_home
|
||||
|
||||
HERMES_HOME = get_hermes_home()
|
||||
HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
||||
TOKEN_PATH = HERMES_HOME / "google_token.json"
|
||||
CLIENT_SECRET_PATH = HERMES_HOME / "google_client_secret.json"
|
||||
PENDING_AUTH_PATH = HERMES_HOME / "google_oauth_pending.json"
|
||||
@@ -53,30 +52,6 @@ REQUIRED_PACKAGES = ["google-api-python-client", "google-auth-oauthlib", "google
|
||||
REDIRECT_URI = "http://localhost:1"
|
||||
|
||||
|
||||
def _load_token_payload(path: Path = TOKEN_PATH) -> dict:
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _missing_scopes_from_payload(payload: dict) -> list[str]:
|
||||
raw = payload.get("scopes") or payload.get("scope")
|
||||
if not raw:
|
||||
return []
|
||||
granted = {s.strip() for s in (raw.split() if isinstance(raw, str) else raw) if s.strip()}
|
||||
return sorted(scope for scope in SCOPES if scope not in granted)
|
||||
|
||||
|
||||
def _format_missing_scopes(missing_scopes: list[str]) -> str:
|
||||
bullets = "\n".join(f" - {scope}" for scope in missing_scopes)
|
||||
return (
|
||||
"Token is valid but missing required Google Workspace scopes:\n"
|
||||
f"{bullets}\n"
|
||||
"Run the Google Workspace setup again from this same Hermes profile to refresh consent."
|
||||
)
|
||||
|
||||
|
||||
def install_deps():
|
||||
"""Install Google API packages if missing. Returns True on success."""
|
||||
try:
|
||||
@@ -127,12 +102,7 @@ def check_auth():
|
||||
print(f"TOKEN_CORRUPT: {e}")
|
||||
return False
|
||||
|
||||
payload = _load_token_payload(TOKEN_PATH)
|
||||
if creds.valid:
|
||||
missing_scopes = _missing_scopes_from_payload(payload)
|
||||
if missing_scopes:
|
||||
print(f"AUTH_SCOPE_MISMATCH: {_format_missing_scopes(missing_scopes)}")
|
||||
return False
|
||||
print(f"AUTHENTICATED: Token valid at {TOKEN_PATH}")
|
||||
return True
|
||||
|
||||
@@ -140,10 +110,6 @@ def check_auth():
|
||||
try:
|
||||
creds.refresh(Request())
|
||||
TOKEN_PATH.write_text(creds.to_json())
|
||||
missing_scopes = _missing_scopes_from_payload(_load_token_payload(TOKEN_PATH))
|
||||
if missing_scopes:
|
||||
print(f"AUTH_SCOPE_MISMATCH: {_format_missing_scopes(missing_scopes)}")
|
||||
return False
|
||||
print(f"AUTHENTICATED: Token refreshed at {TOKEN_PATH}")
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -283,17 +249,9 @@ def exchange_auth_code(code: str):
|
||||
sys.exit(1)
|
||||
|
||||
creds = flow.credentials
|
||||
token_payload = json.loads(creds.to_json())
|
||||
missing_scopes = _missing_scopes_from_payload(token_payload)
|
||||
if missing_scopes:
|
||||
print(f"ERROR: Refusing to save incomplete Google Workspace token. {_format_missing_scopes(missing_scopes)}")
|
||||
print(f"Existing token at {TOKEN_PATH} was left unchanged.")
|
||||
sys.exit(1)
|
||||
|
||||
TOKEN_PATH.write_text(json.dumps(token_payload, indent=2))
|
||||
TOKEN_PATH.write_text(creds.to_json())
|
||||
PENDING_AUTH_PATH.unlink(missing_ok=True)
|
||||
print(f"OK: Authenticated. Token saved to {TOKEN_PATH}")
|
||||
print(f"Profile-scoped token location: {display_hermes_home()}/google_token.json")
|
||||
|
||||
|
||||
def revoke():
|
||||
|
||||
@@ -390,9 +390,6 @@ class TestBlockingApprovalE2E:
|
||||
result_holder = [None]
|
||||
|
||||
def agent_thread():
|
||||
from tools.approval import reset_current_session_key, set_current_session_key
|
||||
|
||||
token = set_current_session_key(session_key)
|
||||
os.environ["HERMES_EXEC_ASK"] = "1"
|
||||
os.environ["HERMES_SESSION_KEY"] = session_key
|
||||
try:
|
||||
@@ -402,7 +399,6 @@ class TestBlockingApprovalE2E:
|
||||
finally:
|
||||
os.environ.pop("HERMES_EXEC_ASK", None)
|
||||
os.environ.pop("HERMES_SESSION_KEY", None)
|
||||
reset_current_session_key(token)
|
||||
|
||||
t = threading.Thread(target=agent_thread)
|
||||
t.start()
|
||||
@@ -436,9 +432,6 @@ class TestBlockingApprovalE2E:
|
||||
result_holder = [None]
|
||||
|
||||
def agent_thread():
|
||||
from tools.approval import reset_current_session_key, set_current_session_key
|
||||
|
||||
token = set_current_session_key(session_key)
|
||||
os.environ["HERMES_EXEC_ASK"] = "1"
|
||||
os.environ["HERMES_SESSION_KEY"] = session_key
|
||||
try:
|
||||
@@ -448,7 +441,6 @@ class TestBlockingApprovalE2E:
|
||||
finally:
|
||||
os.environ.pop("HERMES_EXEC_ASK", None)
|
||||
os.environ.pop("HERMES_SESSION_KEY", None)
|
||||
reset_current_session_key(token)
|
||||
|
||||
t = threading.Thread(target=agent_thread)
|
||||
t.start()
|
||||
@@ -477,9 +469,6 @@ class TestBlockingApprovalE2E:
|
||||
result_holder = [None]
|
||||
|
||||
def agent_thread():
|
||||
from tools.approval import reset_current_session_key, set_current_session_key
|
||||
|
||||
token = set_current_session_key(session_key)
|
||||
os.environ["HERMES_EXEC_ASK"] = "1"
|
||||
os.environ["HERMES_SESSION_KEY"] = session_key
|
||||
try:
|
||||
@@ -491,7 +480,6 @@ class TestBlockingApprovalE2E:
|
||||
finally:
|
||||
os.environ.pop("HERMES_EXEC_ASK", None)
|
||||
os.environ.pop("HERMES_SESSION_KEY", None)
|
||||
reset_current_session_key(token)
|
||||
|
||||
t = threading.Thread(target=agent_thread)
|
||||
t.start()
|
||||
@@ -517,9 +505,6 @@ class TestBlockingApprovalE2E:
|
||||
|
||||
def make_agent(idx, cmd):
|
||||
def run():
|
||||
from tools.approval import reset_current_session_key, set_current_session_key
|
||||
|
||||
token = set_current_session_key(session_key)
|
||||
os.environ["HERMES_EXEC_ASK"] = "1"
|
||||
os.environ["HERMES_SESSION_KEY"] = session_key
|
||||
try:
|
||||
@@ -527,7 +512,6 @@ class TestBlockingApprovalE2E:
|
||||
finally:
|
||||
os.environ.pop("HERMES_EXEC_ASK", None)
|
||||
os.environ.pop("HERMES_SESSION_KEY", None)
|
||||
reset_current_session_key(token)
|
||||
return run
|
||||
|
||||
threads = [
|
||||
@@ -572,9 +556,6 @@ class TestBlockingApprovalE2E:
|
||||
|
||||
def make_agent(idx, cmd):
|
||||
def run():
|
||||
from tools.approval import reset_current_session_key, set_current_session_key
|
||||
|
||||
token = set_current_session_key(session_key)
|
||||
os.environ["HERMES_EXEC_ASK"] = "1"
|
||||
os.environ["HERMES_SESSION_KEY"] = session_key
|
||||
try:
|
||||
@@ -582,7 +563,6 @@ class TestBlockingApprovalE2E:
|
||||
finally:
|
||||
os.environ.pop("HERMES_EXEC_ASK", None)
|
||||
os.environ.pop("HERMES_SESSION_KEY", None)
|
||||
reset_current_session_key(token)
|
||||
return run
|
||||
|
||||
threads = [
|
||||
@@ -600,9 +580,8 @@ class TestBlockingApprovalE2E:
|
||||
for t in threads:
|
||||
t.join(timeout=5)
|
||||
|
||||
assert all(r is not None for r in results)
|
||||
assert sorted(r["approved"] for r in results) == [False, True]
|
||||
assert sum("BLOCKED" in (r.get("message") or "") for r in results) == 1
|
||||
assert results[0]["approved"] is True
|
||||
assert results[1]["approved"] is False
|
||||
unregister_gateway_notify(session_key)
|
||||
|
||||
|
||||
|
||||
@@ -42,13 +42,11 @@ _ensure_telegram_mock()
|
||||
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
|
||||
|
||||
|
||||
def _make_adapter(dm_topics_config=None, group_topics_config=None):
|
||||
"""Create a TelegramAdapter with optional DM/group topics config."""
|
||||
def _make_adapter(dm_topics_config=None):
|
||||
"""Create a TelegramAdapter with optional DM topics config."""
|
||||
extra = {}
|
||||
if dm_topics_config is not None:
|
||||
extra["dm_topics"] = dm_topics_config
|
||||
if group_topics_config is not None:
|
||||
extra["group_topics"] = group_topics_config
|
||||
config = PlatformConfig(enabled=True, token="***", extra=extra)
|
||||
adapter = TelegramAdapter(config)
|
||||
return adapter
|
||||
@@ -487,161 +485,3 @@ def test_build_message_event_no_auto_skill_without_thread():
|
||||
event = adapter._build_message_event(msg, MessageType.TEXT)
|
||||
|
||||
assert event.auto_skill is None
|
||||
|
||||
|
||||
# ── _build_message_event: group_topics skill binding ──
|
||||
|
||||
# The telegram mock sets sys.modules["telegram.constants"] = telegram_mod (root mock),
|
||||
# so `from telegram.constants import ChatType` in telegram.py resolves to
|
||||
# telegram_mod.ChatType — not telegram_mod.constants.ChatType. We must use
|
||||
# the same ChatType object the production code sees so equality checks work.
|
||||
from telegram.constants import ChatType as _ChatType # noqa: E402
|
||||
|
||||
|
||||
def test_group_topic_skill_binding():
|
||||
"""Group topic with skill config should set auto_skill on the event."""
|
||||
from gateway.platforms.base import MessageType
|
||||
|
||||
adapter = _make_adapter(group_topics_config=[
|
||||
{
|
||||
"chat_id": -1001234567890,
|
||||
"topics": [
|
||||
{"name": "Engineering", "thread_id": 5, "skill": "software-development"},
|
||||
{"name": "Sales", "thread_id": 12, "skill": "sales-framework"},
|
||||
],
|
||||
}
|
||||
])
|
||||
|
||||
msg = _make_mock_message(
|
||||
chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=5, text="hello"
|
||||
)
|
||||
event = adapter._build_message_event(msg, MessageType.TEXT)
|
||||
|
||||
assert event.auto_skill == "software-development"
|
||||
assert event.source.chat_topic == "Engineering"
|
||||
|
||||
|
||||
def test_group_topic_skill_binding_second_topic():
|
||||
"""A different thread_id in the same group should resolve its own skill."""
|
||||
from gateway.platforms.base import MessageType
|
||||
|
||||
adapter = _make_adapter(group_topics_config=[
|
||||
{
|
||||
"chat_id": -1001234567890,
|
||||
"topics": [
|
||||
{"name": "Engineering", "thread_id": 5, "skill": "software-development"},
|
||||
{"name": "Sales", "thread_id": 12, "skill": "sales-framework"},
|
||||
],
|
||||
}
|
||||
])
|
||||
|
||||
msg = _make_mock_message(
|
||||
chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=12, text="deal update"
|
||||
)
|
||||
event = adapter._build_message_event(msg, MessageType.TEXT)
|
||||
|
||||
assert event.auto_skill == "sales-framework"
|
||||
assert event.source.chat_topic == "Sales"
|
||||
|
||||
|
||||
def test_group_topic_no_skill_binding():
|
||||
"""Group topic without a skill key should have auto_skill=None but set chat_topic."""
|
||||
from gateway.platforms.base import MessageType
|
||||
|
||||
adapter = _make_adapter(group_topics_config=[
|
||||
{
|
||||
"chat_id": -1001234567890,
|
||||
"topics": [
|
||||
{"name": "General", "thread_id": 1},
|
||||
],
|
||||
}
|
||||
])
|
||||
|
||||
msg = _make_mock_message(
|
||||
chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=1, text="hey"
|
||||
)
|
||||
event = adapter._build_message_event(msg, MessageType.TEXT)
|
||||
|
||||
assert event.auto_skill is None
|
||||
assert event.source.chat_topic == "General"
|
||||
|
||||
|
||||
def test_group_topic_unmapped_thread_id():
|
||||
"""Thread ID not in config should fall through — no skill, no topic name."""
|
||||
from gateway.platforms.base import MessageType
|
||||
|
||||
adapter = _make_adapter(group_topics_config=[
|
||||
{
|
||||
"chat_id": -1001234567890,
|
||||
"topics": [
|
||||
{"name": "Engineering", "thread_id": 5, "skill": "software-development"},
|
||||
],
|
||||
}
|
||||
])
|
||||
|
||||
msg = _make_mock_message(
|
||||
chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=999, text="random"
|
||||
)
|
||||
event = adapter._build_message_event(msg, MessageType.TEXT)
|
||||
|
||||
assert event.auto_skill is None
|
||||
assert event.source.chat_topic is None
|
||||
|
||||
|
||||
def test_group_topic_unmapped_chat_id():
|
||||
"""Chat ID not in group_topics config should fall through silently."""
|
||||
from gateway.platforms.base import MessageType
|
||||
|
||||
adapter = _make_adapter(group_topics_config=[
|
||||
{
|
||||
"chat_id": -1001234567890,
|
||||
"topics": [
|
||||
{"name": "Engineering", "thread_id": 5, "skill": "software-development"},
|
||||
],
|
||||
}
|
||||
])
|
||||
|
||||
msg = _make_mock_message(
|
||||
chat_id=-1009999999999, chat_type=_ChatType.SUPERGROUP, thread_id=5, text="wrong group"
|
||||
)
|
||||
event = adapter._build_message_event(msg, MessageType.TEXT)
|
||||
|
||||
assert event.auto_skill is None
|
||||
assert event.source.chat_topic is None
|
||||
|
||||
|
||||
def test_group_topic_no_config():
|
||||
"""No group_topics config at all should be fine — no skill, no topic."""
|
||||
from gateway.platforms.base import MessageType
|
||||
|
||||
adapter = _make_adapter() # no group_topics_config
|
||||
|
||||
msg = _make_mock_message(
|
||||
chat_id=-1001234567890, chat_type=_ChatType.GROUP, thread_id=5, text="hi"
|
||||
)
|
||||
event = adapter._build_message_event(msg, MessageType.TEXT)
|
||||
|
||||
assert event.auto_skill is None
|
||||
assert event.source.chat_topic is None
|
||||
|
||||
|
||||
def test_group_topic_chat_id_int_string_coercion():
|
||||
"""chat_id as string in config should match integer chat.id via str() coercion."""
|
||||
from gateway.platforms.base import MessageType
|
||||
|
||||
adapter = _make_adapter(group_topics_config=[
|
||||
{
|
||||
"chat_id": "-1001234567890", # string, not int
|
||||
"topics": [
|
||||
{"name": "Dev", "thread_id": "7", "skill": "hermes-agent-dev"},
|
||||
],
|
||||
}
|
||||
])
|
||||
|
||||
msg = _make_mock_message(
|
||||
chat_id=-1001234567890, chat_type=_ChatType.SUPERGROUP, thread_id=7, text="test"
|
||||
)
|
||||
event = adapter._build_message_event(msg, MessageType.TEXT)
|
||||
|
||||
assert event.auto_skill == "hermes-agent-dev"
|
||||
assert event.source.chat_topic == "Dev"
|
||||
|
||||
@@ -27,16 +27,7 @@ class FakeCredentials:
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
"scopes": [
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
"https://www.googleapis.com/auth/gmail.send",
|
||||
"https://www.googleapis.com/auth/gmail.modify",
|
||||
"https://www.googleapis.com/auth/calendar",
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
"https://www.googleapis.com/auth/contacts.readonly",
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/documents.readonly",
|
||||
],
|
||||
"scopes": ["scope-a"],
|
||||
}
|
||||
|
||||
def to_json(self):
|
||||
@@ -210,28 +201,3 @@ class TestExchangeAuthCode:
|
||||
assert "token exchange failed" in out.lower()
|
||||
assert setup_module.PENDING_AUTH_PATH.exists()
|
||||
assert not setup_module.TOKEN_PATH.exists()
|
||||
|
||||
def test_refuses_to_overwrite_existing_token_with_narrower_scopes(self, setup_module, capsys):
|
||||
setup_module.PENDING_AUTH_PATH.write_text(
|
||||
json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"})
|
||||
)
|
||||
setup_module.TOKEN_PATH.write_text(json.dumps({"token": "existing-token", "scopes": setup_module.SCOPES}))
|
||||
FakeFlow.credentials_payload = {
|
||||
"token": "narrow-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
"scopes": [
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
],
|
||||
}
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
setup_module.exchange_auth_code("4/test-auth-code")
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "refusing to save incomplete google workspace token" in out.lower()
|
||||
assert json.loads(setup_module.TOKEN_PATH.read_text())["token"] == "existing-token"
|
||||
assert setup_module.PENDING_AUTH_PATH.exists()
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
"""Regression tests for Google Workspace API credential validation."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
SCRIPT_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "skills/productivity/google-workspace/scripts/google_api.py"
|
||||
)
|
||||
|
||||
|
||||
class FakeAuthorizedCredentials:
|
||||
def __init__(self, *, valid=True, expired=False, refresh_token="refresh-token"):
|
||||
self.valid = valid
|
||||
self.expired = expired
|
||||
self.refresh_token = refresh_token
|
||||
self.refresh_calls = 0
|
||||
|
||||
def refresh(self, _request):
|
||||
self.refresh_calls += 1
|
||||
self.valid = True
|
||||
self.expired = False
|
||||
|
||||
def to_json(self):
|
||||
return json.dumps({
|
||||
"token": "refreshed-token",
|
||||
"refresh_token": self.refresh_token,
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
"scopes": [
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
"https://www.googleapis.com/auth/gmail.send",
|
||||
"https://www.googleapis.com/auth/gmail.modify",
|
||||
"https://www.googleapis.com/auth/calendar",
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
"https://www.googleapis.com/auth/contacts.readonly",
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
"https://www.googleapis.com/auth/documents.readonly",
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
class FakeCredentialsFactory:
|
||||
creds = FakeAuthorizedCredentials()
|
||||
|
||||
@classmethod
|
||||
def from_authorized_user_file(cls, _path, _scopes):
|
||||
return cls.creds
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def google_api_module(monkeypatch, tmp_path):
|
||||
google_module = types.ModuleType("google")
|
||||
oauth2_module = types.ModuleType("google.oauth2")
|
||||
credentials_module = types.ModuleType("google.oauth2.credentials")
|
||||
credentials_module.Credentials = FakeCredentialsFactory
|
||||
auth_module = types.ModuleType("google.auth")
|
||||
transport_module = types.ModuleType("google.auth.transport")
|
||||
requests_module = types.ModuleType("google.auth.transport.requests")
|
||||
requests_module.Request = object
|
||||
|
||||
monkeypatch.setitem(sys.modules, "google", google_module)
|
||||
monkeypatch.setitem(sys.modules, "google.oauth2", oauth2_module)
|
||||
monkeypatch.setitem(sys.modules, "google.oauth2.credentials", credentials_module)
|
||||
monkeypatch.setitem(sys.modules, "google.auth", auth_module)
|
||||
monkeypatch.setitem(sys.modules, "google.auth.transport", transport_module)
|
||||
monkeypatch.setitem(sys.modules, "google.auth.transport.requests", requests_module)
|
||||
|
||||
spec = importlib.util.spec_from_file_location("google_workspace_api_test", SCRIPT_PATH)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
monkeypatch.setattr(module, "TOKEN_PATH", tmp_path / "google_token.json")
|
||||
return module
|
||||
|
||||
|
||||
def _write_token(path: Path, scopes):
|
||||
path.write_text(json.dumps({
|
||||
"token": "access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"token_uri": "https://oauth2.googleapis.com/token",
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
"scopes": scopes,
|
||||
}))
|
||||
|
||||
|
||||
def test_get_credentials_rejects_missing_scopes(google_api_module, capsys):
|
||||
FakeCredentialsFactory.creds = FakeAuthorizedCredentials(valid=True)
|
||||
_write_token(google_api_module.TOKEN_PATH, [
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
"https://www.googleapis.com/auth/spreadsheets",
|
||||
])
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
google_api_module.get_credentials()
|
||||
|
||||
err = capsys.readouterr().err
|
||||
assert "missing google workspace scopes" in err.lower()
|
||||
assert "gmail.send" in err
|
||||
|
||||
|
||||
def test_get_credentials_accepts_full_scope_token(google_api_module):
|
||||
FakeCredentialsFactory.creds = FakeAuthorizedCredentials(valid=True)
|
||||
_write_token(google_api_module.TOKEN_PATH, list(google_api_module.SCOPES))
|
||||
|
||||
creds = google_api_module.get_credentials()
|
||||
|
||||
assert creds is FakeCredentialsFactory.creds
|
||||
198
tests/test_branch_command.py
Normal file
198
tests/test_branch_command.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""Tests for the /branch (/fork) command — session branching.
|
||||
|
||||
Verifies that:
|
||||
- Branching creates a new session with copied conversation history
|
||||
- The original session is preserved (ended with "branched" reason)
|
||||
- Auto-generated titles use lineage numbering
|
||||
- Custom branch names are used when provided
|
||||
- parent_session_id links are set correctly
|
||||
- Edge cases: empty conversation, missing session DB
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch, PropertyMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_db(tmp_path):
|
||||
"""Create a real SessionDB for testing."""
|
||||
os.environ["HERMES_HOME"] = str(tmp_path / ".hermes")
|
||||
os.makedirs(tmp_path / ".hermes", exist_ok=True)
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB(db_path=tmp_path / ".hermes" / "test_sessions.db")
|
||||
yield db
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cli_instance(tmp_path, session_db):
|
||||
"""Create a minimal HermesCLI-like object for testing _handle_branch_command."""
|
||||
# We'll mock the CLI enough to test the branch logic without full init
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
cli = MagicMock()
|
||||
cli._session_db = session_db
|
||||
cli.session_id = "20260403_120000_abc123"
|
||||
cli.model = "anthropic/claude-sonnet-4.6"
|
||||
cli.max_turns = 90
|
||||
cli.reasoning_config = {"enabled": True, "effort": "medium"}
|
||||
cli.session_start = datetime.now()
|
||||
cli._pending_title = None
|
||||
cli._resumed = False
|
||||
cli.agent = None
|
||||
cli.conversation_history = [
|
||||
{"role": "user", "content": "Hello, can you help me?"},
|
||||
{"role": "assistant", "content": "Of course! How can I help?"},
|
||||
{"role": "user", "content": "Write a Python function to sort a list."},
|
||||
{"role": "assistant", "content": "def sort_list(lst): return sorted(lst)"},
|
||||
]
|
||||
|
||||
# Create the original session in the DB
|
||||
session_db.create_session(
|
||||
session_id=cli.session_id,
|
||||
source="cli",
|
||||
model=cli.model,
|
||||
)
|
||||
session_db.set_session_title(cli.session_id, "My Coding Session")
|
||||
|
||||
return cli
|
||||
|
||||
|
||||
class TestBranchCommandCLI:
|
||||
"""Test the /branch command logic for the CLI."""
|
||||
|
||||
def test_branch_creates_new_session(self, cli_instance, session_db):
|
||||
"""Branching should create a new session in the DB."""
|
||||
from cli import HermesCLI
|
||||
|
||||
# Call the real method on the mock, using the real implementation
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
||||
|
||||
# Verify a new session was created
|
||||
assert cli_instance.session_id != "20260403_120000_abc123"
|
||||
new_session = session_db.get_session(cli_instance.session_id)
|
||||
assert new_session is not None
|
||||
|
||||
def test_branch_copies_history(self, cli_instance, session_db):
|
||||
"""Branching should copy all messages to the new session."""
|
||||
from cli import HermesCLI
|
||||
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
||||
|
||||
messages = session_db.get_messages_as_conversation(cli_instance.session_id)
|
||||
assert len(messages) == 4 # All 4 messages copied
|
||||
|
||||
def test_branch_preserves_parent_link(self, cli_instance, session_db):
|
||||
"""The new session should reference the original as parent."""
|
||||
from cli import HermesCLI
|
||||
original_id = cli_instance.session_id
|
||||
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
||||
|
||||
new_session = session_db.get_session(cli_instance.session_id)
|
||||
assert new_session["parent_session_id"] == original_id
|
||||
|
||||
def test_branch_ends_original_session(self, cli_instance, session_db):
|
||||
"""The original session should be marked as ended with 'branched' reason."""
|
||||
from cli import HermesCLI
|
||||
original_id = cli_instance.session_id
|
||||
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
||||
|
||||
original = session_db.get_session(original_id)
|
||||
assert original["end_reason"] == "branched"
|
||||
|
||||
def test_branch_with_custom_name(self, cli_instance, session_db):
|
||||
"""Custom branch name should be used as the title."""
|
||||
from cli import HermesCLI
|
||||
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch refactor approach")
|
||||
|
||||
title = session_db.get_session_title(cli_instance.session_id)
|
||||
assert title == "refactor approach"
|
||||
|
||||
def test_branch_auto_title_lineage(self, cli_instance, session_db):
|
||||
"""Without a name, branch should auto-generate a title from the parent's title."""
|
||||
from cli import HermesCLI
|
||||
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
||||
|
||||
title = session_db.get_session_title(cli_instance.session_id)
|
||||
assert title == "My Coding Session #2"
|
||||
|
||||
def test_branch_empty_conversation(self, cli_instance, session_db):
|
||||
"""Branching with no history should show an error."""
|
||||
from cli import HermesCLI
|
||||
cli_instance.conversation_history = []
|
||||
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
||||
|
||||
# session_id should not have changed
|
||||
assert cli_instance.session_id == "20260403_120000_abc123"
|
||||
|
||||
def test_branch_no_session_db(self, cli_instance):
|
||||
"""Branching without a session DB should show an error."""
|
||||
from cli import HermesCLI
|
||||
cli_instance._session_db = None
|
||||
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
||||
|
||||
# session_id should not have changed
|
||||
assert cli_instance.session_id == "20260403_120000_abc123"
|
||||
|
||||
def test_branch_syncs_agent(self, cli_instance, session_db):
|
||||
"""If an agent is active, branch should sync it to the new session."""
|
||||
from cli import HermesCLI
|
||||
|
||||
agent = MagicMock()
|
||||
agent._last_flushed_db_idx = 0
|
||||
cli_instance.agent = agent
|
||||
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
||||
|
||||
# Agent should have been updated
|
||||
assert agent.session_id == cli_instance.session_id
|
||||
assert agent.reset_session_state.called
|
||||
assert agent._last_flushed_db_idx == 4 # len(conversation_history)
|
||||
|
||||
def test_branch_sets_resumed_flag(self, cli_instance, session_db):
|
||||
"""Branch should set _resumed=True to prevent auto-title generation."""
|
||||
from cli import HermesCLI
|
||||
|
||||
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
||||
|
||||
assert cli_instance._resumed is True
|
||||
|
||||
def test_fork_alias(self):
|
||||
"""The /fork alias should resolve to 'branch'."""
|
||||
from hermes_cli.commands import resolve_command
|
||||
result = resolve_command("fork")
|
||||
assert result is not None
|
||||
assert result.name == "branch"
|
||||
|
||||
|
||||
class TestBranchCommandDef:
|
||||
"""Test the CommandDef registration for /branch."""
|
||||
|
||||
def test_branch_in_registry(self):
|
||||
"""The branch command should be in the command registry."""
|
||||
from hermes_cli.commands import COMMAND_REGISTRY
|
||||
names = [c.name for c in COMMAND_REGISTRY]
|
||||
assert "branch" in names
|
||||
|
||||
def test_branch_has_fork_alias(self):
|
||||
"""The branch command should have 'fork' as an alias."""
|
||||
from hermes_cli.commands import COMMAND_REGISTRY
|
||||
branch = next(c for c in COMMAND_REGISTRY if c.name == "branch")
|
||||
assert "fork" in branch.aliases
|
||||
|
||||
def test_branch_in_session_category(self):
|
||||
"""The branch command should be in the Session category."""
|
||||
from hermes_cli.commands import COMMAND_REGISTRY
|
||||
branch = next(c for c in COMMAND_REGISTRY if c.name == "branch")
|
||||
assert branch.category == "Session"
|
||||
@@ -859,9 +859,7 @@ def test_opencode_zen_claude_defaults_to_messages(monkeypatch):
|
||||
|
||||
assert resolved["provider"] == "opencode-zen"
|
||||
assert resolved["api_mode"] == "anthropic_messages"
|
||||
# Trailing /v1 stripped for anthropic_messages mode — the Anthropic SDK
|
||||
# appends its own /v1/messages to the base_url.
|
||||
assert resolved["base_url"] == "https://opencode.ai/zen"
|
||||
assert resolved["base_url"] == "https://opencode.ai/zen/v1"
|
||||
|
||||
|
||||
def test_opencode_go_minimax_defaults_to_messages(monkeypatch):
|
||||
@@ -874,8 +872,7 @@ def test_opencode_go_minimax_defaults_to_messages(monkeypatch):
|
||||
|
||||
assert resolved["provider"] == "opencode-go"
|
||||
assert resolved["api_mode"] == "anthropic_messages"
|
||||
# Trailing /v1 stripped — Anthropic SDK appends /v1/messages itself.
|
||||
assert resolved["base_url"] == "https://opencode.ai/zen/go"
|
||||
assert resolved["base_url"] == "https://opencode.ai/zen/go/v1"
|
||||
|
||||
|
||||
def test_opencode_go_glm_defaults_to_chat_completions(monkeypatch):
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Tests for the dangerous command approval module."""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch as mock_patch
|
||||
|
||||
import tools.approval as approval_module
|
||||
@@ -150,79 +148,6 @@ class TestApproveAndCheckSession:
|
||||
assert has_pending(key) is False
|
||||
|
||||
|
||||
class TestSessionKeyContext:
|
||||
def test_context_session_key_overrides_process_env(self):
|
||||
token = approval_module.set_current_session_key("alice")
|
||||
try:
|
||||
with mock_patch.dict("os.environ", {"HERMES_SESSION_KEY": "bob"}, clear=False):
|
||||
assert approval_module.get_current_session_key() == "alice"
|
||||
finally:
|
||||
approval_module.reset_current_session_key(token)
|
||||
|
||||
def test_gateway_runner_binds_session_key_to_context_before_agent_run(self):
|
||||
run_py = Path(__file__).resolve().parents[2] / "gateway" / "run.py"
|
||||
module = ast.parse(run_py.read_text(encoding="utf-8"))
|
||||
|
||||
run_sync = None
|
||||
for node in ast.walk(module):
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "run_sync":
|
||||
run_sync = node
|
||||
break
|
||||
|
||||
assert run_sync is not None, "gateway.run.run_sync not found"
|
||||
|
||||
called_names = set()
|
||||
for node in ast.walk(run_sync):
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
||||
called_names.add(node.func.id)
|
||||
|
||||
assert "set_current_session_key" in called_names
|
||||
assert "reset_current_session_key" in called_names
|
||||
|
||||
def test_context_keeps_pending_approval_attached_to_originating_session(self):
|
||||
import os
|
||||
import threading
|
||||
|
||||
clear_session("alice")
|
||||
clear_session("bob")
|
||||
pop_pending("alice")
|
||||
pop_pending("bob")
|
||||
approval_module._permanent_approved.clear()
|
||||
|
||||
alice_ready = threading.Event()
|
||||
bob_ready = threading.Event()
|
||||
|
||||
def worker_alice():
|
||||
token = approval_module.set_current_session_key("alice")
|
||||
try:
|
||||
os.environ["HERMES_EXEC_ASK"] = "1"
|
||||
os.environ["HERMES_SESSION_KEY"] = "alice"
|
||||
alice_ready.set()
|
||||
bob_ready.wait()
|
||||
approval_module.check_all_command_guards("rm -rf /tmp/alice-secret", "local")
|
||||
finally:
|
||||
approval_module.reset_current_session_key(token)
|
||||
|
||||
def worker_bob():
|
||||
alice_ready.wait()
|
||||
token = approval_module.set_current_session_key("bob")
|
||||
try:
|
||||
os.environ["HERMES_SESSION_KEY"] = "bob"
|
||||
bob_ready.set()
|
||||
finally:
|
||||
approval_module.reset_current_session_key(token)
|
||||
|
||||
t1 = threading.Thread(target=worker_alice)
|
||||
t2 = threading.Thread(target=worker_bob)
|
||||
t1.start()
|
||||
t2.start()
|
||||
t1.join()
|
||||
t2.join()
|
||||
|
||||
assert pop_pending("alice") is not None
|
||||
assert pop_pending("bob") is None
|
||||
|
||||
|
||||
class TestRmFalsePositiveFix:
|
||||
"""Regression tests: filenames starting with 'r' must NOT trigger recursive delete."""
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ This module is the single source of truth for the dangerous command system:
|
||||
- Permanent allowlist persistence (config.yaml)
|
||||
"""
|
||||
|
||||
import contextvars
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -19,33 +18,6 @@ from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Per-thread/per-task gateway session identity.
|
||||
# Gateway runs agent turns concurrently in executor threads, so reading a
|
||||
# process-global env var for session identity is racy. Keep env fallback for
|
||||
# legacy single-threaded callers, but prefer the context-local value when set.
|
||||
_approval_session_key: contextvars.ContextVar[str] = contextvars.ContextVar(
|
||||
"approval_session_key",
|
||||
default="",
|
||||
)
|
||||
|
||||
|
||||
def set_current_session_key(session_key: str) -> contextvars.Token[str]:
|
||||
"""Bind the active approval session key to the current context."""
|
||||
return _approval_session_key.set(session_key or "")
|
||||
|
||||
|
||||
def reset_current_session_key(token: contextvars.Token[str]) -> None:
|
||||
"""Restore the prior approval session key context."""
|
||||
_approval_session_key.reset(token)
|
||||
|
||||
|
||||
def get_current_session_key(default: str = "default") -> str:
|
||||
"""Return the active session key, preferring context-local state."""
|
||||
session_key = _approval_session_key.get()
|
||||
if session_key:
|
||||
return session_key
|
||||
return os.getenv("HERMES_SESSION_KEY", default)
|
||||
|
||||
# Sensitive write targets that should trigger approval even when referenced
|
||||
# via shell expansions like $HOME or $HERMES_HOME.
|
||||
_SSH_SENSITIVE_PATH = r'(?:~|\$home|\$\{home\})/\.ssh(?:/|$)'
|
||||
@@ -562,7 +534,7 @@ def check_dangerous_command(command: str, env_type: str,
|
||||
if not is_dangerous:
|
||||
return {"approved": True, "message": None}
|
||||
|
||||
session_key = get_current_session_key()
|
||||
session_key = os.getenv("HERMES_SESSION_KEY", "default")
|
||||
if is_approved(session_key, pattern_key):
|
||||
return {"approved": True, "message": None}
|
||||
|
||||
@@ -688,7 +660,7 @@ def check_all_command_guards(command: str, env_type: str,
|
||||
# Collect warnings that need approval
|
||||
warnings = [] # list of (pattern_key, description, is_tirith)
|
||||
|
||||
session_key = get_current_session_key()
|
||||
session_key = os.getenv("HERMES_SESSION_KEY", "default")
|
||||
|
||||
# Tirith block/warn → approvable warning with rich findings.
|
||||
# Previously, tirith "block" was a hard block with no approval prompt.
|
||||
|
||||
@@ -1088,10 +1088,9 @@ def terminal_tool(
|
||||
# Spawn a tracked background process via the process registry.
|
||||
# For local backends: uses subprocess.Popen with output buffering.
|
||||
# For non-local backends: runs inside the sandbox via env.execute().
|
||||
from tools.approval import get_current_session_key
|
||||
from tools.process_registry import process_registry
|
||||
|
||||
session_key = get_current_session_key(default="")
|
||||
session_key = os.getenv("HERMES_SESSION_KEY", "")
|
||||
effective_cwd = workdir or cwd
|
||||
try:
|
||||
if env_type == "local":
|
||||
|
||||
@@ -312,71 +312,6 @@ 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.
|
||||
:::
|
||||
|
||||
## 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.
|
||||
|
||||
### Use case
|
||||
|
||||
A team supergroup with forum topics for different workstreams:
|
||||
|
||||
- **Engineering** topic → auto-loads the `software-development` skill
|
||||
- **Research** topic → auto-loads the `arxiv` skill
|
||||
- **General** topic → no skill, general-purpose assistant
|
||||
|
||||
### Configuration
|
||||
|
||||
Add topic bindings under `platforms.telegram.extra.group_topics` in `~/.hermes/config.yaml`:
|
||||
|
||||
```yaml
|
||||
platforms:
|
||||
telegram:
|
||||
extra:
|
||||
group_topics:
|
||||
- chat_id: -1001234567890 # Supergroup ID
|
||||
topics:
|
||||
- name: Engineering
|
||||
thread_id: 5
|
||||
skill: software-development
|
||||
- name: Research
|
||||
thread_id: 12
|
||||
skill: arxiv
|
||||
- name: General
|
||||
thread_id: 1
|
||||
# No skill — general purpose
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `chat_id` | Yes | The supergroup's numeric ID (negative number starting with `-100`) |
|
||||
| `name` | No | Human-readable label for the topic (informational only) |
|
||||
| `thread_id` | Yes | Telegram forum topic ID — visible in `t.me/c/<group_id>/<thread_id>` links |
|
||||
| `skill` | No | Skill to auto-load on new sessions in this topic |
|
||||
|
||||
### How it works
|
||||
|
||||
1. When a message arrives in a mapped group topic, Hermes looks up the `chat_id` and `thread_id` in `group_topics` config
|
||||
2. If a matching entry has a `skill` field, that skill is auto-loaded for the session — identical to DM topic skill binding
|
||||
3. Topics without a `skill` key get session isolation only (existing behavior, unchanged)
|
||||
4. Unmapped `thread_id` values or `chat_id` values fall through silently — no error, no skill
|
||||
|
||||
### Differences from DM Topics
|
||||
|
||||
| | DM Topics | Group Topics |
|
||||
|---|---|---|
|
||||
| Config key | `extra.dm_topics` | `extra.group_topics` |
|
||||
| Topic creation | Hermes creates topics via API if `thread_id` is missing | Admin creates topics in Telegram UI |
|
||||
| `thread_id` | Auto-populated after creation | Must be set manually |
|
||||
| `icon_color` / `icon_custom_emoji_id` | Supported | Not applicable (admin controls appearance) |
|
||||
| Skill binding | ✓ | ✓ |
|
||||
| Session isolation | ✓ | ✓ (already built-in for forum topics) |
|
||||
|
||||
:::tip
|
||||
To find a topic's `thread_id`, open the topic in Telegram Web or Desktop and look at the URL: `https://t.me/c/1234567890/5` — the last number (`5`) is the `thread_id`. The `chat_id` for supergroups is the group ID prefixed with `-100` (e.g., group `1234567890` becomes `-1001234567890`).
|
||||
:::
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -363,7 +363,7 @@ terminal:
|
||||
|
||||
### Credential File Passthrough (OAuth tokens, etc.) {#credential-file-passthrough}
|
||||
|
||||
Some skills need **files** (not just env vars) in the sandbox — for example, Google Workspace stores OAuth tokens as `google_token.json` under the active profile's `HERMES_HOME`. Skills declare these in frontmatter:
|
||||
Some skills need **files** (not just env vars) in the sandbox — for example, Google Workspace stores OAuth tokens as `google_token.json` in `~/.hermes/`. Skills declare these in frontmatter:
|
||||
|
||||
```yaml
|
||||
required_credential_files:
|
||||
@@ -373,7 +373,7 @@ required_credential_files:
|
||||
description: Google OAuth2 client credentials
|
||||
```
|
||||
|
||||
When loaded, Hermes checks if these files exist in the active profile's `HERMES_HOME` and registers them for mounting:
|
||||
When loaded, Hermes checks if these files exist in `~/.hermes/` and registers them for mounting:
|
||||
|
||||
- **Docker**: Read-only bind mounts (`-v host:container:ro`)
|
||||
- **Modal**: Mounted at sandbox creation + synced before each command (handles mid-session OAuth setup)
|
||||
|
||||
Reference in New Issue
Block a user