Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions agent/auxiliary_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,9 @@ def _normalize_aux_provider(provider: Optional[str]) -> str:


def _is_kimi_model(model: Optional[str]) -> bool:
"""True for any Kimi / Moonshot model that manages temperature server-side."""
"""True for any Kimi / Moonshot / Qwen model that manages temperature server-side."""
bare = (model or "").strip().lower().rsplit("/", 1)[-1]
return bare.startswith("kimi-") or bare == "kimi"
return bare.startswith("kimi-") or bare == "kimi" or bare.startswith("qwen")


def _fixed_temperature_for_model(
Expand Down Expand Up @@ -1005,19 +1005,19 @@ def _read_nous_auth() -> Optional[dict]:
"""
pool_present, entry = _select_pool_entry("nous")
if pool_present:
if entry is None:
return None
return {
"access_token": getattr(entry, "access_token", ""),
"refresh_token": getattr(entry, "refresh_token", None),
"agent_key": getattr(entry, "agent_key", None),
"inference_base_url": _pool_runtime_base_url(entry, _NOUS_DEFAULT_BASE_URL),
"portal_base_url": getattr(entry, "portal_base_url", None),
"client_id": getattr(entry, "client_id", None),
"scope": getattr(entry, "scope", None),
"token_type": getattr(entry, "token_type", "Bearer"),
"source": "pool",
}
if entry is not None:
return {
"access_token": getattr(entry, "access_token", ""),
"refresh_token": getattr(entry, "refresh_token", None),
"agent_key": getattr(entry, "agent_key", None),
"inference_base_url": _pool_runtime_base_url(entry, _NOUS_DEFAULT_BASE_URL),
"portal_base_url": getattr(entry, "portal_base_url", None),
"client_id": getattr(entry, "client_id", None),
"scope": getattr(entry, "scope", None),
"token_type": getattr(entry, "token_type", "Bearer"),
"source": "pool",
}
# entry is None → pool exists but all exhausted, fall through to auth.json

try:
if not _AUTH_JSON_PATH.is_file():
Expand Down
16 changes: 12 additions & 4 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4817,7 +4817,7 @@ def _list_recent_sessions(self, limit: int = 10) -> list[dict[str, Any]]:
try:
sessions = self._session_db.list_sessions_rich(
source="cli",
exclude_sources=["tool"],
exclude_sources=["tool", "cron"],
limit=limit,
)
except Exception:
Expand Down Expand Up @@ -5415,6 +5415,7 @@ def _open_model_picker(self, providers: list, current_model: str, current_provid
"stage": "provider",
"providers": providers,
"selected": default_idx,
"scroll_offset": 0,
"current_model": current_model,
"current_provider": current_provider,
"user_provs": user_provs,
Expand Down Expand Up @@ -5565,6 +5566,7 @@ def _handle_model_picker_selection(self, persist_global: bool = False) -> None:
state["provider_data"] = provider_data
state["model_list"] = model_list
state["selected"] = 0
state["scroll_offset"] = 0
self._invalidate(min_interval=0.0)
return
if stage == "model":
Expand All @@ -5575,6 +5577,7 @@ def _handle_model_picker_selection(self, persist_global: bool = False) -> None:
if selected == back_idx:
state["stage"] = "provider"
state["selected"] = next((i for i, p in enumerate(state.get("providers") or []) if p.get("slug") == provider_data.get("slug")), 0)
state["scroll_offset"] = 0
self._invalidate(min_interval=0.0)
return
if selected >= cancel_idx:
Expand Down Expand Up @@ -10271,11 +10274,16 @@ def approval_down(event):
self._approval_state["selected"] = min(max_idx, self._approval_state["selected"] + 1)
event.app.invalidate()

# --- /model picker: arrow-key navigation ---
# --- /model picker: arrow-key navigation with scroll ---
@kb.add('up', filter=Condition(lambda: bool(self._model_picker_state)))
def model_picker_up(event):
if self._model_picker_state:
self._model_picker_state["selected"] = max(0, self._model_picker_state.get("selected", 0) - 1)
state = self._model_picker_state
if state:
new_selected = max(0, state.get("selected", 0) - 1)
state["selected"] = new_selected
# Keep cursor within scroll window
if new_selected < state.get("scroll_offset", 0):
state["scroll_offset"] = new_selected
event.app.invalidate()

@kb.add('down', filter=Condition(lambda: bool(self._model_picker_state)))
Expand Down
68 changes: 43 additions & 25 deletions cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import os
import subprocess
import sys
import time

# fcntl is Unix-only; on Windows use msvcrt for file locking
try:
Expand All @@ -40,6 +41,10 @@

logger = logging.getLogger(__name__)

# Delivery retry configuration for transient network/platform failures.
_MAX_DELIVERY_RETRIES = 3
_DELIVERY_RETRY_BACKOFF = [10, 30, 60] # seconds between attempts


def _resolve_cron_enabled_toolsets(job: dict, cfg: dict) -> list[str] | None:
"""Resolve the toolset list for a cron job.
Expand Down Expand Up @@ -475,33 +480,46 @@ def _deliver_result(job: dict, content: str, adapters=None, loop=None) -> Option
)

