Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4c21c76
feat: prod infrastructure - channel-aware updates, healthcheck restar…
Git-on-my-level Apr 9, 2026
2cedec0
fix(gateway): auth error detection, usage accuracy, full transcript c…
Git-on-my-level Apr 9, 2026
ba20a0b
fix(gateway): preserve queued commands on interrupt, honor session mo…
Git-on-my-level Apr 9, 2026
0fadeb0
chore: glm-5.1 setup, pre-push hooks, doc fixes
Git-on-my-level Apr 9, 2026
802e286
fix(cli): Z.AI coding URL, slash provider syntax, Codex model probing
Git-on-my-level Apr 9, 2026
8c31fa5
feat(telegram): inbound topic allowlists
Git-on-my-level Apr 9, 2026
37c388e
fix: repair *** corruption in auth.py, models.py, doctor.py from suba…
Git-on-my-level Apr 9, 2026
a13bb00
chore: switch update-prod-branch.sh to rebase-based sync
Git-on-my-level Apr 9, 2026
58fdc0d
fix(banner): add missing shutil import
Git-on-my-level Apr 9, 2026
d8b8bb1
Merge remote-tracking branch 'origin/main' into HEAD
Git-on-my-level Apr 9, 2026
1c2f5a3
Merge remote-tracking branch 'origin/main' into HEAD
Apr 9, 2026
5fd4dad
fix(gateway): resolve NameError in fallback model tracking
Git-on-my-level Apr 9, 2026
8fee7dd
Merge remote-tracking branch 'origin/main' into HEAD
Apr 9, 2026
f1840b0
Merge origin/main into prod (399 upstream commits)
Apr 12, 2026
967a534
Merge remote-tracking branch 'origin/main' into HEAD
Git-on-my-level Apr 12, 2026
21b3637
feat: sync prod to tagged upstream releases only
Apr 12, 2026
efc2c8a
merge: sync prod to v2026.4.16 (v0.10.0) — 439 upstream commits
Apr 19, 2026
54ecc1a
fix: initialize _allowed_inbound_targets in test fixture
Apr 19, 2026
eddc8df
feat(acp): bridge interim_assistant_callback for live commentary over…
Git-on-my-level Apr 19, 2026
75b3fcf
Harden launchd service path generation
Apr 21, 2026
5f4dd3c
fix(acp): wire stream_delta_callback for real-time ACP text delta str…
Apr 22, 2026
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
43 changes: 43 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Pre-push hook: basic guardrails before pushing to remote.
#
# Checks:
# 1. Refuse to push to main/master with a dirty working tree.
# 2. Warn if the latest commit is a "WIP" or "fixup!" commit.
# 3. Run a fast lint check (flake8 / ruff) if available.
#
# Install: scripts/install-githooks.sh

set -euo pipefail

REMOTE="$1"
BRANCH="$(git rev-parse --abbrev-ref HEAD)"

# --- 1. Dirty-tree guard for protected branches ---
if [[ "$BRANCH" == "main" || "$BRANCH" == "master" ]]; then
if ! git diff --quiet HEAD -- ':!*.lock' 2>/dev/null; then
echo "::error::Refusing to push to '$BRANCH' with uncommitted changes." >&2
exit 1
fi
fi

# --- 2. WIP / fixup warning ---
LAST_MSG="$(git log -1 --pretty=%s HEAD 2>/dev/null || true)"
if echo "$LAST_MSG" | grep -qiE '^(WIP|fixup!|squash!)'; then
echo "::warning::Pushing a WIP/fixup commit: $LAST_MSG" >&2
fi

# --- 3. Fast lint (optional) ---
if command -v ruff &>/dev/null; then
echo "[pre-push] Running ruff check ..."
ruff check hermes_cli/ agent/ gateway/ --quiet || {
echo "::warning::ruff found issues (non-blocking)." >&2
}
elif command -v flake8 &>/dev/null; then
echo "[pre-push] Running flake8 ..."
flake8 hermes_cli/ agent/ gateway/ --max-line-length=120 --statistics || {
echo "::warning::flake8 found issues (non-blocking)." >&2
}
fi

