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.
Files changed (175) hide show
  1. nthlayer_workers/__init__.py +5 -0
  2. nthlayer_workers/cli.py +234 -0
  3. nthlayer_workers/correlate/__init__.py +1 -0
  4. nthlayer_workers/correlate/cli.py +847 -0
  5. nthlayer_workers/correlate/config.py +111 -0
  6. nthlayer_workers/correlate/correlation/__init__.py +1 -0
  7. nthlayer_workers/correlate/correlation/changes.py +87 -0
  8. nthlayer_workers/correlate/correlation/dedup.py +62 -0
  9. nthlayer_workers/correlate/correlation/engine.py +244 -0
  10. nthlayer_workers/correlate/correlation/temporal.py +79 -0
  11. nthlayer_workers/correlate/correlation/topology.py +104 -0
  12. nthlayer_workers/correlate/ingestion/__init__.py +1 -0
  13. nthlayer_workers/correlate/ingestion/protocol.py +10 -0
  14. nthlayer_workers/correlate/ingestion/severity.py +18 -0
  15. nthlayer_workers/correlate/ingestion/webhook.py +197 -0
  16. nthlayer_workers/correlate/notifications.py +85 -0
  17. nthlayer_workers/correlate/prometheus.py +234 -0
  18. nthlayer_workers/correlate/reasoning.py +375 -0
  19. nthlayer_workers/correlate/session.py +189 -0
  20. nthlayer_workers/correlate/snapshot/__init__.py +1 -0
  21. nthlayer_workers/correlate/snapshot/generator.py +170 -0
  22. nthlayer_workers/correlate/snapshot/model.py +177 -0
  23. nthlayer_workers/correlate/snapshot/token.py +14 -0
  24. nthlayer_workers/correlate/state.py +88 -0
  25. nthlayer_workers/correlate/store/__init__.py +5 -0
  26. nthlayer_workers/correlate/store/protocol.py +48 -0
  27. nthlayer_workers/correlate/store/sqlite.py +443 -0
  28. nthlayer_workers/correlate/summary.py +180 -0
  29. nthlayer_workers/correlate/traces/__init__.py +1 -0
  30. nthlayer_workers/correlate/traces/protocol.py +120 -0
  31. nthlayer_workers/correlate/traces/tempo.py +667 -0
  32. nthlayer_workers/correlate/traces/topology.py +39 -0
  33. nthlayer_workers/correlate/types.py +77 -0
  34. nthlayer_workers/correlate/worker.py +630 -0
  35. nthlayer_workers/learn/__init__.py +5 -0
  36. nthlayer_workers/learn/__main__.py +5 -0
  37. nthlayer_workers/learn/cli.py +164 -0
  38. nthlayer_workers/learn/retrospective.py +381 -0
  39. nthlayer_workers/learn/trends.py +102 -0
  40. nthlayer_workers/learn/worker.py +366 -0
  41. nthlayer_workers/measure/__init__.py +3 -0
  42. nthlayer_workers/measure/__main__.py +5 -0
  43. nthlayer_workers/measure/_parsing.py +15 -0
  44. nthlayer_workers/measure/adapters/__init__.py +0 -0
  45. nthlayer_workers/measure/adapters/_util.py +24 -0
  46. nthlayer_workers/measure/adapters/devin.py +119 -0
  47. nthlayer_workers/measure/adapters/gastown.py +88 -0
  48. nthlayer_workers/measure/adapters/prometheus.py +277 -0
  49. nthlayer_workers/measure/adapters/protocol.py +20 -0
  50. nthlayer_workers/measure/adapters/webhook.py +161 -0
  51. nthlayer_workers/measure/api/__init__.py +0 -0
  52. nthlayer_workers/measure/api/normalise.py +50 -0
  53. nthlayer_workers/measure/api/queue.py +243 -0
  54. nthlayer_workers/measure/api/response.py +51 -0
  55. nthlayer_workers/measure/api/server.py +504 -0
  56. nthlayer_workers/measure/calibration/__init__.py +0 -0
  57. nthlayer_workers/measure/calibration/loop.py +62 -0
  58. nthlayer_workers/measure/calibration/slos.py +212 -0
  59. nthlayer_workers/measure/calibration/verdict_calibration.py +31 -0
  60. nthlayer_workers/measure/cli.py +753 -0
  61. nthlayer_workers/measure/config.py +191 -0
  62. nthlayer_workers/measure/detection/__init__.py +6 -0
  63. nthlayer_workers/measure/detection/detector.py +82 -0
  64. nthlayer_workers/measure/detection/protocol.py +29 -0
  65. nthlayer_workers/measure/governance/__init__.py +0 -0
  66. nthlayer_workers/measure/governance/engine.py +163 -0
  67. nthlayer_workers/measure/manifest.py +77 -0
  68. nthlayer_workers/measure/notifications.py +53 -0
  69. nthlayer_workers/measure/pipeline/__init__.py +0 -0
  70. nthlayer_workers/measure/pipeline/evaluator.py +155 -0
  71. nthlayer_workers/measure/pipeline/router.py +160 -0
  72. nthlayer_workers/measure/store/__init__.py +0 -0
  73. nthlayer_workers/measure/store/protocol.py +38 -0
  74. nthlayer_workers/measure/store/sqlite.py +276 -0
  75. nthlayer_workers/measure/telemetry.py +116 -0
  76. nthlayer_workers/measure/tiering/__init__.py +0 -0
  77. nthlayer_workers/measure/tiering/classifier.py +58 -0
  78. nthlayer_workers/measure/tiering/promotion.py +118 -0
  79. nthlayer_workers/measure/trends/__init__.py +0 -0
  80. nthlayer_workers/measure/trends/tracker.py +72 -0
  81. nthlayer_workers/measure/types.py +75 -0
  82. nthlayer_workers/measure/worker.py +439 -0
  83. nthlayer_workers/observe/__init__.py +25 -0
  84. nthlayer_workers/observe/__main__.py +5 -0
  85. nthlayer_workers/observe/api/__init__.py +1 -0
  86. nthlayer_workers/observe/assessment.py +95 -0
  87. nthlayer_workers/observe/cli.py +737 -0
  88. nthlayer_workers/observe/config.py +11 -0
  89. nthlayer_workers/observe/db/__init__.py +1 -0
  90. nthlayer_workers/observe/decision_records.py +220 -0
  91. nthlayer_workers/observe/dependencies/__init__.py +18 -0
  92. nthlayer_workers/observe/dependencies/discovery.py +294 -0
  93. nthlayer_workers/observe/dependencies/providers/__init__.py +48 -0
  94. nthlayer_workers/observe/dependencies/providers/backstage.py +467 -0
  95. nthlayer_workers/observe/dependencies/providers/base.py +76 -0
  96. nthlayer_workers/observe/dependencies/providers/consul.py +518 -0
  97. nthlayer_workers/observe/dependencies/providers/etcd.py +360 -0
  98. nthlayer_workers/observe/dependencies/providers/kubernetes.py +682 -0
  99. nthlayer_workers/observe/dependencies/providers/prometheus.py +368 -0
  100. nthlayer_workers/observe/dependencies/providers/zookeeper.py +399 -0
  101. nthlayer_workers/observe/deployments/__init__.py +1 -0
  102. nthlayer_workers/observe/discovery/__init__.py +14 -0
  103. nthlayer_workers/observe/discovery/classifier.py +66 -0
  104. nthlayer_workers/observe/discovery/client.py +189 -0
  105. nthlayer_workers/observe/discovery/models.py +53 -0
  106. nthlayer_workers/observe/drift/__init__.py +26 -0
  107. nthlayer_workers/observe/drift/analyzer.py +383 -0
  108. nthlayer_workers/observe/drift/models.py +174 -0
  109. nthlayer_workers/observe/drift/patterns.py +88 -0
  110. nthlayer_workers/observe/explanation.py +118 -0
  111. nthlayer_workers/observe/gate/__init__.py +39 -0
  112. nthlayer_workers/observe/gate/conditions.py +92 -0
  113. nthlayer_workers/observe/gate/correlator.py +154 -0
  114. nthlayer_workers/observe/gate/evaluator.py +192 -0
  115. nthlayer_workers/observe/gate/policies.py +226 -0
  116. nthlayer_workers/observe/gate_adapter.py +40 -0
  117. nthlayer_workers/observe/incident.py +36 -0
  118. nthlayer_workers/observe/portfolio/__init__.py +17 -0
  119. nthlayer_workers/observe/portfolio/aggregator.py +168 -0
  120. nthlayer_workers/observe/portfolio/scorer.py +13 -0
  121. nthlayer_workers/observe/slo/__init__.py +19 -0
  122. nthlayer_workers/observe/slo/collector.py +235 -0
  123. nthlayer_workers/observe/slo/spec_loader.py +40 -0
  124. nthlayer_workers/observe/sqlite_store.py +152 -0
  125. nthlayer_workers/observe/store.py +92 -0
  126. nthlayer_workers/observe/verification/__init__.py +22 -0
  127. nthlayer_workers/observe/verification/exporter_guidance.py +146 -0
  128. nthlayer_workers/observe/verification/extractor.py +127 -0
  129. nthlayer_workers/observe/verification/models.py +101 -0
  130. nthlayer_workers/observe/verification/verifier.py +111 -0
  131. nthlayer_workers/observe/worker.py +332 -0
  132. nthlayer_workers/respond/__init__.py +2 -0
  133. nthlayer_workers/respond/__main__.py +4 -0
  134. nthlayer_workers/respond/agents/__init__.py +0 -0
  135. nthlayer_workers/respond/agents/base.py +556 -0
  136. nthlayer_workers/respond/agents/communication.py +115 -0
  137. nthlayer_workers/respond/agents/investigation.py +124 -0
  138. nthlayer_workers/respond/agents/remediation.py +219 -0
  139. nthlayer_workers/respond/agents/triage.py +132 -0
  140. nthlayer_workers/respond/cli.py +772 -0
  141. nthlayer_workers/respond/config.py +135 -0
  142. nthlayer_workers/respond/context_store.py +256 -0
  143. nthlayer_workers/respond/coordinator.py +487 -0
  144. nthlayer_workers/respond/metrics.py +104 -0
  145. nthlayer_workers/respond/notification_backends/__init__.py +1 -0
  146. nthlayer_workers/respond/notification_backends/ntfy_backend.py +158 -0
  147. nthlayer_workers/respond/notification_backends/protocol.py +59 -0
  148. nthlayer_workers/respond/notification_backends/slack_backend.py +203 -0
  149. nthlayer_workers/respond/notification_backends/stdout_backend.py +56 -0
  150. nthlayer_workers/respond/notifications.py +247 -0
  151. nthlayer_workers/respond/oncall/__init__.py +1 -0
  152. nthlayer_workers/respond/oncall/escalation.py +103 -0
  153. nthlayer_workers/respond/oncall/runner.py +193 -0
  154. nthlayer_workers/respond/oncall/schedule.py +243 -0
  155. nthlayer_workers/respond/safe_actions/__init__.py +0 -0
  156. nthlayer_workers/respond/safe_actions/actions.py +139 -0
  157. nthlayer_workers/respond/safe_actions/registry.py +171 -0
  158. nthlayer_workers/respond/safe_actions/webhook.py +194 -0
  159. nthlayer_workers/respond/server.py +357 -0
  160. nthlayer_workers/respond/sre/__init__.py +1 -0
  161. nthlayer_workers/respond/sre/brief.py +175 -0
  162. nthlayer_workers/respond/sre/delegation.py +101 -0
  163. nthlayer_workers/respond/sre/post_incident.py +146 -0
  164. nthlayer_workers/respond/sre/shift_report.py +129 -0
  165. nthlayer_workers/respond/sre/suppression.py +91 -0
  166. nthlayer_workers/respond/types.py +109 -0
  167. nthlayer_workers/respond/verdict_submission.py +56 -0
  168. nthlayer_workers/respond/worker.py +533 -0
  169. nthlayer_workers/respond/worker_helpers.py +140 -0
  170. nthlayer_workers/runner.py +198 -0
  171. nthlayer_workers-1.0.0.dist-info/METADATA +19 -0
  172. nthlayer_workers-1.0.0.dist-info/RECORD +175 -0
  173. nthlayer_workers-1.0.0.dist-info/WHEEL +5 -0
  174. nthlayer_workers-1.0.0.dist-info/entry_points.txt +2 -0
  175. 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