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:
@@ -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}
|
||||
|
||||
@@ -461,6 +461,16 @@ class Settings(BaseSettings):
|
||||
default="",
|
||||
description="Telegram Webhook Secret Token(setWebhook 設定的同一值)",
|
||||
)
|
||||
# 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",
|
||||
|
||||
0
apps/api/src/hermes/__init__.py
Normal file
0
apps/api/src/hermes/__init__.py
Normal file
37
apps/api/src/hermes/agent_loader.py
Normal file
37
apps/api/src/hermes/agent_loader.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""載入 .claude/agents/*.md 並解析 system prompt(ADR-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 不存在回傳 None(caller 決定是否 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"))]
|
||||
43
apps/api/src/hermes/display_names.py
Normal file
43
apps/api/src/hermes/display_names.py
Normal 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"
|
||||
133
apps/api/src/hermes/nl_gateway.py
Normal file
133
apps/api/src/hermes/nl_gateway.py
Normal 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}"
|
||||
63
apps/api/src/hermes/safety_hooks.py
Normal file
63
apps/api/src/hermes/safety_hooks.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Hermes NL 輸入安全守門 — Python 重做 awoooi-guard.js(ADR-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)
|
||||
@@ -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 各自回覆分析
|
||||
|
||||
Reference in New Issue
Block a user