nthlayer-workers 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nthlayer_workers/__init__.py +5 -0
- nthlayer_workers/cli.py +234 -0
- nthlayer_workers/correlate/__init__.py +1 -0
- nthlayer_workers/correlate/cli.py +847 -0
- nthlayer_workers/correlate/config.py +111 -0
- nthlayer_workers/correlate/correlation/__init__.py +1 -0
- nthlayer_workers/correlate/correlation/changes.py +87 -0
- nthlayer_workers/correlate/correlation/dedup.py +62 -0
- nthlayer_workers/correlate/correlation/engine.py +244 -0
- nthlayer_workers/correlate/correlation/temporal.py +79 -0
- nthlayer_workers/correlate/correlation/topology.py +104 -0
- nthlayer_workers/correlate/ingestion/__init__.py +1 -0
- nthlayer_workers/correlate/ingestion/protocol.py +10 -0
- nthlayer_workers/correlate/ingestion/severity.py +18 -0
- nthlayer_workers/correlate/ingestion/webhook.py +197 -0
- nthlayer_workers/correlate/notifications.py +85 -0
- nthlayer_workers/correlate/prometheus.py +234 -0
- nthlayer_workers/correlate/reasoning.py +375 -0
- nthlayer_workers/correlate/session.py +189 -0
- nthlayer_workers/correlate/snapshot/__init__.py +1 -0
- nthlayer_workers/correlate/snapshot/generator.py +170 -0
- nthlayer_workers/correlate/snapshot/model.py +177 -0
- nthlayer_workers/correlate/snapshot/token.py +14 -0
- nthlayer_workers/correlate/state.py +88 -0
- nthlayer_workers/correlate/store/__init__.py +5 -0
- nthlayer_workers/correlate/store/protocol.py +48 -0
- nthlayer_workers/correlate/store/sqlite.py +443 -0
- nthlayer_workers/correlate/summary.py +180 -0
- nthlayer_workers/correlate/traces/__init__.py +1 -0
- nthlayer_workers/correlate/traces/protocol.py +120 -0
- nthlayer_workers/correlate/traces/tempo.py +667 -0
- nthlayer_workers/correlate/traces/topology.py +39 -0
- nthlayer_workers/correlate/types.py +77 -0
- nthlayer_workers/correlate/worker.py +630 -0
- nthlayer_workers/learn/__init__.py +5 -0
- nthlayer_workers/learn/__main__.py +5 -0
- nthlayer_workers/learn/cli.py +164 -0
- nthlayer_workers/learn/retrospective.py +381 -0
- nthlayer_workers/learn/trends.py +102 -0
- nthlayer_workers/learn/worker.py +366 -0
- nthlayer_workers/measure/__init__.py +3 -0
- nthlayer_workers/measure/__main__.py +5 -0
- nthlayer_workers/measure/_parsing.py +15 -0
- nthlayer_workers/measure/adapters/__init__.py +0 -0
- nthlayer_workers/measure/adapters/_util.py +24 -0
- nthlayer_workers/measure/adapters/devin.py +119 -0
- nthlayer_workers/measure/adapters/gastown.py +88 -0
- nthlayer_workers/measure/adapters/prometheus.py +277 -0
- nthlayer_workers/measure/adapters/protocol.py +20 -0
- nthlayer_workers/measure/adapters/webhook.py +161 -0
- nthlayer_workers/measure/api/__init__.py +0 -0
- nthlayer_workers/measure/api/normalise.py +50 -0
- nthlayer_workers/measure/api/queue.py +243 -0
- nthlayer_workers/measure/api/response.py +51 -0
- nthlayer_workers/measure/api/server.py +504 -0
- nthlayer_workers/measure/calibration/__init__.py +0 -0
- nthlayer_workers/measure/calibration/loop.py +62 -0
- nthlayer_workers/measure/calibration/slos.py +212 -0
- nthlayer_workers/measure/calibration/verdict_calibration.py +31 -0
- nthlayer_workers/measure/cli.py +753 -0
- nthlayer_workers/measure/config.py +191 -0
- nthlayer_workers/measure/detection/__init__.py +6 -0
- nthlayer_workers/measure/detection/detector.py +82 -0
- nthlayer_workers/measure/detection/protocol.py +29 -0
- nthlayer_workers/measure/governance/__init__.py +0 -0
- nthlayer_workers/measure/governance/engine.py +163 -0
- nthlayer_workers/measure/manifest.py +77 -0
- nthlayer_workers/measure/notifications.py +53 -0
- nthlayer_workers/measure/pipeline/__init__.py +0 -0
- nthlayer_workers/measure/pipeline/evaluator.py +155 -0
- nthlayer_workers/measure/pipeline/router.py +160 -0
- nthlayer_workers/measure/store/__init__.py +0 -0
- nthlayer_workers/measure/store/protocol.py +38 -0
- nthlayer_workers/measure/store/sqlite.py +276 -0
- nthlayer_workers/measure/telemetry.py +116 -0
- nthlayer_workers/measure/tiering/__init__.py +0 -0
- nthlayer_workers/measure/tiering/classifier.py +58 -0
- nthlayer_workers/measure/tiering/promotion.py +118 -0
- nthlayer_workers/measure/trends/__init__.py +0 -0
- nthlayer_workers/measure/trends/tracker.py +72 -0
- nthlayer_workers/measure/types.py +75 -0
- nthlayer_workers/measure/worker.py +439 -0
- nthlayer_workers/observe/__init__.py +25 -0
- nthlayer_workers/observe/__main__.py +5 -0
- nthlayer_workers/observe/api/__init__.py +1 -0
- nthlayer_workers/observe/assessment.py +95 -0
- nthlayer_workers/observe/cli.py +737 -0
- nthlayer_workers/observe/config.py +11 -0
- nthlayer_workers/observe/db/__init__.py +1 -0
- nthlayer_workers/observe/decision_records.py +220 -0
- nthlayer_workers/observe/dependencies/__init__.py +18 -0
- nthlayer_workers/observe/dependencies/discovery.py +294 -0
- nthlayer_workers/observe/dependencies/providers/__init__.py +48 -0
- nthlayer_workers/observe/dependencies/providers/backstage.py +467 -0
- nthlayer_workers/observe/dependencies/providers/base.py +76 -0
- nthlayer_workers/observe/dependencies/providers/consul.py +518 -0
- nthlayer_workers/observe/dependencies/providers/etcd.py +360 -0
- nthlayer_workers/observe/dependencies/providers/kubernetes.py +682 -0
- nthlayer_workers/observe/dependencies/providers/prometheus.py +368 -0
- nthlayer_workers/observe/dependencies/providers/zookeeper.py +399 -0
- nthlayer_workers/observe/deployments/__init__.py +1 -0
- nthlayer_workers/observe/discovery/__init__.py +14 -0
- nthlayer_workers/observe/discovery/classifier.py +66 -0
- nthlayer_workers/observe/discovery/client.py +189 -0
- nthlayer_workers/observe/discovery/models.py +53 -0
- nthlayer_workers/observe/drift/__init__.py +26 -0
- nthlayer_workers/observe/drift/analyzer.py +383 -0
- nthlayer_workers/observe/drift/models.py +174 -0
- nthlayer_workers/observe/drift/patterns.py +88 -0
- nthlayer_workers/observe/explanation.py +118 -0
- nthlayer_workers/observe/gate/__init__.py +39 -0
- nthlayer_workers/observe/gate/conditions.py +92 -0
- nthlayer_workers/observe/gate/correlator.py +154 -0
- nthlayer_workers/observe/gate/evaluator.py +192 -0
- nthlayer_workers/observe/gate/policies.py +226 -0
- nthlayer_workers/observe/gate_adapter.py +40 -0
- nthlayer_workers/observe/incident.py +36 -0
- nthlayer_workers/observe/portfolio/__init__.py +17 -0
- nthlayer_workers/observe/portfolio/aggregator.py +168 -0
- nthlayer_workers/observe/portfolio/scorer.py +13 -0
- nthlayer_workers/observe/slo/__init__.py +19 -0
- nthlayer_workers/observe/slo/collector.py +235 -0
- nthlayer_workers/observe/slo/spec_loader.py +40 -0
- nthlayer_workers/observe/sqlite_store.py +152 -0
- nthlayer_workers/observe/store.py +92 -0
- nthlayer_workers/observe/verification/__init__.py +22 -0
- nthlayer_workers/observe/verification/exporter_guidance.py +146 -0
- nthlayer_workers/observe/verification/extractor.py +127 -0
- nthlayer_workers/observe/verification/models.py +101 -0
- nthlayer_workers/observe/verification/verifier.py +111 -0
- nthlayer_workers/observe/worker.py +332 -0
- nthlayer_workers/respond/__init__.py +2 -0
- nthlayer_workers/respond/__main__.py +4 -0
- nthlayer_workers/respond/agents/__init__.py +0 -0
- nthlayer_workers/respond/agents/base.py +556 -0
- nthlayer_workers/respond/agents/communication.py +115 -0
- nthlayer_workers/respond/agents/investigation.py +124 -0
- nthlayer_workers/respond/agents/remediation.py +219 -0
- nthlayer_workers/respond/agents/triage.py +132 -0
- nthlayer_workers/respond/cli.py +772 -0
- nthlayer_workers/respond/config.py +135 -0
- nthlayer_workers/respond/context_store.py +256 -0
- nthlayer_workers/respond/coordinator.py +487 -0
- nthlayer_workers/respond/metrics.py +104 -0
- nthlayer_workers/respond/notification_backends/__init__.py +1 -0
- nthlayer_workers/respond/notification_backends/ntfy_backend.py +158 -0
- nthlayer_workers/respond/notification_backends/protocol.py +59 -0
- nthlayer_workers/respond/notification_backends/slack_backend.py +203 -0
- nthlayer_workers/respond/notification_backends/stdout_backend.py +56 -0
- nthlayer_workers/respond/notifications.py +247 -0
- nthlayer_workers/respond/oncall/__init__.py +1 -0
- nthlayer_workers/respond/oncall/escalation.py +103 -0
- nthlayer_workers/respond/oncall/runner.py +193 -0
- nthlayer_workers/respond/oncall/schedule.py +243 -0
- nthlayer_workers/respond/safe_actions/__init__.py +0 -0
- nthlayer_workers/respond/safe_actions/actions.py +139 -0
- nthlayer_workers/respond/safe_actions/registry.py +171 -0
- nthlayer_workers/respond/safe_actions/webhook.py +194 -0
- nthlayer_workers/respond/server.py +357 -0
- nthlayer_workers/respond/sre/__init__.py +1 -0
- nthlayer_workers/respond/sre/brief.py +175 -0
- nthlayer_workers/respond/sre/delegation.py +101 -0
- nthlayer_workers/respond/sre/post_incident.py +146 -0
- nthlayer_workers/respond/sre/shift_report.py +129 -0
- nthlayer_workers/respond/sre/suppression.py +91 -0
- nthlayer_workers/respond/types.py +109 -0
- nthlayer_workers/respond/verdict_submission.py +56 -0
- nthlayer_workers/respond/worker.py +533 -0
- nthlayer_workers/respond/worker_helpers.py +140 -0
- nthlayer_workers/runner.py +198 -0
- nthlayer_workers-1.0.0.dist-info/METADATA +19 -0
- nthlayer_workers-1.0.0.dist-info/RECORD +175 -0
- nthlayer_workers-1.0.0.dist-info/WHEEL +5 -0
- nthlayer_workers-1.0.0.dist-info/entry_points.txt +2 -0
- nthlayer_workers-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
# src/nthlayer_respond/agents/base.py
|
|
2
|
+
"""AgentBase ABC — ZFC boundary: transport here, judgment in subclasses."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import urllib.request
|
|
9
|
+
import urllib.error
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import structlog
|
|
14
|
+
from nthlayer_common.verdicts import create as verdict_create, Verdict
|
|
15
|
+
from nthlayer_workers.respond.types import AgentRole, IncidentContext
|
|
16
|
+
|
|
17
|
+
log = structlog.get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AgentBase(ABC):
|
|
21
|
+
"""Abstract base class for all Mayday agents.
|
|
22
|
+
|
|
23
|
+
Transport concerns (model calls, verdict emission, HTTP governance
|
|
24
|
+
requests) live here. Judgment concerns (prompt construction, response
|
|
25
|
+
parsing, result application) are abstract and implemented by subclasses.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
# Subclasses MUST declare these class-level attributes.
|
|
29
|
+
role: AgentRole
|
|
30
|
+
default_timeout: int = 30
|
|
31
|
+
|
|
32
|
+
# ------------------------------------------------------------------ #
|
|
33
|
+
# Construction #
|
|
34
|
+
# ------------------------------------------------------------------ #
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
model: str,
|
|
39
|
+
max_tokens: int,
|
|
40
|
+
verdict_store: Any | None = None,
|
|
41
|
+
config: dict | None = None,
|
|
42
|
+
timeout: int | None = None,
|
|
43
|
+
decision_store: Any | None = None,
|
|
44
|
+
*,
|
|
45
|
+
client: Any | None = None, # P3-E.1 worker mode: CoreAPIClient
|
|
46
|
+
deployment_id: str | None = None, # P3-E.1 worker mode
|
|
47
|
+
) -> None:
|
|
48
|
+
self._model = model
|
|
49
|
+
self._max_tokens = max_tokens
|
|
50
|
+
self._verdict_store = verdict_store
|
|
51
|
+
self._config = config if config is not None else {}
|
|
52
|
+
self._timeout = timeout if timeout is not None else self.default_timeout
|
|
53
|
+
self._decision_store = decision_store
|
|
54
|
+
self._client = client
|
|
55
|
+
self._deployment_id = deployment_id
|
|
56
|
+
if self._client is None and self._verdict_store is None:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"{type(self).__name__}: must configure exactly one of "
|
|
59
|
+
"client (worker mode) or verdict_store (legacy CLI mode)"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------------ #
|
|
63
|
+
# Transport: model #
|
|
64
|
+
# ------------------------------------------------------------------ #
|
|
65
|
+
|
|
66
|
+
async def _call_model(self, system_prompt: str, user_prompt: str) -> str:
|
|
67
|
+
"""Call the LLM via the shared nthlayer-common wrapper.
|
|
68
|
+
|
|
69
|
+
Uses asyncio.to_thread for the sync httpx call, wrapped in
|
|
70
|
+
asyncio.wait_for for timeout enforcement.
|
|
71
|
+
"""
|
|
72
|
+
from nthlayer_common.llm import llm_call
|
|
73
|
+
|
|
74
|
+
result = await asyncio.wait_for(
|
|
75
|
+
asyncio.to_thread(
|
|
76
|
+
llm_call,
|
|
77
|
+
system=system_prompt,
|
|
78
|
+
user=user_prompt,
|
|
79
|
+
model=self._model,
|
|
80
|
+
max_tokens=self._max_tokens,
|
|
81
|
+
timeout=self._timeout,
|
|
82
|
+
),
|
|
83
|
+
timeout=self._timeout,
|
|
84
|
+
)
|
|
85
|
+
return result.text
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------ #
|
|
88
|
+
# Transport: verdict emission #
|
|
89
|
+
# ------------------------------------------------------------------ #
|
|
90
|
+
|
|
91
|
+
async def _emit_verdict(
|
|
92
|
+
self,
|
|
93
|
+
context: IncidentContext,
|
|
94
|
+
subject_summary: str,
|
|
95
|
+
action: str,
|
|
96
|
+
confidence: float,
|
|
97
|
+
reasoning: str,
|
|
98
|
+
tags: list[str] | None = None,
|
|
99
|
+
dimensions: dict | None = None,
|
|
100
|
+
) -> Verdict:
|
|
101
|
+
"""Create, wire lineage, persist, and register a verdict.
|
|
102
|
+
|
|
103
|
+
P3-E.1: async. Persists via either a CoreAPIClient (worker mode) or
|
|
104
|
+
a local SQLiteVerdictStore (legacy CLI mode). Exactly one is configured
|
|
105
|
+
at construction time. Lineage and parent_ids are wired before persist;
|
|
106
|
+
escalation flag is captured at write-time on the IncidentContext.
|
|
107
|
+
"""
|
|
108
|
+
judgment: dict[str, Any] = {
|
|
109
|
+
"action": action,
|
|
110
|
+
"confidence": confidence,
|
|
111
|
+
"reasoning": reasoning,
|
|
112
|
+
}
|
|
113
|
+
if tags is not None:
|
|
114
|
+
judgment["tags"] = tags
|
|
115
|
+
if dimensions is not None:
|
|
116
|
+
judgment["dimensions"] = dimensions
|
|
117
|
+
|
|
118
|
+
v = verdict_create(
|
|
119
|
+
subject={
|
|
120
|
+
"type": self.role.value,
|
|
121
|
+
"ref": context.id,
|
|
122
|
+
"summary": subject_summary,
|
|
123
|
+
},
|
|
124
|
+
judgment=judgment,
|
|
125
|
+
producer={"system": "nthlayer-respond", "model": self._model},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Wire lineage (existing) + parent_ids (P3-E.1: cross-module ancestry)
|
|
129
|
+
v.lineage.context = list(context.trigger_verdict_ids)
|
|
130
|
+
if context.verdict_chain:
|
|
131
|
+
v.lineage.parent = context.verdict_chain[-1]
|
|
132
|
+
v.parent_ids = [context.verdict_chain[-1]]
|
|
133
|
+
else:
|
|
134
|
+
v.lineage.parent = None
|
|
135
|
+
v.parent_ids = list(context.trigger_verdict_ids)
|
|
136
|
+
|
|
137
|
+
# P3-E.1: capture-at-write-time escalation flag. Set on the in-memory
|
|
138
|
+
# context regardless of whether the verdict reaches core — the agent's
|
|
139
|
+
# judgment to escalate is local state and must drive the gate even if
|
|
140
|
+
# the persistence layer is degraded. Operators can correlate via the
|
|
141
|
+
# respond_verdict_submit_failed log + errors_total metric.
|
|
142
|
+
if not isinstance(self._config, dict):
|
|
143
|
+
raise RuntimeError(
|
|
144
|
+
f"{type(self).__name__}: agent config must be a dict, got {type(self._config).__name__}"
|
|
145
|
+
)
|
|
146
|
+
threshold = self._config.get("escalation_threshold", 0.0)
|
|
147
|
+
if (
|
|
148
|
+
v.judgment.action == "escalate"
|
|
149
|
+
and v.judgment.confidence < threshold
|
|
150
|
+
):
|
|
151
|
+
context.metadata["escalation_pending"] = True
|
|
152
|
+
|
|
153
|
+
# Persist via configured backend (exactly one is set; checked at __init__).
|
|
154
|
+
# Worker mode: only append to verdict_chain on successful submission so
|
|
155
|
+
# the in-memory chain stays consistent with core's lineage graph. A
|
|
156
|
+
# failed submission must not produce a dangling parent_ids reference
|
|
157
|
+
# for the next verdict; the next verdict will instead chain to the
|
|
158
|
+
# previous successfully-submitted verdict (or to the trigger if none).
|
|
159
|
+
if self._client is not None:
|
|
160
|
+
from nthlayer_workers.respond.verdict_submission import submit_verdict_to_core
|
|
161
|
+
ok = await submit_verdict_to_core(self._client, v, deployment_id=self._deployment_id)
|
|
162
|
+
if not ok:
|
|
163
|
+
return v # not appended; next verdict chains to last successful predecessor
|
|
164
|
+
else:
|
|
165
|
+
self._verdict_store.put(v)
|
|
166
|
+
context.verdict_chain.append(v.id)
|
|
167
|
+
|
|
168
|
+
return v
|
|
169
|
+
|
|
170
|
+
def _write_decision_verdict(
|
|
171
|
+
self,
|
|
172
|
+
context: IncidentContext,
|
|
173
|
+
verdict: Verdict,
|
|
174
|
+
system_prompt: str,
|
|
175
|
+
user_prompt: str,
|
|
176
|
+
response_text: str,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Write a content-addressed Verdict record if decision_store is configured.
|
|
179
|
+
|
|
180
|
+
The ``verdict`` parameter is an ``nthlayer_learn.models.Verdict`` (dataclass).
|
|
181
|
+
Uses the shared ``write_decision_verdict`` helper for chain management and retry.
|
|
182
|
+
"""
|
|
183
|
+
if self._decision_store is None:
|
|
184
|
+
return
|
|
185
|
+
from nthlayer_common.records.verdict_bridge import write_decision_verdict
|
|
186
|
+
|
|
187
|
+
write_decision_verdict(
|
|
188
|
+
self._decision_store,
|
|
189
|
+
agent=self.role.value,
|
|
190
|
+
incident_id=context.id,
|
|
191
|
+
timestamp=verdict.timestamp,
|
|
192
|
+
model=self._model,
|
|
193
|
+
reasoning=getattr(verdict.judgment, "reasoning", "") or "",
|
|
194
|
+
action={"action": getattr(verdict.judgment, "action", ""), "subject_type": getattr(verdict.subject, "type", "")},
|
|
195
|
+
prompt_text=system_prompt + "\n" + user_prompt,
|
|
196
|
+
response_text=response_text,
|
|
197
|
+
summaries_technical=f"{self.role.value}: {(getattr(verdict.subject, 'summary', '') or '')[:250]}",
|
|
198
|
+
summaries_plain=(getattr(verdict.subject, "summary", "") or "")[:280],
|
|
199
|
+
summaries_executive=f"{self.role.value} verdict emitted",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def _build_service_context_prompt(self, context: IncidentContext) -> str:
|
|
203
|
+
"""Build a service context section for agent prompts from OpenSRM spec
|
|
204
|
+
and evaluation verdict data. This gives agents the domain context they
|
|
205
|
+
need to reason correctly about AI vs infrastructure failures."""
|
|
206
|
+
meta = getattr(context, "metadata", {}) or {}
|
|
207
|
+
svc_ctx = meta.get("service_context", {})
|
|
208
|
+
if not svc_ctx:
|
|
209
|
+
return ""
|
|
210
|
+
|
|
211
|
+
lines = ["\nService context:"]
|
|
212
|
+
service = svc_ctx.get("service", "unknown")
|
|
213
|
+
svc_type = svc_ctx.get("service_type", "unknown")
|
|
214
|
+
is_ai = svc_ctx.get("is_ai_gate", False)
|
|
215
|
+
spec = svc_ctx.get("spec", {})
|
|
216
|
+
ev = svc_ctx.get("evaluation", {})
|
|
217
|
+
|
|
218
|
+
type_desc = "AI decision service" if is_ai else "traditional service"
|
|
219
|
+
lines.append(f"- Service: {service}")
|
|
220
|
+
lines.append(f"- Type: {svc_type} ({type_desc})")
|
|
221
|
+
|
|
222
|
+
if spec.get("tier"):
|
|
223
|
+
lines.append(f"- Tier: {spec['tier']}")
|
|
224
|
+
if spec.get("team"):
|
|
225
|
+
lines.append(f"- Team: {spec['team']}")
|
|
226
|
+
|
|
227
|
+
# SLO breach details from evaluation verdict
|
|
228
|
+
if ev.get("slo_name"):
|
|
229
|
+
slo_name = ev["slo_name"]
|
|
230
|
+
slo_type = ev.get("slo_type", "unknown")
|
|
231
|
+
target = ev.get("target")
|
|
232
|
+
current = ev.get("current_value")
|
|
233
|
+
slo_desc = "measures decision quality, not infrastructure health" if slo_type == "judgment" else "measures infrastructure reliability"
|
|
234
|
+
lines.append(f"- Breached SLO: {slo_name} ({slo_type.upper()} SLO — {slo_desc})")
|
|
235
|
+
if current is not None and target is not None:
|
|
236
|
+
lines.append(f"- Current value: {current} (target: < {target})")
|
|
237
|
+
|
|
238
|
+
# SLO definitions from spec
|
|
239
|
+
slos = spec.get("slos", {})
|
|
240
|
+
if slos:
|
|
241
|
+
lines.append(f"- Declared SLOs: {', '.join(slos.keys())}")
|
|
242
|
+
|
|
243
|
+
# Remediation guidance based on service type
|
|
244
|
+
if is_ai:
|
|
245
|
+
lines.append("- This is NOT an infrastructure issue. This is an AI model quality issue.")
|
|
246
|
+
lines.append("- Appropriate remediation: model rollback, canary revert, autonomy reduction")
|
|
247
|
+
lines.append("- Inappropriate remediation: scale_up, restart, increase resources")
|
|
248
|
+
else:
|
|
249
|
+
lines.append("- This is an infrastructure/availability issue.")
|
|
250
|
+
lines.append("- Appropriate remediation: rollback, scale_up, restart, feature flag disable")
|
|
251
|
+
|
|
252
|
+
return "\n".join(lines)
|
|
253
|
+
|
|
254
|
+
def _prune_topology(self, topology: dict, relevant_services: list[str]) -> dict:
|
|
255
|
+
"""Prune topology to relevant services + 1 hop of dependencies.
|
|
256
|
+
|
|
257
|
+
Reduces prompt token cost by excluding services not in the blast radius
|
|
258
|
+
or its immediate neighbourhood.
|
|
259
|
+
"""
|
|
260
|
+
if not topology or not relevant_services:
|
|
261
|
+
return topology
|
|
262
|
+
|
|
263
|
+
services = topology.get("services", [])
|
|
264
|
+
if not services:
|
|
265
|
+
return topology
|
|
266
|
+
|
|
267
|
+
relevant = set(relevant_services)
|
|
268
|
+
|
|
269
|
+
# Add 1 hop: dependencies and dependents of relevant services
|
|
270
|
+
for svc in services:
|
|
271
|
+
name = svc.get("name", "")
|
|
272
|
+
if name in relevant:
|
|
273
|
+
for dep in svc.get("dependencies", []):
|
|
274
|
+
dep_name = dep.get("name", dep) if isinstance(dep, dict) else dep
|
|
275
|
+
relevant.add(dep_name)
|
|
276
|
+
|
|
277
|
+
pruned = [s for s in services if s.get("name", "") in relevant]
|
|
278
|
+
return {**topology, "services": pruned}
|
|
279
|
+
|
|
280
|
+
def _build_summary(self, context: IncidentContext, result) -> str:
|
|
281
|
+
"""Build summary strictly from the agent's actual LLM output.
|
|
282
|
+
|
|
283
|
+
Every field must be traceable to the agent's response.
|
|
284
|
+
No template-generated content that impersonates agent reasoning.
|
|
285
|
+
"""
|
|
286
|
+
role = self.role.value
|
|
287
|
+
reasoning = getattr(result, "reasoning", None) or ""
|
|
288
|
+
|
|
289
|
+
if role == "triage":
|
|
290
|
+
sev = getattr(result, "severity", None)
|
|
291
|
+
blast = getattr(result, "blast_radius", None) or []
|
|
292
|
+
team = getattr(result, "assigned_team", None)
|
|
293
|
+
first_sentence = reasoning.split(".")[0].strip() if reasoning else ""
|
|
294
|
+
if first_sentence:
|
|
295
|
+
return f"SEV-{sev}: {first_sentence}"
|
|
296
|
+
if sev is not None:
|
|
297
|
+
parts = [f"SEV-{sev}"]
|
|
298
|
+
if blast:
|
|
299
|
+
parts.append(f"{len(blast)} services in blast radius")
|
|
300
|
+
if team:
|
|
301
|
+
parts.append(f"assigned to {team}")
|
|
302
|
+
return " — ".join(parts)
|
|
303
|
+
|
|
304
|
+
elif role == "investigation":
|
|
305
|
+
rc = getattr(result, "root_cause", None)
|
|
306
|
+
rc_conf = getattr(result, "root_cause_confidence", 0)
|
|
307
|
+
if rc:
|
|
308
|
+
return f"Root cause ({rc_conf:.0%} confidence): {rc[:90]}"
|
|
309
|
+
hypotheses = getattr(result, "hypotheses", None) or []
|
|
310
|
+
if hypotheses:
|
|
311
|
+
h = hypotheses[0]
|
|
312
|
+
desc = getattr(h, "description", str(h)) if hasattr(h, "description") else str(h)
|
|
313
|
+
return f"Hypothesis: {desc[:90]}"
|
|
314
|
+
|
|
315
|
+
elif role == "communication":
|
|
316
|
+
updates = getattr(result, "updates_sent", None) or []
|
|
317
|
+
if updates:
|
|
318
|
+
u = updates[0]
|
|
319
|
+
content = getattr(u, "content", "") if hasattr(u, "content") else str(u)
|
|
320
|
+
channel = getattr(u, "channel", "") if hasattr(u, "channel") else ""
|
|
321
|
+
return f"{'via ' + channel + ': ' if channel else ''}{content[:90]}"
|
|
322
|
+
|
|
323
|
+
elif role == "remediation":
|
|
324
|
+
action = getattr(result, "proposed_action", None)
|
|
325
|
+
target = getattr(result, "target", None)
|
|
326
|
+
if action and target:
|
|
327
|
+
approval = getattr(result, "requires_human_approval", True)
|
|
328
|
+
return f"{action} on {target}" + (" (requires approval)" if approval else "")
|
|
329
|
+
if action:
|
|
330
|
+
return f"Proposed: {action}"
|
|
331
|
+
|
|
332
|
+
# If we got here, the agent-specific fields were empty.
|
|
333
|
+
# Use first sentence of reasoning as last resort from actual output.
|
|
334
|
+
if reasoning:
|
|
335
|
+
return reasoning.split(".")[0].strip()[:90]
|
|
336
|
+
|
|
337
|
+
# Genuinely empty — mark as unparseable
|
|
338
|
+
log.warning("agent_summary_empty", role=role, incident=context.id)
|
|
339
|
+
return "Agent response produced no summary — see raw output"
|
|
340
|
+
|
|
341
|
+
async def _degraded_verdict(self, context: IncidentContext, reason: str) -> Verdict:
|
|
342
|
+
"""Emit a degraded escalation verdict when the agent cannot produce a
|
|
343
|
+
normal judgment (e.g. model timeout, API error)."""
|
|
344
|
+
summary = self._build_degraded_summary(context)
|
|
345
|
+
return await self._emit_verdict(
|
|
346
|
+
context,
|
|
347
|
+
subject_summary=summary,
|
|
348
|
+
action="escalate",
|
|
349
|
+
confidence=0.0,
|
|
350
|
+
reasoning=f"Agent operating in degraded mode: {reason}",
|
|
351
|
+
tags=["degraded", "human-takeover-required"],
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def _build_degraded_summary(self, context: IncidentContext) -> str:
|
|
355
|
+
"""Build an informative degraded summary using available context data."""
|
|
356
|
+
role = self.role.value
|
|
357
|
+
meta = getattr(context, "metadata", {}) or {}
|
|
358
|
+
blast = meta.get("blast_radius", [])
|
|
359
|
+
root_causes = meta.get("root_causes", [])
|
|
360
|
+
severity = meta.get("severity", "?")
|
|
361
|
+
incident_id = getattr(context, "id", "unknown")
|
|
362
|
+
rc_service = root_causes[0].get("service", "unknown") if root_causes else "unknown"
|
|
363
|
+
rc_type = root_causes[0].get("type", "unknown") if root_causes else "unknown"
|
|
364
|
+
# Also try SLO info from the trigger verdicts
|
|
365
|
+
slo_info = ""
|
|
366
|
+
if rc_service != "unknown" and rc_type != "unknown":
|
|
367
|
+
slo_info = f"{rc_service} {rc_type}"
|
|
368
|
+
|
|
369
|
+
if role == "triage":
|
|
370
|
+
slo_info = f"{rc_service} {rc_type}" if rc_service != "unknown" else "incident"
|
|
371
|
+
return f"DEGRADED: SEV-{severity} — {slo_info}, {len(blast)} services in blast radius"
|
|
372
|
+
elif role == "investigation":
|
|
373
|
+
return f"DEGRADED: Manual investigation required — root cause from correlation: {rc_service} ({rc_type})"
|
|
374
|
+
elif role == "communication":
|
|
375
|
+
return f"DEGRADED: Draft status update required for {incident_id}"
|
|
376
|
+
elif role == "remediation":
|
|
377
|
+
return "DEGRADED: Manual remediation required — see correlation verdict for recommended actions"
|
|
378
|
+
return f"DEGRADED: {role} — manual assessment required"
|
|
379
|
+
|
|
380
|
+
# ------------------------------------------------------------------ #
|
|
381
|
+
# Transport: governance #
|
|
382
|
+
# ------------------------------------------------------------------ #
|
|
383
|
+
|
|
384
|
+
async def _request_autonomy_reduction(
|
|
385
|
+
self,
|
|
386
|
+
agent_name: str,
|
|
387
|
+
arbiter_url: str,
|
|
388
|
+
reason: str,
|
|
389
|
+
) -> dict:
|
|
390
|
+
"""POST to Arbiter's governance endpoint to reduce agent autonomy.
|
|
391
|
+
|
|
392
|
+
Uses urllib (stdlib) in asyncio.to_thread; retries up to 3 times.
|
|
393
|
+
"""
|
|
394
|
+
url = f"{arbiter_url}/api/v1/governance/reduce"
|
|
395
|
+
payload = json.dumps({
|
|
396
|
+
"agent": agent_name,
|
|
397
|
+
"reason": reason,
|
|
398
|
+
}).encode()
|
|
399
|
+
|
|
400
|
+
def _sync_post() -> dict:
|
|
401
|
+
req = urllib.request.Request(
|
|
402
|
+
url,
|
|
403
|
+
data=payload,
|
|
404
|
+
headers={"Content-Type": "application/json"},
|
|
405
|
+
method="POST",
|
|
406
|
+
)
|
|
407
|
+
last_exc: Exception | None = None
|
|
408
|
+
for attempt in range(3):
|
|
409
|
+
try:
|
|
410
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
411
|
+
return json.loads(resp.read().decode())
|
|
412
|
+
except Exception as exc: # noqa: BLE001
|
|
413
|
+
last_exc = exc
|
|
414
|
+
raise RuntimeError(
|
|
415
|
+
f"Autonomy reduction request failed after 3 attempts: {last_exc}"
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
return await asyncio.to_thread(_sync_post)
|
|
419
|
+
|
|
420
|
+
# ------------------------------------------------------------------ #
|
|
421
|
+
# Utility #
|
|
422
|
+
# ------------------------------------------------------------------ #
|
|
423
|
+
|
|
424
|
+
def _parse_json(self, response: str) -> dict:
|
|
425
|
+
"""Extract and parse JSON from a model response.
|
|
426
|
+
|
|
427
|
+
Handles:
|
|
428
|
+
- Clean JSON strings
|
|
429
|
+
- Markdown fenced blocks (```json ... ```)
|
|
430
|
+
- Preamble text before the first ``{``
|
|
431
|
+
"""
|
|
432
|
+
text = response.strip()
|
|
433
|
+
|
|
434
|
+
# Strip markdown fences
|
|
435
|
+
fenced = re.sub(r"^```(?:json)?\s*", "", text)
|
|
436
|
+
fenced = re.sub(r"\s*```$", "", fenced).strip()
|
|
437
|
+
|
|
438
|
+
# Find a valid JSON object by matching braces
|
|
439
|
+
brace_index = fenced.find("{")
|
|
440
|
+
if brace_index == -1:
|
|
441
|
+
raise ValueError(f"No JSON object found in response: {response!r}")
|
|
442
|
+
|
|
443
|
+
# Try progressively from each '{' to find valid JSON
|
|
444
|
+
for start in range(brace_index, len(fenced)):
|
|
445
|
+
if fenced[start] != "{":
|
|
446
|
+
continue
|
|
447
|
+
depth = 0
|
|
448
|
+
for end in range(start, len(fenced)):
|
|
449
|
+
if fenced[end] == "{":
|
|
450
|
+
depth += 1
|
|
451
|
+
elif fenced[end] == "}":
|
|
452
|
+
depth -= 1
|
|
453
|
+
if depth == 0:
|
|
454
|
+
try:
|
|
455
|
+
return json.loads(fenced[start:end + 1])
|
|
456
|
+
except json.JSONDecodeError:
|
|
457
|
+
break # try next '{'
|
|
458
|
+
break
|
|
459
|
+
|
|
460
|
+
raise ValueError(f"Failed to parse JSON from response: {response!r}")
|
|
461
|
+
|
|
462
|
+
# ------------------------------------------------------------------ #
|
|
463
|
+
# Template method #
|
|
464
|
+
# ------------------------------------------------------------------ #
|
|
465
|
+
|
|
466
|
+
async def execute(self, context: IncidentContext) -> IncidentContext:
|
|
467
|
+
"""Run the agent against the given incident context.
|
|
468
|
+
|
|
469
|
+
Template method — orchestrates the call sequence; subclasses
|
|
470
|
+
provide judgment via build_prompt / parse_response / _apply_result.
|
|
471
|
+
"""
|
|
472
|
+
try:
|
|
473
|
+
system, user = self.build_prompt(context)
|
|
474
|
+
response = await self._call_model(system, user)
|
|
475
|
+
body_preview = response if len(response) <= 800 else response[:800].rsplit(" ", 1)[0] + "..."
|
|
476
|
+
log.info("agent_response", role=self.role.value,
|
|
477
|
+
response_length=len(response), body=body_preview)
|
|
478
|
+
result = self.parse_response(response, context)
|
|
479
|
+
context = self._apply_result(context, result)
|
|
480
|
+
confidence = getattr(result, "root_cause_confidence", None) or getattr(result, "confidence", None) or 0.0
|
|
481
|
+
summary = self._build_summary(context, result)
|
|
482
|
+
verdict = await self._emit_verdict(
|
|
483
|
+
context,
|
|
484
|
+
subject_summary=summary,
|
|
485
|
+
action="flag",
|
|
486
|
+
confidence=confidence,
|
|
487
|
+
reasoning=getattr(result, "reasoning", ""),
|
|
488
|
+
)
|
|
489
|
+
self._write_decision_verdict(context, verdict, system, user, response)
|
|
490
|
+
# Slack notification (fail-open, opt-in via SLACK_WEBHOOK_URL)
|
|
491
|
+
await self._notify_slack(context)
|
|
492
|
+
context = await self._post_execute(context, result)
|
|
493
|
+
except Exception as exc: # noqa: BLE001
|
|
494
|
+
log.warning("agent_execute_failed", role=self.role.value,
|
|
495
|
+
error=str(exc), exc_info=True)
|
|
496
|
+
await self._degraded_verdict(context, str(exc))
|
|
497
|
+
return context
|
|
498
|
+
|
|
499
|
+
async def _post_execute(
|
|
500
|
+
self, context: IncidentContext, result: Any
|
|
501
|
+
) -> IncidentContext:
|
|
502
|
+
"""Hook called after a successful execute cycle. No-op by default."""
|
|
503
|
+
return context
|
|
504
|
+
|
|
505
|
+
async def _notify_slack(self, context: IncidentContext) -> None:
|
|
506
|
+
"""Send Slack notification for this agent's verdict. Fail-open."""
|
|
507
|
+
import os
|
|
508
|
+
if not os.environ.get("SLACK_WEBHOOK_URL"):
|
|
509
|
+
return
|
|
510
|
+
try:
|
|
511
|
+
from nthlayer_workers.respond.notifications import send_slack_notification
|
|
512
|
+
from nthlayer_workers.respond.notifications import (
|
|
513
|
+
build_triage_blocks,
|
|
514
|
+
build_remediation_blocks,
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
role = self.role.value
|
|
518
|
+
builders = {
|
|
519
|
+
"triage": build_triage_blocks,
|
|
520
|
+
"remediation": build_remediation_blocks,
|
|
521
|
+
}
|
|
522
|
+
builder = builders.get(role)
|
|
523
|
+
if not builder or not context.verdict_chain:
|
|
524
|
+
return
|
|
525
|
+
|
|
526
|
+
# Get the most recent verdict (just emitted)
|
|
527
|
+
last_vid = context.verdict_chain[-1]
|
|
528
|
+
v = self._verdict_store.get(last_vid) if self._verdict_store else None
|
|
529
|
+
if not v:
|
|
530
|
+
return
|
|
531
|
+
|
|
532
|
+
await send_slack_notification(
|
|
533
|
+
v, builder,
|
|
534
|
+
verdict_store=self._verdict_store,
|
|
535
|
+
trigger_verdict_ids=context.trigger_verdict_ids,
|
|
536
|
+
)
|
|
537
|
+
except Exception:
|
|
538
|
+
log.warning("Slack notification failed", role=self.role.value, exc_info=True)
|
|
539
|
+
|
|
540
|
+
# ------------------------------------------------------------------ #
|
|
541
|
+
# Abstract judgment interface #
|
|
542
|
+
# ------------------------------------------------------------------ #
|
|
543
|
+
|
|
544
|
+
@abstractmethod
|
|
545
|
+
def build_prompt(self, context: IncidentContext) -> tuple[str, str]:
|
|
546
|
+
"""Return (system_prompt, user_prompt) for this agent's judgment."""
|
|
547
|
+
|
|
548
|
+
@abstractmethod
|
|
549
|
+
def parse_response(self, response: str, context: IncidentContext) -> Any:
|
|
550
|
+
"""Parse the model's text response into a typed result object."""
|
|
551
|
+
|
|
552
|
+
@abstractmethod
|
|
553
|
+
def _apply_result(
|
|
554
|
+
self, context: IncidentContext, result: Any
|
|
555
|
+
) -> IncidentContext:
|
|
556
|
+
"""Write the parsed result into the shared incident context."""
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# src/nthlayer_respond/agents/communication.py
|
|
2
|
+
"""CommunicationAgent — two-phase incident communication drafting."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from nthlayer_common.prompts import extract_confidence, load_prompt
|
|
9
|
+
from nthlayer_workers.respond.agents.base import AgentBase
|
|
10
|
+
from nthlayer_workers.respond.types import (
|
|
11
|
+
AgentRole,
|
|
12
|
+
CommunicationResult,
|
|
13
|
+
CommunicationUpdate,
|
|
14
|
+
IncidentContext,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
_PROMPT_PATH = Path(__file__).parent.parent.parent.parent / "prompts" / "communication.yaml"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CommunicationAgent(AgentBase):
|
|
21
|
+
"""Draft incident communications in two pipeline phases.
|
|
22
|
+
|
|
23
|
+
Phase 1 (context.remediation is None): draft initial status update.
|
|
24
|
+
Phase 2 (context.remediation is not None): draft resolution update.
|
|
25
|
+
|
|
26
|
+
Judgment SLO: human edit rate < 15%.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
role = AgentRole.COMMUNICATION
|
|
30
|
+
default_timeout = 20
|
|
31
|
+
|
|
32
|
+
# ------------------------------------------------------------------ #
|
|
33
|
+
# Judgment interface #
|
|
34
|
+
# ------------------------------------------------------------------ #
|
|
35
|
+
|
|
36
|
+
def build_prompt(self, context: IncidentContext) -> tuple[str, str]:
|
|
37
|
+
spec = load_prompt(_PROMPT_PATH)
|
|
38
|
+
|
|
39
|
+
# Service context from OpenSRM spec
|
|
40
|
+
svc_ctx = self._build_service_context_prompt(context)
|
|
41
|
+
|
|
42
|
+
if context.remediation is None:
|
|
43
|
+
# Phase 1 — initial status update
|
|
44
|
+
triage = context.triage
|
|
45
|
+
severity = triage.severity if triage is not None else "unknown"
|
|
46
|
+
blast_radius = triage.blast_radius if triage is not None else []
|
|
47
|
+
user = (
|
|
48
|
+
f"Draft an initial status update. "
|
|
49
|
+
f"We know: severity={severity}, "
|
|
50
|
+
f"affected services={blast_radius}. "
|
|
51
|
+
f"Investigation is ongoing."
|
|
52
|
+
)
|
|
53
|
+
else:
|
|
54
|
+
# Phase 2 — resolution update
|
|
55
|
+
inv = context.investigation
|
|
56
|
+
rem = context.remediation
|
|
57
|
+
root_cause = inv.root_cause if inv is not None else "under investigation"
|
|
58
|
+
user = (
|
|
59
|
+
f"Draft a resolution update. "
|
|
60
|
+
f"Root cause: {root_cause}. "
|
|
61
|
+
f"Remediation: {rem.proposed_action} on {rem.target}. "
|
|
62
|
+
f"Outcome: {rem.execution_result}."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if svc_ctx:
|
|
66
|
+
user = user + "\n" + svc_ctx
|
|
67
|
+
|
|
68
|
+
return spec.system, user
|
|
69
|
+
|
|
70
|
+
def parse_response(
|
|
71
|
+
self, response: str, context: IncidentContext
|
|
72
|
+
) -> CommunicationResult:
|
|
73
|
+
data = self._parse_json(response)
|
|
74
|
+
|
|
75
|
+
timestamp = datetime.now(tz=timezone.utc).isoformat()
|
|
76
|
+
|
|
77
|
+
updates: list[CommunicationUpdate] = []
|
|
78
|
+
for raw in data.get("updates") or data.get("messages") or []:
|
|
79
|
+
updates.append(
|
|
80
|
+
CommunicationUpdate(
|
|
81
|
+
channel=raw.get("channel", ""),
|
|
82
|
+
timestamp=timestamp,
|
|
83
|
+
update_type=raw.get("update_type", raw.get("type", "")),
|
|
84
|
+
content=raw.get("content", raw.get("message", "")),
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# If no structured updates, synthesize from flat response fields
|
|
89
|
+
if not updates:
|
|
90
|
+
content_parts = []
|
|
91
|
+
for key in ("title", "impact_description", "current_status", "summary", "message"):
|
|
92
|
+
if data.get(key):
|
|
93
|
+
content_parts.append(str(data[key]))
|
|
94
|
+
if content_parts:
|
|
95
|
+
updates.append(CommunicationUpdate(
|
|
96
|
+
channel=data.get("channel", "status_page"),
|
|
97
|
+
timestamp=timestamp,
|
|
98
|
+
update_type=data.get("status", "initial"),
|
|
99
|
+
content=" — ".join(content_parts),
|
|
100
|
+
))
|
|
101
|
+
|
|
102
|
+
reasoning: str = data.get("reasoning", "") or data.get("rationale", "")
|
|
103
|
+
confidence = extract_confidence(data)
|
|
104
|
+
return CommunicationResult(updates_sent=updates, reasoning=reasoning, confidence=confidence)
|
|
105
|
+
|
|
106
|
+
def _apply_result(
|
|
107
|
+
self, context: IncidentContext, result: CommunicationResult
|
|
108
|
+
) -> IncidentContext:
|
|
109
|
+
if context.communication is None:
|
|
110
|
+
context.communication = result
|
|
111
|
+
else:
|
|
112
|
+
# APPEND — do not replace; phase 2 adds to phase 1's updates
|
|
113
|
+
context.communication.updates_sent.extend(result.updates_sent)
|
|
114
|
+
context.communication.reasoning = result.reasoning
|
|
115
|
+
return context
|