if not delivered:
# Standalone path: run the async send in a fresh event loop (safe from any thread)
coro = _send_to_platform(platform, pconfig, chat_id, cleaned_delivery_content, thread_id=thread_id, media_files=media_files)
try:
result = asyncio.run(coro)
except RuntimeError:
# asyncio.run() checks for a running loop before awaiting the coroutine;
# when it raises, the original coro was never started — close it to
# prevent "coroutine was never awaited" RuntimeWarning, then retry in a
# fresh thread that has no running loop.
coro.close()
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(asyncio.run, _send_to_platform(platform, pconfig, chat_id, cleaned_delivery_content, thread_id=thread_id, media_files=media_files))
result = future.result(timeout=30)
except Exception as e:
msg = f"delivery to {platform_name}:{chat_id} failed: {e}"
logger.error("Job '%s': %s", job["id"], msg)
delivery_errors.append(msg)
continue

if result and result.get("error"):
msg = f"delivery error: {result['error']}"
logger.error("Job '%s': %s", job["id"], msg)
delivery_errors.append(msg)
# Standalone path with retry: attempt delivery up to _MAX_DELIVERY_RETRIES
# times on transient failures (network errors, platform disconnects).
last_error = None
for attempt in range(_MAX_DELIVERY_RETRIES):
if attempt > 0:
backoff = _DELIVERY_RETRY_BACKOFF[min(attempt - 1, len(_DELIVERY_RETRY_BACKOFF) - 1)]
logger.info("Job '%s': delivery retry %d/%d to %s:%s after %ds",
job["id"], attempt + 1, _MAX_DELIVERY_RETRIES,
platform_name, chat_id, backoff)
time.sleep(backoff)

coro = _send_to_platform(platform, pconfig, chat_id, cleaned_delivery_content, thread_id=thread_id, media_files=media_files)
try:
result = asyncio.run(coro)
except RuntimeError:
coro.close()
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future = pool.submit(asyncio.run, _send_to_platform(platform, pconfig, chat_id, cleaned_delivery_content, thread_id=thread_id, media_files=media_files))
result = future.result(timeout=30)
except Exception as e:
last_error = f"delivery to {platform_name}:{chat_id} failed (attempt {attempt + 1}): {e}"
logger.warning("Job '%s': %s", job["id"], last_error)
continue # retry

if result and result.get("error"):
last_error = f"delivery error (attempt {attempt + 1}): {result['error']}"
logger.warning("Job '%s': %s", job["id"], last_error)
continue # retry

# Success
logger.info("Job '%s': delivered to %s:%s (attempt %d)", job["id"], platform_name, chat_id, attempt + 1)
last_error = None
break

if last_error:
logger.error("Job '%s': giving up delivery to %s:%s after %d attempts: %s",
job["id"], platform_name, chat_id, _MAX_DELIVERY_RETRIES, last_error)
delivery_errors.append(last_error)
continue

