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,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
|