Files
awoooi/apps/api/src/agents/solver_agent.py
Your Name 0d81b28b1b
All checks were successful
E2E Health Check / e2e-health (push) Successful in 52s
CD Pipeline / build-and-deploy (push) Successful in 9m24s
fix(aiops): bound phase2 timeout and repair incident links
2026-04-24 23:53:56 +08:00

440 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
AWOOOI AIOps Phase 2 — Solver Agent軍師
===========================================
職責:對每個根因假設產修復方案
輸入DiagnosisReport來自 Diagnostician
輸出ActionPlan候選動作含 blast_radius + rollback_cost + confidence
設計原則:
1. 每個 Hypothesis 至少產 1 個 CandidateAction
2. blast_radius 評分影響 Reviewer 的審查嚴格度
3. blast_radius > 50 → Reviewer 必須 request_revision
4. 熔斷降級LLM 失敗 → rule-based mock基於 category 推 RESTART 為兜底動作)
5. Solver 不直接觸碰執行層Coordinator 的工作)
ADR-082: Phase 2 多 Agent 協作
2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 2 初始建立
"""
from __future__ import annotations
import asyncio
import hashlib
import time
from typing import Any
import structlog
from src.agents.base import BaseAgent
from src.agents.protocol import (
ActionPlan,
AgentRole,
AgentVote,
CandidateAction,
DiagnosisReport,
)
from src.services.sanitization_service import sanitize
logger = structlog.get_logger(__name__)
# Phase 2 單步 LLM timeout保留 Critic/Coordinator 的全局預算)
PHASE2_STEP_TIMEOUT_SEC = 20.0
class SolverAgent(BaseAgent):
"""
Solver Agent — 修復方案軍師
Usage:
agent = SolverAgent()
plan = await agent.run(diagnosis_report)
"""
AGENT_NAME = AgentRole.SOLVER.value
AGENT_DESCRIPTION = "Remediation plan specialist. Produces candidate actions with blast radius scoring."
async def run(
self,
diagnosis: DiagnosisReport,
timeout_sec: float = 0.0, # noqa: ARG002 — 已廢棄,保留簽名相容性
) -> ActionPlan:
"""
根據診斷報告產出修復計畫。
Args:
diagnosis: Diagnostician 輸出
timeout_sec: 已廢棄 (2026-04-16 ogt) — LLM 等完整回應,真實異常才降級
Returns:
ActionPlan真實異常時 degraded=True
"""
start_ms = int(time.monotonic() * 1000)
# 若 Diagnostician 已棄權Solver 也應棄權(無論降級假設是否存在)
# Gate 2: 原條件 `and not diagnosis.hypotheses` 誤放行降級的 confidence=0.2 假設
if diagnosis.vote == AgentVote.ABSTAIN:
return ActionPlan(
candidates=[],
diagnosis_report=diagnosis,
latency_ms=0,
vote=AgentVote.ABSTAIN,
degraded=diagnosis.degraded,
)
try:
plan = await self._solve(diagnosis)
plan.latency_ms = int(time.monotonic() * 1000) - start_ms
logger.info(
"solver_done",
candidates=len(plan.candidates),
vote=plan.vote,
latency_ms=plan.latency_ms,
)
return plan
except Exception:
latency = int(time.monotonic() * 1000) - start_ms
logger.exception("solver_error")
return self._degraded_plan(diagnosis, latency, "error")
async def _solve(self, diagnosis: DiagnosisReport) -> ActionPlan:
"""核心 LLM 推理邏輯。"""
top = diagnosis.top_hypothesis
if not top:
return ActionPlan(
candidates=[],
diagnosis_report=diagnosis,
latency_ms=0,
vote=AgentVote.ABSTAIN,
)
# 2026-04-17 ogt + Claude Sonnet 4.6 (Checkpoint-2 環境感知):
# 根因LLM 在無叢集上下文時「盲猜」資源名稱 → awooiii-api三個 i→ K8s not found
# 修復:生成指令前先拉取實際 Deployment 清單,注入 prompt 讓 LLM 對齊真實名稱
# 失敗無害kubectl 超時或拒絕 → _k8s_inventory 為空 → prompt 仍正常但無鎖定效果
_k8s_inventory = await _fetch_k8s_inventory(namespace="awoooi-prod")
prompt = self._build_prompt({
"hypothesis": top.description,
"category": top.category,
"confidence": top.confidence,
"k8s_inventory": _k8s_inventory,
})
# 2026-04-16 ogt + Claude Sonnet 4.6: 傳遞 hypothesis 結構化資料給 OPENCLAW_NEMO
# 根因:原本 call(prompt) 不傳 context → nemo fallback 把 prompt[:500](系統說明)
# 當 signal description → LLM 回傳「設計修復方案的軍師 Agent」垃圾
# 修復:把 top hypothesis description 放進 alert_context.signals 讓 nemo 看到真實診斷
_hypothesis_text = (top.description or "(待診斷)")[:800]
alert_context = {
"incident_id": diagnosis.evidence_snapshot_id or "UNKNOWN",
"severity": "P3",
"signals": [{"alert_name": "diagnosis_hypothesis", "description": _hypothesis_text}],
"affected_services": [],
"intent_hint": "diagnose",
}
from src.services.openclaw import get_openclaw
openclaw = get_openclaw()
try:
response_text, _provider, success = await asyncio.wait_for(
openclaw.call(prompt, alert_context=alert_context),
timeout=PHASE2_STEP_TIMEOUT_SEC,
)
except asyncio.TimeoutError:
logger.warning(
"solver_step_timeout",
snapshot_id=diagnosis.evidence_snapshot_id,
timeout_sec=PHASE2_STEP_TIMEOUT_SEC,
)
return self._degraded_plan(diagnosis, 0, "step_timeout")
if not success or not response_text:
return self._degraded_plan(diagnosis, 0, "llm_failed")
parsed = self._parse_response(sanitize(response_text, "solver_output"))
candidates = _extract_candidates(parsed)
if not candidates:
return self._degraded_plan(diagnosis, 0, "no_candidates")
return ActionPlan(
candidates=candidates,
diagnosis_report=diagnosis,
latency_ms=0,
vote=AgentVote.APPROVE,
)
def _build_prompt(self, context: dict[str, Any]) -> str:
# 2026-04-17 ogt + Claude Sonnet 4.6: 修復 Solver action 格式問題
# 根因:舊 prompt action 範例為 "restart_service:awoooi-api"(自訂格式)
# LLM 模仿範例輸出自然語言描述,而非 kubectl 命令
# → auto_approve Condition 1c 拒絕(無 kubectl 關鍵字)
# → blast_radius_calculator 永遠不被調用fill rate = 0%
# 修復:要求 action 必須是真實 kubectl 命令,並提供正確範例
# 2026-04-17 ogt + Claude Sonnet 4.6 (Checkpoint-2): 注入 K8s 實際 Deployment 清單
# LLM 必須從此清單選擇資源名稱,不可自行編造
_inventory = context.get("k8s_inventory", "")
_inventory_section = (
f"\n🔒 叢集實際 Deployment 清單awoooi-prod— 必須從此清單選擇資源名稱:\n{_inventory}\n"
if _inventory
else "\n⚠️ 無法取得叢集清單,請謹慎填寫資源名稱。\n"
)
return f"""你是 AWOOOI SRE 系統的軍師 Agent專職修復方案設計。
根因假設:{context.get("hypothesis", "")}
告警類別:{context.get("category", "")}
診斷信心:{context.get("confidence", 0.0):.0%}
{_inventory_section}
你的工作:為此根因提出 1-3 個修復候選方案。
每個方案必須評估:
- blast_radius0-100影響範圍越高 = 風險越大)
- rollback_cost0-100回滾難度越高 = 越難還原)
blast_radius 參考:
- kubectl rollout restart deployment = 10
- kubectl scale deployment --replicas=N = 15
- kubectl rollout undo deployment = 25
- kubectl apply -f = 40
- kubectl delete deployment = 75
- kubectl delete pvc = 95
🔴 關鍵規則action 欄位必須是真實的 kubectl 命令,不可用自然語言描述。
目標資源格式deployment/<name>,命名空間統一用 awoooi-prod。
以 JSON 回覆:
{{
"candidates": [
{{
"action": "kubectl rollout restart deployment/awoooi-api -n awoooi-prod",
"blast_radius": 10,
"rollback_cost": 5,
"confidence": 0.8,
"rationale": "重啟可清除 OOM 導致的記憶體碎片化"
}}
]
}}"""
def _parse_response(self, response: str) -> dict[str, Any]:
return self._extract_json(response)
def analyze(self, context: dict[str, Any]) -> Any:
raise NotImplementedError("Use run() for Phase 2 agents")
def _degraded_plan(
self,
diagnosis: DiagnosisReport,
latency_ms: int,
reason: str = "unknown",
) -> ActionPlan:
"""熔斷降級rule-based mock依 category 推 RESTART 兜底)"""
category = diagnosis.top_hypothesis.category if diagnosis.top_hypothesis else "Unknown"
fallback_action = _default_action_for_category(category)
return ActionPlan(
candidates=[
CandidateAction(
action=fallback_action,
blast_radius=20,
rollback_cost=5,
confidence=0.2,
rationale=f"[降級] LLM 分析失敗({reason}),使用類別 {category} 的預設兜底動作",
)
],
diagnosis_report=diagnosis,
latency_ms=latency_ms,
vote=AgentVote.DEGRADED,
degraded=True,
)
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
async def _fetch_k8s_inventory(namespace: str = "awoooi-prod", timeout_sec: float = 5.0) -> str:
"""
取得 K8s 叢集實際 Deployment/StatefulSet 清單,供 Solver prompt 注入。
2026-04-17 ogt + Claude Sonnet 4.6 (Checkpoint-2 環境感知):
- 在生成 kubectl 指令前查詢叢集真實資源,防止 LLM 幻覺資源名(如 awooiii-api
- 超時或失敗 → 返回 ""(呼叫端降級為警示模式,不中斷 Solver 主流程)
- 只執行唯讀 get 指令,不修改叢集
Returns:
"awoooi-api, awoooi-web, postgres, ..." 格式字串,失敗時返回 ""
"""
import asyncio as _asyncio
try:
cmd = f"kubectl get deployments,statefulsets -n {namespace} -o jsonpath='{{.items[*].metadata.name}}' 2>/dev/null"
proc = await _asyncio.create_subprocess_shell(
cmd,
stdout=_asyncio.subprocess.PIPE,
stderr=_asyncio.subprocess.PIPE,
)
try:
stdout, _ = await _asyncio.wait_for(proc.communicate(), timeout=timeout_sec)
except _asyncio.TimeoutError:
proc.kill()
logger.warning("k8s_inventory_timeout", namespace=namespace, timeout_sec=timeout_sec)
return ""
raw = (stdout or b"").decode("utf-8", errors="replace").strip()
if not raw:
return ""
# jsonpath 輸出以空格分隔,轉成可讀逗號格式
names = [n.strip() for n in raw.split() if n.strip()]
inventory = ", ".join(names)
logger.debug("k8s_inventory_fetched", namespace=namespace, count=len(names))
return inventory
except Exception as _e:
logger.warning("k8s_inventory_failed", namespace=namespace, error=str(_e))
return ""
def _extract_candidates(parsed: dict[str, Any]) -> list[CandidateAction]:
"""從 LLM 解析結果提取候選方案(按信心降序)。
支援兩種格式:
1. 標準格式:{"candidates": [{action, blast_radius, rollback_cost, confidence, rationale}]}
2. OpenClaw Nemo 格式:{"action_title": "...", "risk_level": "...", "confidence": 0.85}
2026-04-16 ogt + Claude Sonnet 4.6: 與 diagnostician 同步,修復 openclaw_nemo 格式不相容
"""
# OpenClaw Nemo 格式轉換
# 2026-04-17 ogt + Claude Sonnet 4.6: Nemo path kubectl 驗證
# 根因Nemo 回傳 {"action_title": "重啟 Crash Looping Pod"} 自然語言
# 直接用 action_title → 無 kubectl → auto_approve 誤通過 → 死迴圈
# 2026-04-24 ogt + Claude Sonnet 4.6: 修復靜默丟棄 → 語意合成兜底
# 舊action_title 無 kubectl → return [] → _degraded_plan confidence=0.2
# 新:先嘗試語意合成 kubectl 指令;真的無從映射才 return []
if "action_title" in parsed and "candidates" not in parsed:
action_title = str(parsed.get("action_title", ""))
confidence = float(parsed.get("confidence", 0.5))
risk_level = str(parsed.get("risk_level", "medium"))
risk_to_blast = {"critical": 60, "high": 40, "medium": 25, "low": 10}
blast = risk_to_blast.get(risk_level.lower(), 30)
if "kubectl" in action_title.lower():
if action_title and confidence > 0:
return [CandidateAction(
action=action_title[:200],
blast_radius=blast,
rollback_cost=20,
confidence=confidence,
rationale=f"OpenClaw Nemo 建議: {action_title}",
)]
return []
# action_title 無 kubectl → 嘗試語意合成 kubectl 指令
_at_lower = action_title.lower()
_synthesized: str | None = None
if any(w in _at_lower for w in ("rollback", "undo", "回滾", "還原")):
_synthesized = "kubectl rollout undo deployment -n awoooi-prod"
elif any(w in _at_lower for w in ("restart", "重啟", "重新啟動")):
_synthesized = "kubectl rollout restart deployment -n awoooi-prod"
elif any(w in _at_lower for w in ("scale", "擴容", "縮容", "replicas")):
_synthesized = "kubectl scale deployment -n awoooi-prod"
elif any(w in _at_lower for w in ("logs", "日誌", "log")):
_synthesized = "kubectl logs -n awoooi-prod --tail=100 --selector=app=awoooi-api"
elif any(w in _at_lower for w in ("describe", "診斷", "diagnos")):
_synthesized = "kubectl describe pods -n awoooi-prod"
if _synthesized:
logger.debug(
"solver_nemo_action_synthesized",
action_title=action_title[:80],
synthesized=_synthesized,
)
return [CandidateAction(
action=_synthesized,
blast_radius=blast,
rollback_cost=20,
confidence=min(confidence, 0.5), # 合成指令最高 0.5,避免誤入自動執行
rationale=f"[語意合成] Nemo 建議「{action_title[:80]}」→ 轉為 kubectl 指令",
)]
# 完全無從映射 → return [](交由 _degraded_plan 輸出 category-based 調查指令)
logger.debug(
"solver_nemo_no_kubectl_fallback",
action_title=action_title[:80],
reason="action_title 無 kubectl 且語意合成失敗,降級至 _degraded_plan",
)
return []
raw = parsed.get("candidates", [])
candidates = []
for item in raw:
if not isinstance(item, dict):
continue
c = CandidateAction(
action=str(item.get("action", ""))[:200],
blast_radius=max(0, min(100, int(item.get("blast_radius", 50)))),
rollback_cost=max(0, min(100, int(item.get("rollback_cost", 50)))),
confidence=float(item.get("confidence", 0.0)),
rationale=str(item.get("rationale", ""))[:500],
)
candidates.append(c)
candidates.sort(key=lambda c: c.confidence, reverse=True)
return candidates
def _default_action_for_category(category: str) -> str:
"""降級時的預設調查指令 — 必須是真實 kubectl/ssh 命令(調查優先,不執行破壞性操作)
2026-04-17 ogt + Claude Sonnet 4.6: 改為真實 kubectl 指令
2026-04-24 ogt + Claude Sonnet 4.6: 擴展非 K8s 類別ClickHouse/主機磁碟/DB
根因SentryClickHouseMemoryPressure/HostDiskUsageHigh 類別不符任何 K8s 關鍵字
→ 全部 fallback 到 "kubectl get pods"(無意義診斷指令)
修復:加入 clickhouse/database/sentry/host/node/infra 類別映射
"""
category_lower = category.lower()
# K8s workload 層
if "pod" in category_lower or "kube" in category_lower or "crash" in category_lower:
return "kubectl get pods -n awoooi-prod -o wide"
if "cpu" in category_lower or "load" in category_lower:
return "kubectl top pods -n awoooi-prod --sort-by=cpu"
if "memory" in category_lower or "oom" in category_lower:
return "kubectl top pods -n awoooi-prod --sort-by=memory"
if "network" in category_lower or "connect" in category_lower:
return "kubectl get services -n awoooi-prod"
if "disk" in category_lower or "storage" in category_lower or "pvc" in category_lower:
return "kubectl exec -n awoooi-prod deployment/postgresql -- df -h"
# 外部服務層(非 K8s — 唯讀診斷)
if "clickhouse" in category_lower or "sentry" in category_lower:
return "kubectl get pods -n awoooi-prod -l app=sentry -o wide"
if "database" in category_lower or "postgres" in category_lower or "redis" in category_lower:
return "kubectl get pods -n awoooi-prod -l tier=database -o wide"
if "rollback" in category_lower or "deploy" in category_lower or "version" in category_lower:
return "kubectl rollout history deployment -n awoooi-prod"
if "latency" in category_lower or "slow" in category_lower or "timeout" in category_lower:
return "kubectl top pods -n awoooi-prod --sort-by=cpu"
# 主機層host/node/infra — 調查指令kubectl 只查 node 資訊)
if "host" in category_lower or "node" in category_lower or "infra" in category_lower:
return "kubectl describe nodes | grep -A5 'Conditions\\|Allocatable'"
return "kubectl get pods -n awoooi-prod -o wide"
def compute_input_hash(diagnosis: DiagnosisReport) -> str:
"""計算 Solver 輸入的 fingerprint。"""
key = diagnosis.evidence_snapshot_id + (
diagnosis.top_hypothesis.description if diagnosis.top_hypothesis else ""
)
return hashlib.sha256(key.encode()).hexdigest()[:16]
# ─────────────────────────────────────────────────────────────────────────────
# Singleton
# ─────────────────────────────────────────────────────────────────────────────
_agent: SolverAgent | None = None
def get_solver_agent() -> SolverAgent:
global _agent
if _agent is None:
_agent = SolverAgent()
return _agent