logger.info("Job '%s': delivered to %s:%s", job["id"], platform_name, chat_id)

if delivery_errors:
return "; ".join(delivery_errors)
return None
Expand Down
11 changes: 11 additions & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2752,6 +2752,17 @@ async def _stop_typing_task() -> None:
if not response:
logger.debug("[%s] Handler returned empty/None response for %s", self.name, event.source.chat_id)
if response:
# Agent is done — stop the typing indicator *before* sending.
# If we wait until the finally block, _keep_typing keeps
# refreshing sendChatAction("typing") during the entire
# media-extraction / TTS / message-sending phase below.
# Telegram's client then toggles: incoming message clears
# typing, but _keep_typing's next tick re-triggers it 2s
# later, making it look like the bot is perpetually "typing".
# Stop here so the outgoing message naturally clears the
# indicator per Telegram's documented behavior.
await _stop_typing_task()

# Extract MEDIA:<path> tags (from TTS tool) before other processing
media_files, response = self.extract_media(response)

Expand Down
31 changes: 31 additions & 0 deletions hermes_cli/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,21 @@ def clarify_callback(cli, question, choices):
responds. Returns the user's choice or a timeout message.
"""
from cli import CLI_CONFIG
import subprocess

# Fire macOS notification + terminal bell so user notices even if terminal is in background
notification_body = question[:120] + ("..." if len(question) > 120 else "")
try:
subprocess.run(
["osascript", "-e", f'display notification "{notification_body}" with title "Hermes — 需要确认" sound name "Glass"'],
timeout=3, capture_output=True,
)
except Exception:
pass
try:
subprocess.run(["echo", "-e", "\\a"], timeout=2, capture_output=True)
except Exception:
pass

timeout = CLI_CONFIG.get("clarify", {}).get("timeout", 120)
response_queue = queue.Queue()
Expand Down Expand Up @@ -201,6 +216,22 @@ def approval_callback(cli, command: str, description: str) -> str:

with lock:
from cli import CLI_CONFIG
import subprocess

# Fire macOS notification + terminal bell for dangerous command approvals
notification_body = (description or command)[:120] + ("..." if len(description or command) > 120 else "")
try:
subprocess.run(
["osascript", "-e", f'display notification "{notification_body}" with title "Hermes — 命令待批准" sound name "Alert"'],
timeout=3, capture_output=True,
)
except Exception:
pass
try:
subprocess.run(["echo", "-e", "\\a"], timeout=2, capture_output=True)
except Exception:
pass

timeout = CLI_CONFIG.get("approvals", {}).get("timeout", 60)
response_queue = queue.Queue()
choices = ["once", "session", "always", "deny"]
Expand Down
2 changes: 2 additions & 0 deletions hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ class CommandDef:
gateway_only=True),
CommandDef("background", "Run a prompt in the background", "Session",
aliases=("bg", "btw"), args_hint="<prompt>"),
CommandDef("codex", "Interactive Codex CLI session (persistent context)", "Session",
aliases=("cx",), args_hint="[start|kill|log|<message>]"),
CommandDef("agents", "Show active agents and running tasks", "Session",
aliases=("tasks",)),
CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session",
Expand Down
84 changes: 66 additions & 18 deletions hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

DEFAULT_DB_PATH = get_hermes_home() / "state.db"

SCHEMA_VERSION = 11
SCHEMA_VERSION = 12

SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS schema_version (
Expand Down Expand Up @@ -102,25 +102,31 @@

FTS_SQL = """
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
content
content,
tool_name,
tool_calls,
content='messages',
content_rowid='id'
);

