""" 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 hashlib 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.services.sanitization_service import sanitize logger = structlog.get_logger(__name__) # Critic 挑戰數量上限(防止 LLM 生成無限質疑) MAX_CHALLENGES = 5 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, }) from src.services.openclaw import get_openclaw openclaw = get_openclaw() response_text, _provider, success = await openclaw.call(prompt) 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