feat(ws4): Hermes NL 自然語言介面 — 12-Agent Claude SDK 接入(ADR-094/095)

## hermes/ 套件(5 個新模組)

### display_names.py
- 12 agent 視覺識別表(emoji + hashtag + handle + short_name)
- format_response_header() 產生 Telegram 前綴

### agent_loader.py
- 解析 .claude/agents/*.md frontmatter → system prompt
- lru_cache 避免重複讀檔

### safety_hooks.py
- 移植 awoooi-guard.js 20 條 HARD BLOCK 規則(DENY_PATTERNS)
- 5 條 MUTATE_PATTERNS → 須走審批流

### nl_gateway.py
- Layer 1: 關鍵字正則路由(12 條規則,<10ms)
- Layer 3: DEFAULT_AGENT = "debugger"
- Claude Agent SDK query() 非同步串流,取 ResultMessage.result
- 安全降級:SDK error → 友好錯誤訊息

### telegram_webhook.py
- WS4 Hermes NL 接入(@tsenyangbot mention 或私訊觸發)
- HERMES_NL_ENABLED=False(feature flag 保護,預設關閉)

## telegram_gateway.py
- send_hermes_reply(text, chat_id, reply_to_message_id)
  無 500 字截斷,支援 Agent 長回覆

## config.py
- HERMES_NL_ENABLED: bool = False
- TELEGRAM_BOT_USERNAME: str = "tsenyangbot"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-04-25 02:05:18 +08:00
parent 5675e7c3b0
commit 2572ec46d2
8 changed files with 361 additions and 5 deletions

View File

@@ -1,10 +1,11 @@
"""
Telegram Webhook Handler — ADR-094
===================================
Telegram Webhook Handler — ADR-094 / ADR-095
=============================================
接收 Telegram Bot API 的 webhook push支援 secret_token 驗證。
WS4 Hermes NL 接入點框架(目前只記錄 update不執行動作
WS4 Hermes NL 接入@mention 觸發 12-Agent 自然語言問答
2026-04-24 Claude Sonnet 4.6 (ADR-093 WS3 / ADR-094)
2026-04-24 Claude Sonnet 4.6 (ADR-095 WS4 Hermes NL 接入)
"""
from __future__ import annotations
@@ -70,7 +71,50 @@ async def telegram_webhook(request: Request) -> dict:
update_id=body.get("update_id"),
)
# WS4: 在此呼叫 Hermes NL router
# hermes_router.dispatch(body)
# WS4: Hermes NL 接入ADR-095
# 只在 HERMES_NL_ENABLED=true 且 update_type=message 時處理
if update_type == "message" and settings.HERMES_NL_ENABLED:
msg = body.get("message", {})
text = msg.get("text", "")
chat_id = str(msg.get("chat", {}).get("id", ""))
from_user = msg.get("from", {})
user_id = from_user.get("id", 0)
username = from_user.get("username", "")
bot_username = settings.TELEGRAM_BOT_USERNAME
# 觸發條件:@mention 或私訊(私訊的 chat.type == "private"
chat_type = msg.get("chat", {}).get("type", "")
is_mention = f"@{bot_username}" in text
is_private = chat_type == "private"
message_id: int | None = msg.get("message_id")
if (is_mention or is_private) and text.strip():
clean_text = text.replace(f"@{bot_username}", "").strip()
if clean_text:
# 延遲 import 避免啟動時副作用SDK / structlog 初始化)
from src.hermes.nl_gateway import process_nl_message
from src.services.telegram_gateway import get_telegram_gateway
try:
reply = await process_nl_message(
clean_text,
chat_id=chat_id,
user_id=user_id,
username=username,
)
gw = get_telegram_gateway()
# send_hermes_reply長文純文字reply-to 原始訊息
await gw.send_hermes_reply(
text=reply,
chat_id=chat_id,
reply_to_message_id=message_id,
)
except Exception as exc:
logger.error(
"telegram_webhook_hermes_error",
error=str(exc),
chat_id=chat_id,
user_id=user_id,
)
return {"ok": True}

View File

@@ -461,6 +461,16 @@ class Settings(BaseSettings):
default="",
description="Telegram Webhook Secret TokensetWebhook 設定的同一值)",
)
# 2026-04-24 Claude Sonnet 4.6 (ADR-095 WS4): Hermes NL 自然語言閘道
# false=不啟用預設true=啟用 @mention 問答(需 ANTHROPIC_API_KEY
HERMES_NL_ENABLED: bool = Field(
default=False,
description="Hermes NL 對話功能開關ADR-095",
)
TELEGRAM_BOT_USERNAME: str = Field(
default="tsenyangbot",
description="Telegram Bot username不含 @),用於 @mention 識別",
)
WEBHOOK_NONCE_TTL: int = Field(
default=300,
description="Nonce TTL in seconds for replay attack prevention",

View File

View File

@@ -0,0 +1,37 @@
"""載入 .claude/agents/*.md 並解析 system promptADR-095
2026-04-24 Claude Sonnet 4.6 (WS4 Hermes NL)
"""
from __future__ import annotations
import pathlib
from functools import lru_cache
_AGENTS_DIR = pathlib.Path("/Users/ogt/awoooi/.claude/agents")
def _parse_agent_md(path: pathlib.Path) -> str:
"""去除 YAML frontmatter回傳 body 作為 system prompt"""
text = path.read_text(encoding="utf-8")
# --- frontmatter ---
if text.startswith("---"):
end = text.find("---", 3)
if end != -1:
return text[end + 3:].strip()
return text.strip()
@lru_cache(maxsize=None)
def get_agent_system_prompt(agent_name: str) -> str | None:
"""
回傳指定 agent 的 system prompt。
若 .md 不存在回傳 Nonecaller 決定是否 fallback
"""
path = _AGENTS_DIR / f"{agent_name}.md"
if not path.exists():
return None
return _parse_agent_md(path)
def list_available_agents() -> list[str]:
"""回傳目前存在的 agent 名稱列表(無副檔名)"""
return [p.stem for p in sorted(_AGENTS_DIR.glob("*.md"))]

View File

@@ -0,0 +1,43 @@
"""ADR-095 12-Agent 視覺分派對照表
2026-04-24 Claude Sonnet 4.6 (WS4 Hermes NL)
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class AgentVisual:
emoji: str
hashtag: str
handle: str # 文字 prefix非真 bot
short_name: str
# 鍵值 = .claude/agents/{key}.md 的檔名(不含副檔名)
AGENT_VISUALS: dict[str, AgentVisual] = {
"critic": AgentVisual("🔍", "#審查", "@hermes-critic", "找碴專家"),
"vuln-verifier": AgentVisual("🎯", "#漏洞驗證", "@hermes-verifier", "漏洞驗證官"),
"debugger": AgentVisual("🐛", "#除錯", "@hermes-debugger", "除錯偵探"),
"db-expert": AgentVisual("💾", "#資料庫", "@hermes-db", "DB 軍師"),
"planner": AgentVisual("📋", "#拆解", "@hermes-planner", "任務拆解官"),
"fullstack-engineer": AgentVisual("🛠️", "#工程", "@hermes-engineer", "全端工程師"),
"frontend-designer": AgentVisual("🎨", "#設計", "@hermes-designer", "前端設計師"),
"refactor-specialist": AgentVisual("♻️", "#重構", "@hermes-refactor", "重構專家"),
"migration-engineer": AgentVisual("🚚", "#升級", "@hermes-migration", "升級工程師"),
"onboarder": AgentVisual("🗺️", "#導覽", "@hermes-onboarder", "領航員"),
"tool-expert": AgentVisual("🧰", "#工具", "@hermes-tools", "工具專家"),
"web-researcher": AgentVisual("📚", "#文檔", "@hermes-web", "文獻研究員"),
}
DEFAULT_AGENT = "debugger" # Layer 3 fallback最常見「X 為什麼壞了」)
def get_visual(agent_name: str) -> AgentVisual:
return AGENT_VISUALS.get(agent_name, AGENT_VISUALS[DEFAULT_AGENT])
def format_response_header(agent_name: str) -> str:
"""回傳 Telegram 訊息的 agent 識別前綴,格式: emoji 短稱 handle hashtag"""
v = get_visual(agent_name)
return f"{v.emoji} **{v.short_name}** `{v.handle}` {v.hashtag}\n\n"

View File

@@ -0,0 +1,133 @@
"""Hermes 自然語言閘道 — ADR-094
Layer 1 意圖路由(關鍵字正則)→ Claude Agent SDK 呼叫 → Telegram 格式化輸出。
2026-04-24 Claude Sonnet 4.6 (WS4 Hermes NL)
"""
from __future__ import annotations
import re
import structlog
from claude_agent_sdk import query, ClaudeAgentOptions
from claude_agent_sdk.types import ResultMessage
from src.hermes.agent_loader import get_agent_system_prompt
from src.hermes.display_names import DEFAULT_AGENT, format_response_header
from src.hermes.safety_hooks import is_dangerous_input, is_mutate_intent
logger = structlog.get_logger(__name__)
# ─────────────────────────────────────────────────────────────────────────────
# Layer 1 意圖路由(關鍵字正則,<10ms
# ─────────────────────────────────────────────────────────────────────────────
_ROUTING_RULES: list[tuple[re.Pattern, str]] = [
(re.compile(r"(資料庫|postgres|sql|index|query|migration|schema)", re.IGNORECASE), "db-expert"),
(re.compile(r"(漏洞|CVE|injection|XSS|CSRF|security|安全)", re.IGNORECASE), "vuln-verifier"),
(re.compile(r"(bug|crash|error|exception|fail|失敗|崩潰|為什麼壞|不通)", re.IGNORECASE), "debugger"),
(re.compile(r"(重構|refactor|clean|重寫)", re.IGNORECASE), "refactor-specialist"),
(re.compile(r"(升級|upgrade|migration|migrate|版本)", re.IGNORECASE), "migration-engineer"),
(re.compile(r"(設計|UI|frontend|頁面|按鈕|樣式)", re.IGNORECASE), "frontend-designer"),
(re.compile(r"(工具|tool|hook|MCP|plugin)", re.IGNORECASE), "tool-expert"),
(re.compile(r"(文件|document|官方|API spec|how to)", re.IGNORECASE), "web-researcher"),
(re.compile(r"(導覽|介紹|架構|codebase|overview)", re.IGNORECASE), "onboarder"),
(re.compile(r"(拆解|任務|plan|規劃)", re.IGNORECASE), "planner"),
(re.compile(r"(審查|review|code review|找問題)", re.IGNORECASE), "critic"),
(re.compile(r"(實作|implement|develop|功能|feature)", re.IGNORECASE), "fullstack-engineer"),
]
_HERMES_BUDGET_USD = 0.05
def _route_intent_layer1(text: str) -> str:
"""Layer 1: 關鍵字正則路由,回傳 agent 名稱"""
for pattern, agent in _ROUTING_RULES:
if pattern.search(text):
return agent
return DEFAULT_AGENT
async def process_nl_message(
user_message: str,
*,
chat_id: str,
user_id: int,
username: str = "",
) -> str:
"""
處理 NL 訊息,回傳 Telegram 格式的回覆文字。
流程:
1. 安全守門DENY + MUTATE
2. Layer 1 關鍵字路由 → agent_name
3. 讀取 agent system prompt.claude/agents/*.md
4. 呼叫 Claude Agent SDK query()
5. 格式化為 Telegram MarkdownV2 訊息
"""
# 安全守門
if is_dangerous_input(user_message):
logger.warning(
"hermes_nl_dangerous_input",
user_id=user_id,
chat_id=chat_id,
preview=user_message[:80],
)
return "⛔ 偵測到危險指令,拒絕處理。"
if is_mutate_intent(user_message):
return (
"⚠️ 此操作涉及變更,需透過正式審批流程執行。\n"
"請在 Telegram 告警卡片上操作,或聯繫值班 SRE。"
)
# Layer 1 意圖路由
agent_name = _route_intent_layer1(user_message)
# 確認 agent 存在,否則 fallback
system_prompt = get_agent_system_prompt(agent_name)
if system_prompt is None:
logger.warning(
"hermes_nl_agent_not_found",
agent=agent_name,
fallback=DEFAULT_AGENT,
)
agent_name = DEFAULT_AGENT
system_prompt = get_agent_system_prompt(agent_name) or ""
logger.info(
"hermes_nl_dispatch",
agent=agent_name,
user_id=user_id,
chat_id=chat_id,
username=username,
)
# 呼叫 Claude Agent SDK
try:
options = ClaudeAgentOptions(
system_prompt=system_prompt,
max_turns=3,
permission_mode="dontAsk",
max_budget_usd=_HERMES_BUDGET_USD,
)
result_text = ""
async for event in query(prompt=user_message, options=options):
if isinstance(event, ResultMessage):
result_text = getattr(event, "result", "") or ""
break
if not result_text:
result_text = "_Agent 回應為空請稍後再試。_"
except Exception as exc:
logger.error(
"hermes_nl_sdk_error",
error=str(exc),
agent=agent_name,
exc_type=type(exc).__name__,
)
result_text = f"_Hermes 暫時無法連線({type(exc).__name__}請稍後再試。_"
header = format_response_header(agent_name)
# Telegram 訊息上限 4096 字元,超過截斷
body = result_text[:3800]
return f"{header}{body}"

View File

@@ -0,0 +1,63 @@
"""Hermes NL 輸入安全守門 — Python 重做 awoooi-guard.jsADR-094
從 .claude/hooks/awoooi-guard.js HARD BLOCK 規則移植,
涵蓋 K8s 生產刪除、docker volume 摧毀、force push、DROP TABLE 等。
2026-04-24 Claude Sonnet 4.6 (WS4 Hermes NL)
"""
from __future__ import annotations
import re
# ─────────────────────────────────────────────────────────────────────────────
# DENY 正則:完整移植自 awoooi-guard.js HARD BLOCK 段落
# ─────────────────────────────────────────────────────────────────────────────
_DENY_PATTERNS: list[re.Pattern] = [
# 基本破壞性指令
re.compile(r"rm\s+-rf", re.IGNORECASE),
re.compile(r"git\s+push\s+.*--force", re.IGNORECASE),
re.compile(r"DROP\s+TABLE", re.IGNORECASE),
re.compile(r"truncate\s+table", re.IGNORECASE),
re.compile(r"kubectl\s+delete\s+namespace", re.IGNORECASE),
re.compile(r"systemctl\s+(stop|disable)\s+", re.IGNORECASE),
re.compile(r"chmod\s+777", re.IGNORECASE),
re.compile(r"curl.*(sh|bash)\s*\|", re.IGNORECASE),
re.compile(r">\s*/etc/", re.IGNORECASE),
# K8s 生產命名空間(移植自 awoooi-guard.js HARD BLOCK
re.compile(r"kubectl.*delete.*namespace.*awoooi-prod", re.IGNORECASE),
# K8s 生產 PVC/Secret 強制刪除
re.compile(r"kubectl.*delete.*(pvc|secret).*-n.*awoooi-prod", re.IGNORECASE),
re.compile(r"kubectl.*-n.*awoooi-prod.*delete.*(pvc|secret)", re.IGNORECASE),
# docker compose down -v摧毀 volume
re.compile(r"docker[\s-]?compose.*down.*(-v\b|--volumes)", re.IGNORECASE),
# docker system prune -f
re.compile(r"docker\s+system\s+prune.*(-f|--force)", re.IGNORECASE),
# Telegram logOut / deleteWebhook先停後換原則
re.compile(r"api\.telegram\.org/bot[^/]+/(logOut|deleteWebhook)", re.IGNORECASE),
# psql DROP TABLE/DATABASE非 test/dev 環境)
re.compile(r"psql.*-c.*DROP\s+(TABLE|DATABASE|SCHEMA)", re.IGNORECASE),
# force push 到 gitea main
re.compile(r"git push.*(--force|-f).*gitea.*main", re.IGNORECASE),
re.compile(r"git push.*gitea.*main.*(--force|-f)", re.IGNORECASE),
# DROP DATABASE 直接語句
re.compile(r"DROP\s+DATABASE", re.IGNORECASE),
# 清除 /dev/null 覆蓋系統檔案
re.compile(r">\s*/dev/(mem|kmem|port)", re.IGNORECASE),
]
# ─────────────────────────────────────────────────────────────────────────────
# MUTATE 正則:變更意圖偵測(需走審批流)
# ─────────────────────────────────────────────────────────────────────────────
# 只允許 query/describe/summarize 類動作mutate/deploy/approve 走審批流)
_MUTATE_PATTERNS: list[re.Pattern] = [
re.compile(r"\b(deploy|apply|create|delete|rollout|approve|execute)\b", re.IGNORECASE),
]
def is_dangerous_input(text: str) -> bool:
"""True → 拒絕,回 ⛔"""
return any(p.search(text) for p in _DENY_PATTERNS)
def is_mutate_intent(text: str) -> bool:
"""True → 需要走 ApprovalRecord 二次確認"""
return any(p.search(text) for p in _MUTATE_PATTERNS)

View File

@@ -4606,6 +4606,32 @@ class TelegramGateway:
return await self._send_request("sendMessage", payload)
# =========================================================================
# 2026-04-24 Claude Sonnet 4.6 (ADR-095 WS4): Hermes NL 回覆
# =========================================================================
async def send_hermes_reply(
self,
text: str,
chat_id: str | int,
reply_to_message_id: int | None = None,
) -> dict:
"""
傳送 Hermes NL 回覆(長文,最多 4096 字元,純文字模式)。
Args:
text: 回覆內容(由 nl_gateway 已截斷至 4000 字以內)
chat_id: 目標 chat ID
reply_to_message_id: 回覆哪則訊息(可選)
"""
payload: dict = {
"chat_id": chat_id,
"text": text[:4096],
}
if reply_to_message_id:
payload["reply_to_message_id"] = reply_to_message_id
return await self._send_request("sendMessage", payload)
# =========================================================================
# 2026-04-03 ogt: SRE 戰情室群組三頭政治 (Triumvirate) — ADR-053
# @tsenyangbot 發告警卡片到群組OpenClaw/NemoClaw Bot 各自回覆分析