feat(aiops): structure agent loop shadow output
This commit is contained in:
@@ -1802,6 +1802,7 @@ Focus on:
|
||||
"task_type": "diagnose",
|
||||
},
|
||||
)
|
||||
structured_shadow = self._parse_agent_loop_shadow_response(result.raw_response or "")
|
||||
proposal["agent_loop_shadow"] = {
|
||||
"enabled": True,
|
||||
"success": result.success,
|
||||
@@ -1809,6 +1810,9 @@ Focus on:
|
||||
"tokens": result.tokens,
|
||||
"latency_ms": round(result.latency_ms, 1),
|
||||
"error": result.error,
|
||||
"decision_impact": "none",
|
||||
"structured": structured_shadow,
|
||||
"confidence_delta": structured_shadow.get("confidence_delta", 0.0),
|
||||
"preview": (result.raw_response or "")[:700],
|
||||
}
|
||||
logger.info(
|
||||
@@ -1818,6 +1822,8 @@ Focus on:
|
||||
success=result.success,
|
||||
tools_available=len(available_tools),
|
||||
latency_ms=round(result.latency_ms, 1),
|
||||
confidence_delta=structured_shadow.get("confidence_delta", 0.0),
|
||||
parse_status=structured_shadow.get("parse_status"),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
@@ -1826,6 +1832,106 @@ Focus on:
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _parse_agent_loop_shadow_response(cls, raw_response: str) -> dict:
|
||||
"""
|
||||
Normalize read-only Agent Loop output into durable metadata.
|
||||
|
||||
The shadow result is intentionally non-decisive. Downstream code can
|
||||
inspect this structure for quality review, but it must not override the
|
||||
main proposal until ADR-105 canary graduation.
|
||||
"""
|
||||
text = (raw_response or "").strip()
|
||||
if not text:
|
||||
return {
|
||||
"parse_status": "empty",
|
||||
"root_cause_check": "",
|
||||
"evidence_used": [],
|
||||
"confidence_delta": 0.0,
|
||||
"missing_evidence": [],
|
||||
"human_or_ai_next_step": "",
|
||||
}
|
||||
|
||||
payload = cls._extract_json_object(text)
|
||||
if not isinstance(payload, dict):
|
||||
return {
|
||||
"parse_status": "unparsed",
|
||||
"root_cause_check": "",
|
||||
"evidence_used": [],
|
||||
"confidence_delta": 0.0,
|
||||
"missing_evidence": [],
|
||||
"human_or_ai_next_step": "",
|
||||
"raw_preview": text[:700],
|
||||
}
|
||||
|
||||
return {
|
||||
"parse_status": "ok",
|
||||
"root_cause_check": cls._clip_shadow_text(payload.get("root_cause_check"), max_chars=500),
|
||||
"evidence_used": cls._coerce_shadow_list(payload.get("evidence_used"), max_items=5),
|
||||
"confidence_delta": cls._coerce_agent_loop_confidence_delta(
|
||||
payload.get("confidence_delta", 0.0)
|
||||
),
|
||||
"missing_evidence": cls._coerce_shadow_list(payload.get("missing_evidence"), max_items=5),
|
||||
"human_or_ai_next_step": cls._clip_shadow_text(
|
||||
payload.get("human_or_ai_next_step"), max_chars=500
|
||||
),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _extract_json_object(text: str) -> dict | None:
|
||||
"""Extract the first JSON object from plain or fenced LLM output."""
|
||||
candidates = [text]
|
||||
fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, flags=re.DOTALL | re.IGNORECASE)
|
||||
if fenced:
|
||||
candidates.insert(0, fenced.group(1))
|
||||
object_match = re.search(r"\{.*\}", text, flags=re.DOTALL)
|
||||
if object_match:
|
||||
candidates.append(object_match.group(0))
|
||||
|
||||
for candidate in candidates:
|
||||
try:
|
||||
parsed = json.loads(candidate)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
continue
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _clip_shadow_text(value: object, *, max_chars: int) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()[:max_chars]
|
||||
|
||||
@classmethod
|
||||
def _coerce_shadow_list(cls, value: object, *, max_items: int) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
items = value
|
||||
else:
|
||||
items = [value]
|
||||
normalized = []
|
||||
for item in items:
|
||||
clipped = cls._clip_shadow_text(item, max_chars=240)
|
||||
if clipped:
|
||||
normalized.append(clipped)
|
||||
if len(normalized) >= max_items:
|
||||
break
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _coerce_agent_loop_confidence_delta(value: object) -> float:
|
||||
"""
|
||||
Keep canary deltas conservative: metadata may lower confidence later,
|
||||
but positive boosts are recorded as 0 until the shadow path graduates.
|
||||
"""
|
||||
try:
|
||||
delta = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
return round(max(min(delta, 0.0), -0.15), 3)
|
||||
|
||||
def _build_agent_loop_shadow_prompt(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -2,9 +2,12 @@ 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.interfaces import AIResult
|
||||
from src.services.ai_providers.permissions import (
|
||||
filter_tools_for_agent,
|
||||
is_tool_allowed,
|
||||
)
|
||||
from src.services.ai_providers.tool_schema import (
|
||||
anthropic_tool_schema,
|
||||
openai_tool_schema,
|
||||
@@ -69,6 +72,38 @@ def test_tool_schema_round_trips_provider_safe_names():
|
||||
assert tool_by_provider_name([tool], safe_name) is tool
|
||||
|
||||
|
||||
def test_openclaw_agent_loop_shadow_parser_normalizes_json():
|
||||
from src.services.openclaw import OpenClawService
|
||||
|
||||
raw = """```json
|
||||
{
|
||||
"root_cause_check": "current RCA still needs pod evidence",
|
||||
"evidence_used": ["event spike", "error rate"],
|
||||
"confidence_delta": -0.42,
|
||||
"missing_evidence": ["deployment rollout history"],
|
||||
"human_or_ai_next_step": "query rollout history with read-only tools"
|
||||
}
|
||||
```"""
|
||||
|
||||
parsed = OpenClawService._parse_agent_loop_shadow_response(raw)
|
||||
|
||||
assert parsed["parse_status"] == "ok"
|
||||
assert parsed["root_cause_check"] == "current RCA still needs pod evidence"
|
||||
assert parsed["evidence_used"] == ["event spike", "error rate"]
|
||||
assert parsed["confidence_delta"] == -0.15
|
||||
assert parsed["missing_evidence"] == ["deployment rollout history"]
|
||||
|
||||
|
||||
def test_openclaw_agent_loop_shadow_parser_never_boosts_confidence():
|
||||
from src.services.openclaw import OpenClawService
|
||||
|
||||
parsed = OpenClawService._parse_agent_loop_shadow_response(
|
||||
'{"root_cause_check":"looks good","confidence_delta":0.2}'
|
||||
)
|
||||
|
||||
assert parsed["confidence_delta"] == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audited_provider_strips_internal_audit_context(monkeypatch):
|
||||
audit_calls = []
|
||||
@@ -188,4 +223,7 @@ async def test_openclaw_agent_loop_shadow_uses_read_only_tools(monkeypatch):
|
||||
)
|
||||
|
||||
assert proposal["agent_loop_shadow"]["success"] is True
|
||||
assert proposal["agent_loop_shadow"]["decision_impact"] == "none"
|
||||
assert proposal["agent_loop_shadow"]["structured"]["parse_status"] == "ok"
|
||||
assert proposal["agent_loop_shadow"]["structured"]["root_cause_check"] == "ok"
|
||||
assert [tool.name for tool in fake_ai_provider.seen_tools] == ["list_incidents"]
|
||||
|
||||
Reference in New Issue
Block a user