echo "[pre-push] All checks passed."
83 changes: 83 additions & 0 deletions acp_adapter/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,33 @@ def _step(api_call_count: int, prev_tools: Any = None) -> None:
return _step




def _merge_update_meta(update: Any, **values: Any) -> Any:
"""Attach ACP `_meta` metadata to a generated update object.

The Python ACP SDK exposes the schema `_meta` field as `field_meta` and
serializes it back to `_meta`. Keeping commentary markers there avoids
adding non-schema top-level fields to `agent_message_chunk`.
"""
existing = getattr(update, "field_meta", None)
metadata = dict(existing) if isinstance(existing, dict) else {}
metadata.update(values)
try:
update.field_meta = metadata
return update
except Exception:
logger.debug("Failed to attach ACP update metadata", exc_info=True)
try:
payload = update.model_dump(by_alias=True, exclude_none=True)
except Exception:
payload = {
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": ""},
}
payload["_meta"] = metadata
return payload

# ------------------------------------------------------------------
# Agent message callback
# ------------------------------------------------------------------
Expand All @@ -173,3 +200,59 @@ def _message(text: str) -> None:
_send_update(conn, session_id, loop, update)

return _message


def make_stream_delta_cb(
conn: acp.Client,
session_id: str,
loop: asyncio.AbstractEventLoop,
) -> Callable:
"""Create a callback that forwards live LLM text deltas to ACP."""

def _stream_delta(text: str) -> None:
if not text:
return
update = acp.update_agent_message_text(text)
_send_update(conn, session_id, loop, update)

return _stream_delta


def make_interim_assistant_cb(
conn: acp.Client,
session_id: str,
loop: asyncio.AbstractEventLoop,
) -> Callable:
"""Create a callback for completed interim assistant commentary.

AIAgent calls this callback for real assistant-facing progress messages that
Hermes gateway surfaces in Telegram/Discord. Over ACP these are still
`agent_message_chunk` updates, but their `_meta` marks them as live-only
commentary so CAR can render them without treating them as final output.
"""

def _interim_assistant(
text: str,
*,
already_streamed: bool = False,
**_: Any,
) -> None:
if not text:
return
update = acp.update_agent_message_text(text)
update = _merge_update_meta(
update,
phase="commentary",
alreadyStreamed=bool(already_streamed),
car={
"phase": "commentary",
"alreadyStreamed": bool(already_streamed),
},
hermes={
"phase": "commentary",
"alreadyStreamed": bool(already_streamed),
},
)
_send_update(conn, session_id, loop, update)

return _interim_assistant
8 changes: 8 additions & 0 deletions acp_adapter/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@

