All checks were successful
CD Pipeline / deploy (push) Successful in 1m14s
ADR-012 核心設計: - 4 級信任邊界:L0 直出 / L1 Hermes 觀察 / L2 NemoTron 診斷執行 / L3 OpenClaw HITL - 通知鏈絕不中斷:每級失敗立即降級,保底 L0 模板 + 🟡 標記 - Audit Trail:每次 dispatch 自動寫 ai_insights (insight_type=agent_action) - 安全白名單:L2 可呼叫 6 個安全 action(retry/query_km/silence + 3 個既有 NemoTron tool) 新增檔案: - services/event_router.py — 事件分流入口,按 severity × event_type 分 Tier - services/agent_actions.py — 安全 action 白名單(Phase 1 stub + 完整介面) - docs/adr/ADR-012-agent-action-ladder.md — 完整設計 + 分階段計畫 Phase 1 狀態: - L0 直出完整可用 ✅ - L1 Hermes / L2 NemoTron 為 stub(Phase 2/3 填實作) - Fallback 降級鏈已完整 ✅ - 靜音檢查(is_silenced)+ Audit Trail 已就緒 ✅ 處理既有 TODO: - services/openclaw_strategist_service.py::_notify_telegram_group() 改用 telegram_templates.report() 統一週報格式 全景盤點(新 memory): - reference_telegram_endpoints_map.md — 21 個 Telegram 發送點 - feedback_agent_action_ladder.md — 操作規範 (+ 既有 ADR-011 跨專案隔離規範一併生效) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
176 lines
6.9 KiB
Python
176 lines
6.9 KiB
Python
"""
|
||
Agent Action 白名單(ADR-012 Phase 1 骨幹)
|
||
|
||
L2 NemoTron 可安全呼叫的動作集合。嚴格限制:
|
||
- 只能寫 ai_insights 和發 Telegram
|
||
- 不可動 prod 資料表 / 容器 / 外部系統
|
||
- 所有 action 必須 dual-write 審計軌跡
|
||
|
||
現階段為 **stub + 完整 interface**,供 event_router 串接。真實執行邏輯將於 Phase 3 填入。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
from datetime import datetime, timedelta
|
||
from typing import Any
|
||
|
||
from services.logger_manager import SystemLogger
|
||
|
||
sys_log = SystemLogger("AgentAction").get_logger()
|
||
|
||
# 靜音表(記憶體快取,重啟後清空;Phase 3 可改 DB 持久化)
|
||
_silence_table: dict[str, datetime] = {}
|
||
|
||
|
||
def _audit(action: str, params: dict, result: dict, latency_ms: float) -> int | None:
|
||
"""所有 action 統一審計入 ai_insights(ADR-007 Dual-Write)"""
|
||
try:
|
||
from services.openclaw_learning_service import store_insight
|
||
return store_insight(
|
||
insight_type="agent_action",
|
||
content=f"action={action} result={result.get('status', 'unknown')}",
|
||
period=datetime.now().strftime("%Y-%m-%d"),
|
||
metadata={
|
||
"action": action,
|
||
"params": params,
|
||
"result": result,
|
||
"latency_ms": latency_ms,
|
||
"ts": datetime.now().isoformat(),
|
||
},
|
||
)
|
||
except Exception as e:
|
||
sys_log.error(f"[AgentAction] audit 失敗 action={action}: {e}")
|
||
return None
|
||
|
||
|
||
# =====================================================================
|
||
# 🔁 retry_task — 安全重試(exponential backoff)
|
||
# =====================================================================
|
||
def retry_task(task_name: str, max_attempts: int = 3, backoff_sec: int = 60) -> dict:
|
||
"""
|
||
安全重試一個 scheduler task。Phase 1 stub:只記錄,不真正重試。
|
||
Phase 3 將接入 scheduler.py 的 task dispatch。
|
||
|
||
限制:task_name 必須在白名單內(避免任意程式碼執行)
|
||
"""
|
||
ALLOWED_TASKS = {
|
||
"run_auto_import_task", "run_momo_task", "run_edm_task",
|
||
"run_competitor_price_feeder_task", "run_backup_monitor_task",
|
||
"run_icaim_analysis_task",
|
||
}
|
||
t0 = time.time()
|
||
if task_name not in ALLOWED_TASKS:
|
||
result = {"status": "rejected", "reason": f"task '{task_name}' not in whitelist"}
|
||
_audit("retry_task", {"task_name": task_name}, result, (time.time() - t0) * 1000)
|
||
sys_log.warning(f"[AgentAction] retry_task 拒絕:{task_name} 不在白名單")
|
||
return result
|
||
|
||
# TODO Phase 3: 真實重試邏輯(呼叫 scheduler module 的 task function)
|
||
result = {
|
||
"status": "queued",
|
||
"task_name": task_name,
|
||
"max_attempts": max_attempts,
|
||
"backoff_sec": backoff_sec,
|
||
"note": "Phase 1 stub — 尚未真正重試,僅記錄意圖",
|
||
}
|
||
_audit("retry_task", {"task_name": task_name, "max_attempts": max_attempts},
|
||
result, (time.time() - t0) * 1000)
|
||
sys_log.info(f"[AgentAction] retry_task 已排隊(stub): {task_name}")
|
||
return result
|
||
|
||
|
||
# =====================================================================
|
||
# 🔍 query_km — RAG 查詢歷史同類事件
|
||
# =====================================================================
|
||
def query_km(query: str, insight_type: str | None = None, limit: int = 5) -> dict:
|
||
"""透過 openclaw_learning_service.build_rag_context 找歷史同類事件"""
|
||
t0 = time.time()
|
||
try:
|
||
from services.openclaw_learning_service import build_rag_context
|
||
context = build_rag_context(query=query, insight_type=insight_type)
|
||
result = {
|
||
"status": "ok",
|
||
"query": query,
|
||
"context_preview": (context or "")[:500],
|
||
"has_results": bool(context and context.strip()),
|
||
}
|
||
except Exception as e:
|
||
result = {"status": "error", "error": str(e)[:200]}
|
||
sys_log.error(f"[AgentAction] query_km 失敗: {e}")
|
||
|
||
_audit("query_km", {"query": query, "insight_type": insight_type, "limit": limit},
|
||
result, (time.time() - t0) * 1000)
|
||
return result
|
||
|
||
|
||
# =====================================================================
|
||
# 🔕 silence_alert — 靜音抑制(避免告警風暴)
|
||
# =====================================================================
|
||
def silence_alert(event_key: str, duration_min: int = 60) -> dict:
|
||
"""
|
||
對特定 event_key 設定靜音期限。EventRouter 在 dispatch 前會先檢查。
|
||
event_key 建議格式:"<source>:<event_type>",例:
|
||
"Scheduler.AutoImport:db_connection_error"
|
||
"""
|
||
t0 = time.time()
|
||
until = datetime.now() + timedelta(minutes=duration_min)
|
||
_silence_table[event_key] = until
|
||
result = {"status": "silenced", "event_key": event_key, "until": until.isoformat()}
|
||
_audit("silence_alert", {"event_key": event_key, "duration_min": duration_min},
|
||
result, (time.time() - t0) * 1000)
|
||
sys_log.info(f"[AgentAction] silence_alert: {event_key} → 靜音至 {until.strftime('%H:%M')}")
|
||
return result
|
||
|
||
|
||
def is_silenced(event_key: str) -> bool:
|
||
"""EventRouter 呼叫,判斷是否需略過此事件"""
|
||
until = _silence_table.get(event_key)
|
||
if until is None:
|
||
return False
|
||
if datetime.now() >= until:
|
||
_silence_table.pop(event_key, None)
|
||
return False
|
||
return True
|
||
|
||
|
||
# =====================================================================
|
||
# 🏷️ 三個既有 NemoTron tool 的 wrapper(供 event_router 統一調用)
|
||
# =====================================================================
|
||
def flag_for_human_review(sku: str, concern: str) -> dict:
|
||
"""升級到 L3 HITL(包裝 NemoTron 既有 tool,保持呼叫介面一致)"""
|
||
t0 = time.time()
|
||
# TODO Phase 3: 接入 nemoton_dispatcher_service._exec_flag_for_human_review
|
||
result = {"status": "stub", "sku": sku, "concern": concern,
|
||
"note": "Phase 1 stub,Phase 3 接 NemoTron"}
|
||
_audit("flag_for_human_review", {"sku": sku, "concern": concern},
|
||
result, (time.time() - t0) * 1000)
|
||
return result
|
||
|
||
|
||
def route_to_km(sku: str, domain: str, summary: str) -> dict:
|
||
"""KM 歸檔(Phase 3 接 NemoTron)"""
|
||
t0 = time.time()
|
||
result = {"status": "stub", "note": "Phase 3 接 NemoTron"}
|
||
_audit("route_to_km", {"sku": sku, "domain": domain}, result, (time.time() - t0) * 1000)
|
||
return result
|
||
|
||
|
||
def mark_for_relearn(sku: str, reason: str) -> dict:
|
||
"""標記重新訓練(Phase 3 接 NemoTron)"""
|
||
t0 = time.time()
|
||
result = {"status": "stub", "note": "Phase 3 接 NemoTron"}
|
||
_audit("mark_for_relearn", {"sku": sku, "reason": reason}, result, (time.time() - t0) * 1000)
|
||
return result
|
||
|
||
|
||
# 白名單(供 EventRouter / NemoTron 引用)
|
||
SAFE_ACTIONS: dict[str, Any] = {
|
||
"retry_task": retry_task,
|
||
"query_km": query_km,
|
||
"silence_alert": silence_alert,
|
||
"flag_for_human_review": flag_for_human_review,
|
||
"route_to_km": route_to_km,
|
||
"mark_for_relearn": mark_for_relearn,
|
||
}
|