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

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)