feat(aiops): enable read-only agent loop canary
This commit is contained in:
@@ -61,6 +61,16 @@ class Settings(BaseSettings):
|
||||
default=False,
|
||||
description="Phase 24: True=新 AIRouter 路由, False=舊 openclaw.py fallback chain",
|
||||
)
|
||||
ENABLE_OPENCLAW_AGENT_LOOP_SHADOW: bool = Field(
|
||||
default=False,
|
||||
description="ADR-105 P1: True=OpenClaw 生成 proposal 後用本地 Agent Loop 做 read-only shadow investigation, False=不執行",
|
||||
)
|
||||
OPENCLAW_AGENT_LOOP_MAX_ITERATIONS: int = Field(
|
||||
default=3,
|
||||
ge=1,
|
||||
le=5,
|
||||
description="ADR-105 P1: OpenClaw Agent Loop shadow 最大 tool_use 輪數",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# W1 PR-P1: Playbook 匹配 Feature Flag (2026-04-28 ogt + Claude Sonnet 4.6)
|
||||
|
||||
@@ -1709,6 +1709,153 @@ Focus on:
|
||||
# 2026-03-31 Claude Code: 統帥批准實作
|
||||
# =========================================================================
|
||||
|
||||
async def _maybe_run_openclaw_agent_loop_shadow(
|
||||
self,
|
||||
*,
|
||||
proposal: dict,
|
||||
incident_id: str,
|
||||
severity: str,
|
||||
signals: list[dict],
|
||||
affected_services: list[str],
|
||||
expert_context: dict | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
ADR-105 P1: read-only Agent Loop shadow investigation.
|
||||
|
||||
This is intentionally non-decisive: it proves MCP tool_use/audit wiring
|
||||
with local models and read-only tools, then falls back silently to the
|
||||
regular proposal when disabled or unavailable.
|
||||
"""
|
||||
if not settings.ENABLE_OPENCLAW_AGENT_LOOP_SHADOW:
|
||||
return
|
||||
|
||||
try:
|
||||
from src.plugins.mcp.registry import get_provider_registry
|
||||
from src.services.ai_providers.agent_loop import AgentToolExecutor
|
||||
from src.services.ai_providers.permissions import is_read_only_tool
|
||||
from src.services.ai_router import get_ai_registry
|
||||
|
||||
ai_registry = get_ai_registry()
|
||||
provider = ai_registry.get("ollama") or ai_registry.get("ollama_188")
|
||||
if provider is None or not hasattr(provider, "analyze_with_tools"):
|
||||
logger.warning(
|
||||
"openclaw_agent_loop_shadow_skipped",
|
||||
incident_id=incident_id,
|
||||
reason="no_local_tool_provider",
|
||||
)
|
||||
return
|
||||
|
||||
mcp_registry = get_provider_registry()
|
||||
providers = {p.name: p for p in mcp_registry.all()}
|
||||
allowed_servers = {"kubernetes", "prometheus", "signoz", "database", "rag", "grafana"}
|
||||
available_tools = []
|
||||
for mcp_provider in providers.values():
|
||||
if mcp_provider.name not in allowed_servers:
|
||||
continue
|
||||
try:
|
||||
provider_tools = await mcp_provider.list_tools()
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"openclaw_agent_loop_tool_list_failed",
|
||||
incident_id=incident_id,
|
||||
provider=mcp_provider.name,
|
||||
error=str(exc),
|
||||
)
|
||||
continue
|
||||
available_tools.extend(
|
||||
tool for tool in provider_tools
|
||||
if tool.server_name in allowed_servers and is_read_only_tool(tool)
|
||||
)
|
||||
|
||||
if not available_tools:
|
||||
logger.warning(
|
||||
"openclaw_agent_loop_shadow_skipped",
|
||||
incident_id=incident_id,
|
||||
reason="no_readonly_tools",
|
||||
)
|
||||
return
|
||||
|
||||
executor = AgentToolExecutor(
|
||||
available_tools=available_tools,
|
||||
providers=providers,
|
||||
agent_role="openclaw",
|
||||
incident_id=incident_id,
|
||||
flywheel_node="reason",
|
||||
)
|
||||
shadow_prompt = self._build_agent_loop_shadow_prompt(
|
||||
proposal=proposal,
|
||||
incident_id=incident_id,
|
||||
severity=severity,
|
||||
signals=signals,
|
||||
affected_services=affected_services,
|
||||
expert_context=expert_context,
|
||||
)
|
||||
result = await provider.analyze_with_tools(
|
||||
prompt=shadow_prompt,
|
||||
available_tools=available_tools,
|
||||
tool_executor=executor.execute,
|
||||
max_iterations=settings.OPENCLAW_AGENT_LOOP_MAX_ITERATIONS,
|
||||
agent_role="openclaw",
|
||||
context={
|
||||
"incident_id": incident_id,
|
||||
"severity": severity,
|
||||
"task_type": "diagnose",
|
||||
},
|
||||
)
|
||||
proposal["agent_loop_shadow"] = {
|
||||
"enabled": True,
|
||||
"success": result.success,
|
||||
"provider": result.provider,
|
||||
"tokens": result.tokens,
|
||||
"latency_ms": round(result.latency_ms, 1),
|
||||
"error": result.error,
|
||||
"preview": (result.raw_response or "")[:700],
|
||||
}
|
||||
logger.info(
|
||||
"openclaw_agent_loop_shadow_complete",
|
||||
incident_id=incident_id,
|
||||
provider=result.provider,
|
||||
success=result.success,
|
||||
tools_available=len(available_tools),
|
||||
latency_ms=round(result.latency_ms, 1),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"openclaw_agent_loop_shadow_failed",
|
||||
incident_id=incident_id,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
def _build_agent_loop_shadow_prompt(
|
||||
self,
|
||||
*,
|
||||
proposal: dict,
|
||||
incident_id: str,
|
||||
severity: str,
|
||||
signals: list[dict],
|
||||
affected_services: list[str],
|
||||
expert_context: dict | None = None,
|
||||
) -> str:
|
||||
"""Build a compact read-only investigation prompt for Agent Loop shadow mode."""
|
||||
return f"""你是 OpenClaw 的唯讀 shadow investigator。你可以使用 MCP 工具查證,但不得要求任何寫入、重啟、刪除或通知動作。
|
||||
|
||||
請只回傳 JSON:
|
||||
{{
|
||||
"root_cause_check": "你對目前根因的查證結論",
|
||||
"evidence_used": ["最多 5 條具體證據"],
|
||||
"confidence_delta": -0.1,
|
||||
"missing_evidence": ["還缺什麼證據"],
|
||||
"human_or_ai_next_step": "下一個安全步驟"
|
||||
}}
|
||||
|
||||
Incident: {incident_id}
|
||||
Severity: {severity}
|
||||
Affected services: {json.dumps(affected_services, ensure_ascii=False)}
|
||||
Signals: {json.dumps(signals[:5], ensure_ascii=False, default=str)}
|
||||
Current proposal: {json.dumps(proposal, ensure_ascii=False, default=str)}
|
||||
Expert context: {json.dumps(expert_context or {}, ensure_ascii=False, default=str)}
|
||||
"""
|
||||
|
||||
async def generate_incident_proposal_with_tools(
|
||||
self,
|
||||
incident_id: str,
|
||||
@@ -1762,6 +1909,15 @@ Focus on:
|
||||
if not success or proposal is None:
|
||||
return proposal, provider, success
|
||||
|
||||
await self._maybe_run_openclaw_agent_loop_shadow(
|
||||
proposal=proposal,
|
||||
incident_id=incident_id,
|
||||
severity=severity,
|
||||
signals=signals,
|
||||
affected_services=affected_services,
|
||||
expert_context=expert_context,
|
||||
)
|
||||
|
||||
# Step 2: 判斷是否需要 Nemotron
|
||||
risk_level = proposal.get("risk_level", "low").lower()
|
||||
if risk_level == "low":
|
||||
|
||||
@@ -2,6 +2,7 @@ import pytest
|
||||
|
||||
from src.plugins.mcp.interfaces import MCPTool, MCPToolProvider, MCPToolResult
|
||||
from src.plugins.mcp.registry import AuditedMCPToolProvider
|
||||
from src.services.ai_providers.interfaces import AIResult
|
||||
from src.services.ai_providers.agent_loop import AgentToolExecutor
|
||||
from src.services.ai_providers.permissions import filter_tools_for_agent, is_tool_allowed
|
||||
from src.services.ai_providers.tool_schema import (
|
||||
@@ -119,3 +120,72 @@ async def test_agent_tool_executor_blocks_disallowed_tool():
|
||||
assert result.success is False
|
||||
assert "not allowed" in (result.error or "")
|
||||
assert provider.calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openclaw_agent_loop_shadow_uses_read_only_tools(monkeypatch):
|
||||
from src.core.config import settings
|
||||
from src.services.openclaw import OpenClawService
|
||||
|
||||
monkeypatch.setattr(settings, "ENABLE_OPENCLAW_AGENT_LOOP_SHADOW", True)
|
||||
monkeypatch.setattr(settings, "OPENCLAW_AGENT_LOOP_MAX_ITERATIONS", 2)
|
||||
|
||||
class FakeAIProvider:
|
||||
def __init__(self):
|
||||
self.seen_tools = []
|
||||
|
||||
async def analyze_with_tools(
|
||||
self,
|
||||
prompt,
|
||||
available_tools,
|
||||
tool_executor,
|
||||
max_iterations=5,
|
||||
agent_role="openclaw",
|
||||
context=None,
|
||||
):
|
||||
self.seen_tools = available_tools
|
||||
return AIResult(
|
||||
raw_response='{"root_cause_check":"ok"}',
|
||||
success=True,
|
||||
provider="ollama_agent_loop",
|
||||
latency_ms=12.3,
|
||||
)
|
||||
|
||||
class FakeAIRegistry:
|
||||
def __init__(self, provider):
|
||||
self.provider = provider
|
||||
|
||||
def get(self, name):
|
||||
return self.provider if name == "ollama" else None
|
||||
|
||||
class FakeMCPRegistry:
|
||||
def all(self):
|
||||
return [FakeProvider("database"), FakeProvider("ssh_host")]
|
||||
|
||||
async def fake_list_tools(self):
|
||||
if self.name == "database":
|
||||
return [
|
||||
_tool("database", "list_incidents"),
|
||||
_tool("database", "execute_sql"),
|
||||
]
|
||||
return [_tool("ssh_host", "run_command")]
|
||||
|
||||
fake_ai_provider = FakeAIProvider()
|
||||
monkeypatch.setattr(FakeProvider, "list_tools", fake_list_tools)
|
||||
monkeypatch.setattr("src.services.ai_router.get_ai_registry", lambda: FakeAIRegistry(fake_ai_provider))
|
||||
monkeypatch.setattr("src.plugins.mcp.registry.get_provider_registry", lambda: FakeMCPRegistry())
|
||||
|
||||
service = object.__new__(OpenClawService)
|
||||
proposal = {"risk_level": "medium", "action": "investigate"}
|
||||
|
||||
await service._maybe_run_openclaw_agent_loop_shadow(
|
||||
proposal=proposal,
|
||||
incident_id="INC-1",
|
||||
severity="P2",
|
||||
signals=[{"alertname": "ApiErrorRateHigh"}],
|
||||
affected_services=["awoooi-api"],
|
||||
expert_context={"source": "test"},
|
||||
)
|
||||
|
||||
assert proposal["agent_loop_shadow"]["success"] is True
|
||||
assert [tool.name for tool in fake_ai_provider.seen_tools] == ["list_incidents"]
|
||||
|
||||
Reference in New Issue
Block a user