CREATE TRIGGER IF NOT EXISTS messages_fts_insert AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (
new.id,
COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
INSERT INTO messages_fts(rowid, content, tool_name, tool_calls) VALUES (
new.id, new.content, new.tool_name, new.tool_calls
);
END;

CREATE TRIGGER IF NOT EXISTS messages_fts_delete AFTER DELETE ON messages BEGIN
DELETE FROM messages_fts WHERE rowid = old.id;
INSERT INTO messages_fts(messages_fts, rowid, content, tool_name, tool_calls) VALUES (
'delete', old.id, old.content, old.tool_name, old.tool_calls
);
END;

CREATE TRIGGER IF NOT EXISTS messages_fts_update AFTER UPDATE ON messages BEGIN
DELETE FROM messages_fts WHERE rowid = old.id;
INSERT INTO messages_fts(rowid, content) VALUES (
new.id,
COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
INSERT INTO messages_fts(messages_fts, rowid, content, tool_name, tool_calls) VALUES (
'delete', old.id, old.content, old.tool_name, old.tool_calls
);
INSERT INTO messages_fts(rowid, content, tool_name, tool_calls) VALUES (
new.id, new.content, new.tool_name, new.tool_calls
);
END;
"""
Expand All @@ -132,25 +138,31 @@
FTS_TRIGRAM_SQL = """
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts_trigram USING fts5(
content,
tool_name,
tool_calls,
content='messages',
content_rowid='id',
tokenize='trigram'
);

CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_insert AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts_trigram(rowid, content) VALUES (
new.id,
COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
INSERT INTO messages_fts_trigram(rowid, content, tool_name, tool_calls) VALUES (
new.id, new.content, new.tool_name, new.tool_calls
);
END;

CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_delete AFTER DELETE ON messages BEGIN
DELETE FROM messages_fts_trigram WHERE rowid = old.id;
INSERT INTO messages_fts_trigram(messages_fts_trigram, rowid, content, tool_name, tool_calls) VALUES (
'delete', old.id, old.content, old.tool_name, old.tool_calls
);
END;

CREATE TRIGGER IF NOT EXISTS messages_fts_trigram_update AFTER UPDATE ON messages BEGIN
DELETE FROM messages_fts_trigram WHERE rowid = old.id;
INSERT INTO messages_fts_trigram(rowid, content) VALUES (
new.id,
COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
INSERT INTO messages_fts_trigram(messages_fts_trigram, rowid, content, tool_name, tool_calls) VALUES (
'delete', old.id, old.content, old.tool_name, old.tool_calls
);
INSERT INTO messages_fts_trigram(rowid, content, tool_name, tool_calls) VALUES (
new.id, new.content, new.tool_name, new.tool_calls
);
END;
"""
Expand Down Expand Up @@ -481,6 +493,42 @@ def _init_schema(self):
"COALESCE(tool_calls, '') "
"FROM messages"
)
if current_version < 12:
# v12: switch from inline FTS5 to external-content FTS5.
# The v11 inline mode duplicated all content in FTS tables,
# causing ~1.2GB state.db where messages are only ~270MB.
# External-content mode keeps only index data in FTS tables;
# content is read from the messages table on demand.
# All 3 indexed columns (content, tool_name, tool_calls) are
# preserved. Estimated ~33% DB size reduction.
for _trig in (
"messages_fts_insert",
"messages_fts_delete",
"messages_fts_update",
"messages_fts_trigram_insert",
"messages_fts_trigram_delete",
"messages_fts_trigram_update",
):
try:
cursor.execute(f"DROP TRIGGER IF EXISTS {_trig}")
except sqlite3.OperationalError:
pass
for _tbl in ("messages_fts", "messages_fts_trigram"):
try:
cursor.execute(f"DROP TABLE IF EXISTS {_tbl}")
except sqlite3.OperationalError:
pass
cursor.executescript(FTS_SQL)
cursor.executescript(FTS_TRIGRAM_SQL)
# Rebuild both FTS indexes from the messages table.
# External-content mode (content='messages', content_rowid='id')
# reads all column values from messages automatically.
cursor.execute(
"INSERT INTO messages_fts(messages_fts) VALUES('rebuild')"
)
cursor.execute(
"INSERT INTO messages_fts_trigram(messages_fts_trigram) VALUES('rebuild')"
)
if current_version < SCHEMA_VERSION:
cursor.execute(
"UPDATE schema_version SET version = ?",
Expand Down
Loading