Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions mcp-server/src/streamops_mcp/agent/monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,13 @@ async def run_cycle(self) -> IncidentReport | None:

self._log_confidence_distribution(diagnosis)

if self._all_claims_low_confidence(diagnosis):
# Fail-fast: verify the diagnosis is substantive before delegating to
# the Report agent, so it never synthesizes an incident from thin or
# degraded context (cert Ep 04 pattern). See issue #94.
gate_reason = self._verify_diagnosis_precondition(diagnosis)
if gate_reason is not None:
logger.warning(
"All %d claims are LOW or UNSOURCED confidence; "
"downgrading to warning-level log, skipping report agent",
len(diagnosis.claims),
"Skipping Report Agent, diagnosis not reportable: %s", gate_reason
)
return None

Expand Down Expand Up @@ -504,6 +506,26 @@ def _all_claims_low_confidence(diagnosis: DiagnosisReport) -> bool:
low_levels = {Confidence.LOW, Confidence.UNSOURCED}
return all(c.confidence in low_levels for c in diagnosis.claims)

def _verify_diagnosis_precondition(self, diagnosis: DiagnosisReport) -> str | None:
"""Check the diagnosis is substantive enough to delegate to the Report agent.

Returns a short reason string when the diagnosis is NOT fit to delegate,
or None when it is. Delegating a thin or degraded finding invites the
downstream Report agent to synthesize an incident from nothing (cert Ep 04
fail-fast delegation). Gated cases:
- a parse-error fallback diagnosis (unstructured, produced when the
Diagnostic agent's output could not be parsed),
- a diagnosis with no claims (no structured finding to report on),
- all claims LOW or UNSOURCED (no confident finding).
"""
if diagnosis.anomaly_type == "parse_error":
return "diagnosis is a parse-error fallback (unstructured)"
if not diagnosis.claims:
return "diagnosis has no claims to report on"
if self._all_claims_low_confidence(diagnosis):
return f"all {len(diagnosis.claims)} claims are LOW or UNSOURCED confidence"
return None

@staticmethod
def _extract_low_confidence_claims(diagnosis: DiagnosisReport) -> list[str]:
"""Extract claim text for LOW and UNSOURCED claims."""
Expand Down
74 changes: 74 additions & 0 deletions mcp-server/tests/test_monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1195,3 +1195,77 @@ async def fake_spawn(anomaly, hypothesis=None):

# Assert: 3 forks (the hypotheses warranted), not 5 (the cap)
assert len(seen) == 3


def _empty_diagnosis() -> DiagnosisReport:
"""A structurally valid diagnosis the Diagnostic agent produced with no claims."""
return DiagnosisReport(
anomaly_type="latency_spike",
detected_at="2026-07-09T12:00:00Z",
sources=[],
claims=[],
affected_components=[],
root_cause=RootCause(summary="inconclusive", confidence="low", reasoning="none"),
tools_used=[],
)


class TestPreDelegationGate:
"""The coordinator must not delegate a thin/degraded finding to the Report agent (Ep 04, issue #94)."""

def test_reportable_diagnosis_passes(self, agent):
# Arrange: a diagnosis with a HIGH-confidence claim
# Act
reason = agent._verify_diagnosis_precondition(_diag(confidence="HIGH"))

# Assert
assert reason is None

def test_no_claims_is_gated(self, agent):
# Arrange + Act
reason = agent._verify_diagnosis_precondition(_empty_diagnosis())

# Assert
assert reason is not None
assert "no claims" in reason

def test_parse_error_fallback_is_gated(self, agent):
# Arrange: the parse-error fallback shape (even if it somehow had a claim)
diagnosis = _diag(confidence="HIGH")
diagnosis.anomaly_type = "parse_error"

# Act
reason = agent._verify_diagnosis_precondition(diagnosis)

# Assert
assert reason is not None
assert "parse-error" in reason

def test_all_low_confidence_is_gated(self, agent):
# Arrange
# Act
reason = agent._verify_diagnosis_precondition(_diag(confidence="LOW"))

# Assert
assert reason is not None
assert "LOW or UNSOURCED" in reason

@pytest.mark.asyncio
async def test_run_cycle_does_not_delegate_empty_diagnosis(self, agent, monkeypatch):
# Arrange: detection fires, but the Diagnostic agent returns no claims
monkeypatch.setattr("streamops_mcp.agent.monitor.config.agent_diagnostic_forks", 1)
monkeypatch.setattr(
agent, "_detect_anomalies", AsyncMock(return_value=_anom("latency_spike"))
)
monkeypatch.setattr(agent, "_run_diagnostics", AsyncMock(return_value=_empty_diagnosis()))
spawn_report = AsyncMock()
monkeypatch.setattr(agent, "_spawn_report_agent", spawn_report)

# Act
with patch("streamops_mcp.agent.monitor.escalate", new_callable=AsyncMock) as mock_escalate:
result = await agent.run_cycle()

# Assert: fail-fast, the Report agent is never spawned and nothing escalates
assert result is None
spawn_report.assert_not_awaited()
mock_escalate.assert_not_awaited()
Loading