from acp_adapter.auth import detect_provider, has_provider
from acp_adapter.events import (
make_interim_assistant_cb,
make_message_cb,
make_step_cb,
make_stream_delta_cb,
make_thinking_cb,
make_tool_progress_cb,
)
Expand Down Expand Up @@ -396,19 +398,25 @@ async def prompt(
thinking_cb = make_thinking_cb(conn, session_id, loop)
step_cb = make_step_cb(conn, session_id, loop, tool_call_ids)
message_cb = make_message_cb(conn, session_id, loop)
stream_delta_cb = make_stream_delta_cb(conn, session_id, loop)
interim_assistant_cb = make_interim_assistant_cb(conn, session_id, loop)
approval_cb = make_approval_callback(conn.request_permission, loop, session_id)
else:
tool_progress_cb = None
thinking_cb = None
step_cb = None
message_cb = None
stream_delta_cb = None
interim_assistant_cb = None
approval_cb = None

agent = state.agent
agent.tool_progress_callback = tool_progress_cb
agent.thinking_callback = thinking_cb
agent.step_callback = step_cb
agent.message_callback = message_cb
agent.stream_delta_callback = stream_delta_cb
agent.interim_assistant_callback = interim_assistant_cb

if approval_cb:
try:
Expand Down
122 changes: 121 additions & 1 deletion gateway/platforms/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,27 @@ def _strip_mdv2(text: str) -> str:
return cleaned


_AUTH_ERROR_CLASS_NAMES = frozenset({
"Unauthorized",
"InvalidToken",
"Forbidden",
})
_AUTH_ERROR_MESSAGE_FRAGMENTS = (
"rejected by the server",
"invalid token",
"unauthorized",
"token is invalid",
)


def _is_nonretryable_auth_error(exc: Exception) -> bool:
"""Detect Telegram auth errors that won't resolve on retry."""
if exc.__class__.__name__ in _AUTH_ERROR_CLASS_NAMES:
return True
message = str(exc).lower()
return any(fragment in message for fragment in _AUTH_ERROR_MESSAGE_FRAGMENTS)


class TelegramAdapter(BasePlatformAdapter):
"""
Telegram bot adapter.
Expand Down Expand Up @@ -170,6 +191,99 @@ def __init__(self, config: PlatformConfig):
self._model_picker_state: Dict[str, dict] = {}
# Approval button state: message_id → session_key
self._approval_state: Dict[int, str] = {}
# Inbound-topic allowlist: only respond in designated group topics
self._allowed_inbound_targets = self._parse_allowed_inbound_targets(
self.config.extra.get("allowed_inbound_targets", [])
if getattr(self.config, "extra", None)
else []
)

@staticmethod
def _parse_allowed_inbound_targets(raw_targets: Any) -> set[tuple[str, Optional[str]]]:
"""Normalize inbound allowlist config into {(chat_id, thread_id)} tuples."""
if raw_targets is None:
return set()

if isinstance(raw_targets, (str, int, dict)):
raw_targets = [raw_targets]

normalized: set[tuple[str, Optional[str]]] = set()
for item in raw_targets:
chat_id: Optional[str] = None
thread_id: Optional[str] = None

if isinstance(item, dict):
chat_val = item.get("chat_id")
thread_val = item.get("thread_id")
if chat_val is None:
continue
chat_id = str(chat_val).strip()
thread_id = str(thread_val).strip() if thread_val is not None else None
elif isinstance(item, int) and not isinstance(item, bool):
chat_id = str(item)
elif isinstance(item, str):
target = item.strip()
if not target:
continue
if target.startswith("telegram:"):
target = target.split(":", 1)[1]
if ":" in target:
chat_part, thread_part = target.rsplit(":", 1)
chat_id = chat_part.strip()
thread_id = thread_part.strip() or None
else:
chat_id = target
else:
continue

if not chat_id:
continue
normalized.add((chat_id, thread_id or None))

return normalized

def _is_inbound_target_allowed(
self,
*,
chat_id: str,
chat_type: str,
thread_id: Optional[str],
) -> bool:
"""Return whether an inbound Telegram message should be processed."""
if not self._allowed_inbound_targets:
return True
if chat_type == "dm":
return True

for allowed_chat_id, allowed_thread_id in self._allowed_inbound_targets:
if allowed_chat_id != chat_id:
continue
if allowed_thread_id is None or allowed_thread_id == thread_id:
return True
return False

def _is_inbound_message_allowed(self, message: Message) -> bool:
"""Convenience wrapper around _is_inbound_target_allowed for PTB Message."""
chat = message.chat
chat_type = "dm"
if chat.type in (ChatType.GROUP, ChatType.SUPERGROUP):
chat_type = "group"
elif chat.type == ChatType.CHANNEL:
chat_type = "channel"
thread_id = str(message.message_thread_id) if getattr(message, "message_thread_id", None) else None
allowed = self._is_inbound_target_allowed(
chat_id=str(chat.id),
chat_type=chat_type,
thread_id=thread_id,
)
if not allowed:
logger.info(
"[%s] Ignoring inbound Telegram message outside allowlist: chat=%s thread=%s",
self.name,
chat.id,
thread_id or "-",
)
return allowed

@staticmethod
def _is_callback_user_authorized(user_id: str) -> bool:
Expand Down Expand Up @@ -786,7 +900,10 @@ def _polling_error_callback(error: Exception) -> None:
except Exception as e:
self._release_platform_lock()
message = f"Telegram startup failed: {e}"
self._set_fatal_error("telegram_connect_error", message, retryable=True)
if _is_nonretryable_auth_error(e):
self._set_fatal_error("telegram_invalid_token", message, retryable=False)
else:
self._set_fatal_error("telegram_connect_error", message, retryable=True)
logger.error("[%s] Failed to connect to Telegram: %s", self.name, e, exc_info=True)
return False

Expand Down Expand Up @@ -2202,13 +2319,16 @@ def _should_process_message(self, message: Message, *, is_command: bool = False)
"""Apply Telegram group trigger rules.

DMs remain unrestricted. Group/supergroup messages are accepted when:
- the inbound-topic allowlist (if configured) permits this chat/thread
- the chat is explicitly allowlisted in ``free_response_chats``
- ``require_mention`` is disabled
- the message is a command
- the message replies to the bot
- the bot is @mentioned
- the text/caption matches a configured regex wake-word pattern
"""
if not self._is_inbound_message_allowed(message):
return False
if not self._is_group_chat(message):
return True
thread_id = getattr(message, "message_thread_id", None)
Expand Down
4 changes: 2 additions & 2 deletions hermes_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,8 @@ class ProviderConfig:
id="zai",
name="Z.AI / GLM",
auth_type="api_key",
inference_base_url="https://api.z.ai/api/paas/v4",
api_key_env_vars=("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"),
inference_base_url="https://api.z.ai/api/coding/paas/v4",
api_key_env_vars=("ZAI_API_KEY", "Z_AI_API_KEY"),
base_url_env_var="GLM_BASE_URL",
),
"kimi-coding": ProviderConfig(
Expand Down
16 changes: 12 additions & 4 deletions hermes_cli/banner.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from pathlib import Path
from hermes_constants import get_hermes_home
from typing import Dict, List, Optional
from hermes_cli.update_channel import detect_update_target, write_update_channel

from rich.console import Console
from rich.panel import Panel
Expand Down Expand Up @@ -124,7 +125,7 @@ def get_available_skills() -> Dict[str, List[str]]:


def check_for_updates() -> Optional[int]:
"""Check how many commits behind origin/main the local repo is.
"""Check how many commits behind the configured update target the local repo is.

Does a ``git fetch`` at most once every 6 hours (cached to
``~/.hermes/.update_check``). Returns the number of commits behind,
Expand All @@ -150,10 +151,17 @@ def check_for_updates() -> Optional[int]:
except Exception:
pass

# Fetch latest refs (fast — only downloads ref metadata, no files)
update_target = detect_update_target(repo_dir, hermes_home)
_, remote, branch = update_target

# Persist inferred channel so future updates remain explicit even if the
# repo is temporarily checked out elsewhere.
write_update_channel(update_target[0], hermes_home)

# Fetch the specific remote branch so FETCH_HEAD reflects the current tip.
try:
subprocess.run(
["git", "fetch", "origin", "--quiet"],
["git", "fetch", remote, branch, "--quiet"],
capture_output=True, timeout=10,
cwd=str(repo_dir),
)
Expand All @@ -163,7 +171,7 @@ def check_for_updates() -> Optional[int]:
# Count commits behind
try:
result = subprocess.run(
["git", "rev-list", "--count", "HEAD..origin/main"],
["git", "rev-list", "--count", "HEAD..FETCH_HEAD"],
capture_output=True, text=True, timeout=5,
cwd=str(repo_dir),
)
Expand Down
2 changes: 1 addition & 1 deletion hermes_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -801,7 +801,7 @@ def run_doctor(args):
# Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)
# If supports_models_endpoint is False, we skip the health check and just show "configured"
_apikey_providers = [
("Z.AI / GLM", ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "https://api.z.ai/api/paas/v4/models", "GLM_BASE_URL", True),
("Z.AI / GLM", ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "https://api.z.ai/api/coding/paas/v4/models", "GLM_BASE_URL", True),
("Kimi / Moonshot", ("KIMI_API_KEY",), "https://api.moonshot.ai/v1/models", "KIMI_BASE_URL", True),
("Kimi / Moonshot (China)", ("KIMI_CN_API_KEY",), "https://api.moonshot.cn/v1/models", None, True),
("Arcee AI", ("ARCEEAI_API_KEY",), "https://api.arcee.ai/api/v1/models", "ARCEE_BASE_URL", True),
Expand Down
Loading