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,124 @@
1
+ # src/nthlayer_respond/agents/investigation.py
2
+ """InvestigationAgent — hypothesis generation and root cause declaration."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from nthlayer_common.prompts import extract_confidence, load_prompt, render_user_prompt
9
+ from nthlayer_workers.respond.agents.base import AgentBase
10
+ from nthlayer_workers.respond.types import (
11
+ AgentRole,
12
+ Hypothesis,
13
+ IncidentContext,
14
+ InvestigationResult,
15
+ )
16
+
17
+ _PROMPT_PATH = Path(__file__).parent.parent.parent.parent / "prompts" / "investigation.yaml"
18
+
19
+
20
+ class InvestigationAgent(AgentBase):
21
+ """Form hypotheses about root cause and rank by confidence.
22
+
23
+ Judgment SLO: 70% post-incident agreement on declared root causes.
24
+ Root cause is only declared when confidence exceeds root_cause_threshold.
25
+ """
26
+
27
+ role = AgentRole.INVESTIGATION
28
+ default_timeout = 60
29
+
30
+ # ------------------------------------------------------------------ #
31
+ # Judgment interface #
32
+ # ------------------------------------------------------------------ #
33
+
34
+ def build_prompt(self, context: IncidentContext) -> tuple[str, str]:
35
+ threshold = self._config.get("root_cause_threshold", 0.7)
36
+
37
+ spec = load_prompt(_PROMPT_PATH)
38
+ system = render_user_prompt(spec.system, root_cause_threshold=str(threshold))
39
+
40
+ parts: list[str] = []
41
+
42
+ # Triage context
43
+ if context.triage is not None:
44
+ t = context.triage
45
+ parts.append("Triage results:")
46
+ parts.append(f" severity={t.severity}")
47
+ parts.append(f" blast_radius={t.blast_radius}")
48
+ parts.append(f" affected_slos={t.affected_slos}")
49
+
50
+ # nthlayer-correlate correlation verdicts
51
+ if context.trigger_verdict_ids:
52
+ parts.append("\nnthlayer-correlate correlation verdicts:")
53
+ for vid in context.trigger_verdict_ids:
54
+ try:
55
+ v = self._verdict_store.get(vid)
56
+ if v is not None:
57
+ parts.append(
58
+ f" - ref={v.subject.ref!r} "
59
+ f"summary={v.subject.summary!r} "
60
+ f"confidence={v.judgment.confidence} "
61
+ f"reasoning={v.judgment.reasoning!r}"
62
+ )
63
+ except Exception: # noqa: BLE001
64
+ pass
65
+
66
+ # Service context from OpenSRM spec + evaluation verdict
67
+ svc_ctx = self._build_service_context_prompt(context)
68
+ if svc_ctx:
69
+ parts.append(svc_ctx)
70
+
71
+ # Topology
72
+ # Prune topology to blast radius + 1 hop
73
+ relevant = context.triage.blast_radius if context.triage else []
74
+ pruned = self._prune_topology(context.topology, relevant) if relevant else context.topology
75
+ parts.append(f"\nTopology: {json.dumps(pruned)}")
76
+
77
+ user = render_user_prompt(spec.user_template, context="\n".join(parts))
78
+ return system, user
79
+
80
+ def parse_response(
81
+ self, response: str, context: IncidentContext
82
+ ) -> InvestigationResult:
83
+ data = self._parse_json(response)
84
+ threshold = self._config.get("root_cause_threshold", 0.7)
85
+
86
+ # Build hypotheses list — accept both "description" and "hypothesis" field names
87
+ hypotheses: list[Hypothesis] = []
88
+ for h in data.get("hypotheses") or []:
89
+ desc = h.get("description") or h.get("hypothesis") or h.get("summary", "")
90
+ raw_evidence = h.get("evidence") or []
91
+ if not raw_evidence and h.get("reasoning"):
92
+ raw_evidence = [h["reasoning"]]
93
+ hypotheses.append(
94
+ Hypothesis(
95
+ description=desc,
96
+ confidence=float(h.get("confidence", 0.0)),
97
+ evidence=list(raw_evidence),
98
+ change_candidate=h.get("change_candidate"),
99
+ )
100
+ )
101
+
102
+ root_cause: str | None = data.get("root_cause")
103
+ root_cause_confidence: float = float(data.get("root_cause_confidence") or data.get("confidence") or 0.0)
104
+ reasoning: str = data.get("reasoning", "") or data.get("analysis", "")
105
+
106
+ # Mechanical threshold check: clear root_cause if confidence is below threshold
107
+ if root_cause is not None and root_cause_confidence < threshold:
108
+ root_cause = None
109
+
110
+ confidence = extract_confidence(data)
111
+
112
+ return InvestigationResult(
113
+ hypotheses=hypotheses,
114
+ root_cause=root_cause,
115
+ root_cause_confidence=root_cause_confidence,
116
+ reasoning=reasoning,
117
+ confidence=confidence,
118
+ )
119
+
120
+ def _apply_result(
121
+ self, context: IncidentContext, result: InvestigationResult
122
+ ) -> IncidentContext:
123
+ context.investigation = result
124
+ return context
@@ -0,0 +1,219 @@
1
+ # src/nthlayer_respond/agents/remediation.py
2
+ """RemediationAgent — safe action execution with approval ratchet."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import structlog
7
+ from pathlib import Path
8
+
9
+ from nthlayer_common.prompts import extract_confidence, load_prompt, render_user_prompt
10
+ from nthlayer_workers.respond.agents.base import AgentBase
11
+ from nthlayer_workers.respond.safe_actions.registry import SafeActionRegistry
12
+ from nthlayer_workers.respond.types import (
13
+ AgentRole,
14
+ IncidentContext,
15
+ RemediationResult,
16
+ )
17
+
18
+ _PROMPT_PATH = Path(__file__).parent.parent.parent.parent / "prompts" / "remediation.yaml"
19
+
20
+ logger = structlog.get_logger(__name__)
21
+
22
+
23
+ def _format_safe_actions(policy: dict) -> str:
24
+ """Format safe action policy as context for the remediation prompt."""
25
+ lines = ["Available safe actions (you MUST choose from this list):"]
26
+ for name, spec in policy.items():
27
+ desc = spec.get("description", "").strip()
28
+ risk = spec.get("risk", "unknown")
29
+ approval = "requires approval" if spec.get("requires_approval") else "auto-approved"
30
+ applicable = spec.get("applicable_to", {})
31
+ modes = applicable.get("failure_modes", [])
32
+ lines.append(f" - {name} ({risk} risk, {approval}): {desc}")
33
+ if modes:
34
+ lines.append(f" Applicable to: {', '.join(modes)}")
35
+ not_applicable = spec.get("not_applicable_to")
36
+ if not_applicable:
37
+ reason = not_applicable.get("reason", "").strip()
38
+ lines.append(f" NOT applicable to {not_applicable.get('service_types', [])}: {reason}")
39
+ return "\n".join(lines)
40
+
41
+
42
+ class RemediationAgent(AgentBase):
43
+ """Suggest and execute pre-approved safe actions.
44
+
45
+ Judgment SLO: 80% fix success rate.
46
+
47
+ Safety properties:
48
+ - Only actions in the closed SafeActionRegistry may be proposed.
49
+ - Approval ratchet: registry.requires_approval=True can never be downgraded by
50
+ the model. The model may always escalate (say True when registry says False).
51
+ - Novel actions (not in registry) are rejected: proposed_action set to None and
52
+ requires_human_approval forced to True.
53
+ """
54
+
55
+ role = AgentRole.REMEDIATION
56
+ default_timeout = 30
57
+
58
+ def __init__(
59
+ self,
60
+ *args,
61
+ safe_action_registry: SafeActionRegistry,
62
+ **kwargs,
63
+ ) -> None:
64
+ super().__init__(*args, **kwargs)
65
+ self._registry = safe_action_registry
66
+
67
+ # ------------------------------------------------------------------ #
68
+ # Judgment interface #
69
+ # ------------------------------------------------------------------ #
70
+
71
+ def build_prompt(self, context: IncidentContext) -> tuple[str, str]:
72
+ from nthlayer_workers.respond.safe_actions.actions import load_safe_action_policy
73
+
74
+ policy = load_safe_action_policy()
75
+ safe_actions_text = _format_safe_actions(policy)
76
+
77
+ spec = load_prompt(_PROMPT_PATH)
78
+ system = render_user_prompt(spec.system, safe_actions=safe_actions_text)
79
+
80
+ parts: list[str] = []
81
+
82
+ # Investigation context
83
+ if context.investigation is not None:
84
+ inv = context.investigation
85
+ parts.append("Investigation findings:")
86
+ if inv.root_cause is not None:
87
+ parts.append(f" root_cause={inv.root_cause!r}")
88
+ parts.append(f" root_cause_confidence={inv.root_cause_confidence}")
89
+ if inv.hypotheses:
90
+ parts.append(" hypotheses:")
91
+ for h in inv.hypotheses:
92
+ parts.append(
93
+ f" - {h.description!r} (confidence={h.confidence}, "
94
+ f"change_candidate={h.change_candidate!r})"
95
+ )
96
+
97
+ # Triage context
98
+ if context.triage is not None:
99
+ t = context.triage
100
+ parts.append("Triage results:")
101
+ parts.append(f" severity={t.severity}")
102
+ parts.append(f" blast_radius={t.blast_radius}")
103
+ parts.append(f" affected_slos={t.affected_slos}")
104
+
105
+ # Service context from OpenSRM spec + evaluation verdict
106
+ svc_ctx = self._build_service_context_prompt(context)
107
+ if svc_ctx:
108
+ parts.append(svc_ctx)
109
+
110
+ # Topology
111
+ # Prune topology to blast radius + 1 hop
112
+ relevant = context.triage.blast_radius if context.triage else []
113
+ pruned = self._prune_topology(context.topology, relevant) if relevant else context.topology
114
+ parts.append(f"\nTopology: {json.dumps(pruned)}")
115
+
116
+ user = render_user_prompt(spec.user_template, context="\n".join(parts))
117
+ return system, user
118
+
119
+ def parse_response(
120
+ self, response: str, context: IncidentContext
121
+ ) -> RemediationResult:
122
+ data = self._parse_json(response)
123
+
124
+ proposed_action: str | None = data.get("proposed_action") or data.get("recommended_action") or data.get("action")
125
+ target: str | None = data.get("target") or data.get("target_service")
126
+ risk_assessment: str = data.get("risk_assessment", "") or data.get("risk", "")
127
+ requires_human_approval: bool = bool(data.get("requires_human_approval", True))
128
+ autonomy_reduction: dict = data.get("autonomy_reduction") or {}
129
+ reasoning: str = data.get("reasoning", "") or data.get("rationale", "")
130
+
131
+ # Critical validation: reject hallucinated actions
132
+ if proposed_action is not None:
133
+ try:
134
+ registry_action = self._registry.get(proposed_action)
135
+ except KeyError:
136
+ logger.warning(
137
+ "RemediationAgent: model proposed unknown action %r — rejecting "
138
+ "(hallucinated action name). Forcing requires_human_approval=True.",
139
+ proposed_action,
140
+ )
141
+ proposed_action = None
142
+ requires_human_approval = True
143
+ registry_action = None
144
+ else:
145
+ # Approval ratchet: registry can only escalate, never downgrade.
146
+ # If registry says approval required, model cannot override it to False.
147
+ if registry_action.requires_approval and not requires_human_approval:
148
+ requires_human_approval = True
149
+ else:
150
+ registry_action = None
151
+
152
+ confidence = extract_confidence(data)
153
+
154
+ result = RemediationResult(
155
+ proposed_action=proposed_action,
156
+ target=target,
157
+ risk_assessment=risk_assessment,
158
+ requires_human_approval=requires_human_approval,
159
+ reasoning=reasoning,
160
+ confidence=confidence,
161
+ )
162
+
163
+ # Stash autonomy_reduction on the result for use in _post_execute
164
+ result.autonomy_reduction = autonomy_reduction
165
+
166
+ return result
167
+
168
+ def _apply_result(
169
+ self, context: IncidentContext, result: RemediationResult
170
+ ) -> IncidentContext:
171
+ context.remediation = result
172
+ return context
173
+
174
+ # ------------------------------------------------------------------ #
175
+ # Post-execute: safe action + autonomy reduction #
176
+ # ------------------------------------------------------------------ #
177
+
178
+ async def _post_execute(
179
+ self, context: IncidentContext, result: RemediationResult
180
+ ) -> IncidentContext:
181
+ """Execute safe action (if approved) then handle autonomy reduction.
182
+
183
+ Strict ordering:
184
+ 1. Execute safe action (if not requires_human_approval and action is set)
185
+ 2. Autonomy reduction (if recommended)
186
+ """
187
+ # Step 1: Execute safe action
188
+ if not result.requires_human_approval and result.proposed_action is not None:
189
+ try:
190
+ exec_result = await self._registry.execute(
191
+ result.proposed_action, result.target, context
192
+ )
193
+ result.executed = True
194
+ result.execution_result = exec_result.get("detail")
195
+ except Exception as exc: # noqa: BLE001
196
+ result.executed = False
197
+ result.execution_result = str(exc)
198
+
199
+ # Step 2: Autonomy reduction (if recommended by model)
200
+ autonomy_reduction: dict = result.autonomy_reduction or {}
201
+ if autonomy_reduction.get("recommended"):
202
+ target_agent: str = autonomy_reduction.get("target_agent", "")
203
+ arbiter_url: str = self._config.get("arbiter_url", "")
204
+ reason: str = autonomy_reduction.get("reason", "")
205
+
206
+ try:
207
+ gov_response = await self._request_autonomy_reduction(
208
+ target_agent, arbiter_url, reason
209
+ )
210
+ result.autonomy_reduced = True
211
+ result.autonomy_target = target_agent
212
+ result.previous_autonomy_level = gov_response.get("previous_level")
213
+ result.new_autonomy_level = gov_response.get("new_level")
214
+ except Exception as exc: # noqa: BLE001
215
+ logger.warning(
216
+ "RemediationAgent: autonomy reduction request failed: %s", exc
217
+ )
218
+
219
+ return context
@@ -0,0 +1,132 @@
1
+ # src/nthlayer_respond/agents/triage.py
2
+ """TriageAgent — first concrete agent in the Mayday pipeline."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from nthlayer_common.prompts import extract_confidence, load_prompt, render_user_prompt
10
+
11
+ from nthlayer_workers.respond.agents.base import AgentBase
12
+ from nthlayer_workers.respond.types import AgentRole, IncidentContext, TriageResult
13
+
14
+ _PROMPT_PATH = Path(__file__).parent.parent.parent.parent / "prompts" / "triage.yaml"
15
+
16
+
17
+ class TriageAgent(AgentBase):
18
+ """Assess severity, blast radius, affected SLOs, and team assignment.
19
+
20
+ Judgment SLO: severity reversal rate < 10%.
21
+ """
22
+
23
+ role = AgentRole.TRIAGE
24
+ default_timeout = 15
25
+
26
+ # ------------------------------------------------------------------ #
27
+ # Judgment interface #
28
+ # ------------------------------------------------------------------ #
29
+
30
+ def build_prompt(self, context: IncidentContext) -> tuple[str, str]:
31
+ spec = load_prompt(_PROMPT_PATH)
32
+
33
+ parts: list[str] = []
34
+
35
+ if context.trigger_source == "nthlayer-correlate":
36
+ parts.append(
37
+ "You have pre-correlated context from nthlayer-correlate. "
38
+ "The following correlation verdicts informed this incident:"
39
+ )
40
+ for vid in context.trigger_verdict_ids:
41
+ try:
42
+ v = self._verdict_store.get(vid)
43
+ if v is not None:
44
+ parts.append(
45
+ f" - service={v.subject.service!r} "
46
+ f"summary={v.subject.summary!r} "
47
+ f"confidence={v.judgment.confidence} "
48
+ f"reasoning={v.judgment.reasoning!r}"
49
+ )
50
+ except Exception: # noqa: BLE001
51
+ pass
52
+ else:
53
+ parts.append(
54
+ "No pre-correlation available. Raw alert only. "
55
+ "Assess based solely on the topology information below."
56
+ )
57
+
58
+ svc_ctx = self._build_service_context_prompt(context)
59
+ if svc_ctx:
60
+ parts.append(svc_ctx)
61
+
62
+ # Prune topology to trigger service + 1 hop (reduces prompt tokens)
63
+ trigger_svc = (context.metadata or {}).get("trigger_service", "")
64
+ pruned = self._prune_topology(context.topology, [trigger_svc]) if trigger_svc else context.topology
65
+ parts.append(f"\nTopology: {json.dumps(pruned)}")
66
+
67
+ user = render_user_prompt(spec.user_template, context="\n".join(parts))
68
+ return spec.system, user
69
+
70
+ def parse_response(self, response: str, context: IncidentContext) -> TriageResult:
71
+ data = self._parse_json(response)
72
+
73
+ raw_severity = data.get("severity", 2)
74
+ severity = max(0, min(4, int(raw_severity)))
75
+
76
+ raw_blast = data.get("blast_radius") or []
77
+ blast_radius: list[str] = raw_blast if isinstance(raw_blast, list) else [raw_blast] if raw_blast else []
78
+ affected_slos: list[str] = data.get("affected_slos") or []
79
+ assigned_team: str | None = data.get("assigned_team") or data.get("team_assignment")
80
+ reasoning: str = data.get("reasoning", "") or data.get("rationale", "")
81
+ confidence = extract_confidence(data)
82
+
83
+ return TriageResult(
84
+ severity=severity,
85
+ blast_radius=blast_radius,
86
+ affected_slos=affected_slos,
87
+ assigned_team=assigned_team,
88
+ reasoning=reasoning,
89
+ confidence=confidence,
90
+ )
91
+
92
+ def _apply_result(
93
+ self, context: IncidentContext, result: TriageResult
94
+ ) -> IncidentContext:
95
+ context.triage = result
96
+ return context
97
+
98
+ # ------------------------------------------------------------------ #
99
+ # Post-execute hook: autonomy reduction #
100
+ # ------------------------------------------------------------------ #
101
+
102
+ async def _post_execute(
103
+ self, context: IncidentContext, result: Any
104
+ ) -> IncidentContext:
105
+ """Trigger autonomy reduction when a model-update signal is present
106
+ and severity is low enough that the update may have distorted judgment.
107
+
108
+ Condition: any trigger verdict carries tag "agent_model_update"
109
+ AND result.severity <= 2.
110
+ """
111
+ if not (hasattr(result, "severity") and result.severity <= 2):
112
+ return context
113
+
114
+ for vid in context.trigger_verdict_ids:
115
+ try:
116
+ v = self._verdict_store.get(vid)
117
+ tags = (v.judgment.tags or []) if v is not None else []
118
+ if "agent_model_update" in tags:
119
+ arbiter_url: str = self._config.get("arbiter_url", "")
120
+ await self._request_autonomy_reduction(
121
+ agent_name="triage",
122
+ arbiter_url=arbiter_url,
123
+ reason=(
124
+ f"Trigger verdict {vid} flagged agent_model_update; "
125
+ f"incident {context.id} severity={result.severity}"
126
+ ),
127
+ )
128
+ break
129
+ except Exception: # noqa: BLE001
130
+ pass
131
+
132
+ return context