""" AWOOOI AIOps Phase 2 — Critic Agent(質疑者) ============================================= 職責:刻意唱反調,防止幻覺與 echo chamber 輸入:DiagnosisReport + ActionPlan(兩者都看) 輸出:CriticReport(challenges[] 列表 + overall_assessment) 設計原則: 1. Critic 的工作是找漏洞,不是說好話(防 sycophancy) 2. prompt 強制要求批判性思維:「如果診斷是錯的,還有哪 3 種可能?」 3. challenge_count > 0 是 Phase 2 退出條件之一 4. Critic 連續 3 次找到 Diagnostician 嚴重漏洞 → 觸發 Diagnostician 狀態不穩(Phase 4 實作) 5. 熔斷降級:LLM 失敗 → 輸出空 challenges(不阻塞 Coordinator) 6. Critic 和 Reviewer 並行執行(都不阻塞對方) ADR-082: Phase 2 多 Agent 協作 2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 2 初始建立 """ from __future__ import annotations import asyncio import hashlib import os import time from typing import Any import structlog from src.agents.base import BaseAgent from src.agents.protocol import ( ActionPlan, AgentRole, AgentVote, Challenge, CriticReport, DiagnosisReport, ) from src.observability.agent_step_metrics import observe_agent_step from src.services.sanitization_service import sanitize logger = structlog.get_logger(__name__) # Critic 挑戰數量上限(防止 LLM 生成無限質疑) MAX_CHALLENGES = 5 # 2026-04-27 Claude Sonnet 4.6: A1 — 三段 timeout 拆分 + step metric (北極星 §1.2 Observable by Default) # 背景:INC-20260425-8D17BB / 3B6C39 兩則告警 AI 信心降到 20% # OpenClaw NIM (192.168.0.188:8088) 實測 2-27s,原共用 PHASE2_STEP_TIMEOUT_SEC=20.0 # Critic 只做批判性審查(prompt 最短、輸出最簡),分配最小 timeout=15s 以保留全局預算給 Diagnostician/Solver # env override:部署時可透過 K8s ConfigMap 動態調整,無需重新 build image AGENT_CRITIC_TIMEOUT_SEC: float = float( os.environ.get("AGENT_CRITIC_TIMEOUT_SEC", "15.0") ) # 保留相容 alias,標記棄用 # DEPRECATED (2026-04-27): 使用 AGENT_CRITIC_TIMEOUT_SEC,此 alias 將在下一個 Sprint 移除 PHASE2_STEP_TIMEOUT_SEC = AGENT_CRITIC_TIMEOUT_SEC class CriticAgent(BaseAgent): """ Critic Agent — 系統性懷疑論者 Usage: agent = CriticAgent() report = await agent.run(diagnosis, plan) """ AGENT_NAME = AgentRole.CRITIC.value AGENT_DESCRIPTION = ( "Devil's advocate. Challenges diagnosis and proposed actions to prevent " "hallucination and echo chamber effects." ) async def run( self, diagnosis: DiagnosisReport, plan: ActionPlan, timeout_sec: float = 0.0, # noqa: ARG002 — 已廢棄,保留簽名相容性 ) -> CriticReport: """ 批判性審查診斷和方案。 Args: diagnosis: Diagnostician 輸出 plan: Solver 輸出 timeout_sec: 已廢棄 (2026-04-16 ogt) — LLM 等完整回應,真實異常才降級 Returns: CriticReport(真實異常時 degraded=True) """ start_ms = int(time.monotonic() * 1000) try: report = await self._critique(diagnosis, plan) report.latency_ms = int(time.monotonic() * 1000) - start_ms logger.info( "critic_done", challenges=report.challenge_count, has_critical=report.has_critical_challenge, vote=report.vote, latency_ms=report.latency_ms, ) return report except Exception: latency = int(time.monotonic() * 1000) - start_ms logger.exception("critic_error") return self._degraded_report(latency, "error") async def _critique( self, diagnosis: DiagnosisReport, plan: ActionPlan, ) -> CriticReport: """LLM 批判性推理。""" top_hypothesis = diagnosis.top_hypothesis top_candidate = plan.top_candidate prompt = self._build_prompt({ "hypothesis": top_hypothesis.description if top_hypothesis else "(無假設)", "action": top_candidate.action if top_candidate else "(無方案)", "confidence": top_hypothesis.confidence if top_hypothesis else 0.0, }) _critic_signal = ( f"hypothesis={top_hypothesis.description[:300] if top_hypothesis else 'none'}; " f"action={top_candidate.action[:300] if top_candidate else 'none'}" ) alert_context = { "incident_id": diagnosis.evidence_snapshot_id or "UNKNOWN", "severity": "P3", "signals": [{"alert_name": "critic_review", "description": _critic_signal}], "affected_services": [], "intent_hint": "diagnose", } from src.services.openclaw import get_openclaw openclaw = get_openclaw() _step_start = time.monotonic() try: response_text, _provider, success = await asyncio.wait_for( openclaw.call(prompt, alert_context=alert_context), timeout=AGENT_CRITIC_TIMEOUT_SEC, ) # 2026-04-27 Claude Sonnet 4.6: A1 — success path metric observe observe_agent_step("critic", "success", time.monotonic() - _step_start) except asyncio.TimeoutError: # 2026-04-27 Claude Sonnet 4.6: A1 — timeout path metric observe observe_agent_step("critic", "timeout", time.monotonic() - _step_start) logger.warning( "critic_step_timeout", snapshot_id=diagnosis.evidence_snapshot_id, timeout_sec=AGENT_CRITIC_TIMEOUT_SEC, ) return self._degraded_report(0, "step_timeout") if not success or not response_text: return self._degraded_report(0, "llm_failed") parsed = self._parse_response(sanitize(response_text, "critic_output")) challenges = _extract_challenges(parsed) # 有 critical challenge → vote = REJECT vote = AgentVote.REJECT if any(c.severity == "critical" for c in challenges) else AgentVote.APPROVE return CriticReport( challenges=challenges, overall_assessment=str(parsed.get("overall_assessment", ""))[:1000], latency_ms=0, vote=vote, ) def _build_prompt(self, context: dict[str, Any]) -> str: return f"""你是 AWOOOI SRE 系統的質疑者 Agent(Critic)。 你的工作是:找出診斷和方案的弱點。不是說好話,是找漏洞。 當前診斷:{context.get("hypothesis", "")} 當前方案:{context.get("action", "")} 診斷信心:{context.get("confidence", 0.0):.0%} 必須回答以下問題(每個問題產出一個 challenge): 1. 如果這個診斷是錯的,還有哪些可能的根因? 2. 這個方案有什麼副作用或風險? 3. 是否有更好的替代方案被忽略了? 每個 challenge 標記嚴重度: - "minor":小瑕疵,不影響執行 - "major":值得 Coordinator 考慮,但不是阻擋條件 - "critical":嚴重邏輯漏洞,必須阻止此方案執行 以 JSON 回覆: {{ "challenges": [ {{ "target": "diagnosis", "argument": "可能是 OOM 但也可能是 code bug,需要看 GC logs 確認", "severity": "major" }} ], "overall_assessment": "診斷可信但方案風險偏高" }}""" 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_report( self, latency_ms: int, reason: str = "unknown", ) -> CriticReport: """熔斷降級:輸出空 challenges(不阻塞 Coordinator)""" return CriticReport( challenges=[], overall_assessment=f"[降級] Critic LLM 失敗({reason}),跳過批判性審查", latency_ms=latency_ms, vote=AgentVote.ABSTAIN, degraded=True, ) # ───────────────────────────────────────────────────────────────────────────── # Helpers # ───────────────────────────────────────────────────────────────────────────── def _extract_challenges(parsed: dict[str, Any]) -> list[Challenge]: """從 LLM 解析結果提取 challenges(按嚴重度排序)。""" raw = parsed.get("challenges", []) challenges = [] severity_order = {"critical": 0, "major": 1, "minor": 2} for item in raw: if not isinstance(item, dict): continue c = Challenge( target=str(item.get("target", "unknown"))[:50], argument=str(item.get("argument", ""))[:500], severity=item.get("severity", "minor") if item.get("severity") in severity_order else "minor", ) challenges.append(c) challenges.sort(key=lambda c: severity_order.get(c.severity, 2)) return challenges[:MAX_CHALLENGES] def compute_input_hash(diagnosis: DiagnosisReport, plan: ActionPlan) -> str: key = diagnosis.evidence_snapshot_id + ( diagnosis.top_hypothesis.description if diagnosis.top_hypothesis else "" ) + ( plan.top_candidate.action if plan.top_candidate else "" ) return hashlib.sha256(key.encode()).hexdigest()[:16] # ───────────────────────────────────────────────────────────────────────────── # Singleton # ───────────────────────────────────────────────────────────────────────────── _agent: CriticAgent | None = None def get_critic_agent() -> CriticAgent: global _agent if _agent is None: _agent = CriticAgent() return _agent