sre-agent-cli 0.2.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.
sre_agent/memory.py ADDED
@@ -0,0 +1,411 @@
1
+ """Memory: past incidents, found again; runbook stats, kept honestly.
2
+
3
+ In plain words, this is the agent's memory of its own past work.
4
+ When a new incident arrives, an on-call engineer asks "have we
5
+ seen this before, and what fixed it last time?" This module is
6
+ that question, in code. It has three parts:
7
+
8
+ - **Storage** (``IncidentMemory``). Past incidents are kept in
9
+ memory, behind a small interface — the same pattern the
10
+ incident store, the tools and the runbooks use. Records are
11
+ stored as copies and handed back as match summaries, so a
12
+ caller cannot rewrite history by editing what it was given.
13
+ Incidents themselves can now be stored in a database
14
+ (``storage.py``, Phase 7); feeding a server's own stored,
15
+ resolved incidents back in as its memory is not wired yet,
16
+ and nothing here claims it is.
17
+
18
+ - **Retrieval** (``find_similar``). A new incident is scored
19
+ against each past one with fixed, explainable points: the
20
+ same service counts most, then the same namespace, then the
21
+ same environment, then words shared between the two records'
22
+ titles, diagnoses and recorded possible causes (a few points
23
+ at most, so wording can never outweigh the target). Every
24
+ match lists the reasons for its score, in plain words. There
25
+ is no model and no embedding here, on purpose — the same
26
+ choice the reasoning loop made — so every ranking can be
27
+ checked exactly in a test. A past record of the query incident
28
+ itself (same id) is never returned as its own match.
29
+
30
+ - **Runbook statistics and suggestions** (``runbook_stats``,
31
+ ``suggest_runbooks``). Every runbook run leaves an action
32
+ record on its incident (``runbooks.py`` builds it), so the
33
+ stats are counted from those records, never from a separate
34
+ tally that could drift. A run counts as "ended resolved" when
35
+ its incident's final status is resolved, and as "not resolved"
36
+ otherwise. That wording is deliberate, and it is this pass's
37
+ one design lesson: the record shows the incident *ended*
38
+ resolved after the run — it does not prove the run *caused*
39
+ the resolution, so the stats never claim it did. An incident
40
+ that is still open, or was escalated to a human, is counted as
41
+ not resolved, never as a failure of the runbook.
42
+
43
+ How suggestions use the stats — the "influence on proposals"
44
+ the roadmap asks for, kept inside its proper bounds:
45
+
46
+ - Suggestions are ranked on the stats of *same-service* past
47
+ incidents only. Retrieval also returns neighbours — the same
48
+ namespace, a similar title — because a human may learn from
49
+ them; but a fix's track record may only influence a proposal
50
+ for the service it actually ran on, so neighbours never count
51
+ toward a suggestion.
52
+ - A suggestion is advice, Tier 1: it names the runbook, its
53
+ record on similar incidents, and the past incidents behind
54
+ that record. It runs nothing, proposes nothing through the
55
+ policy engine, and approves nothing — a human or a later
56
+ proposal step still takes any suggested fix through the
57
+ normal Tier 2 approval or Tier 3 grant. Memory can inform a
58
+ proposal; it can never become one by itself. (The
59
+ architecture doc already states this rule.)
60
+ - Only runbooks in the runbook library are ever suggested, and
61
+ only when at least one similar past incident actually ran
62
+ them. No history, no suggestion — memory never invents a
63
+ track record.
64
+
65
+ Scope, stated honestly: memory here is in-process and seeded
66
+ for the demo (``demo_memory``, ``demo_incident_memory``).
67
+ Since Phase 7 it can also be wired into the live path: an
68
+ operator may hand an ``IncidentMemory`` to the reasoning loop
69
+ (or to the server, which passes it on), and the loop then
70
+ attaches this module's report to the incident it diagnosed —
71
+ after the diagnosis is decided, as advice only. The report is
72
+ never an input to a claim, a cause, a proposal or an action,
73
+ and a memory that fails is skipped, never fatal. Suggestions
74
+ are also still reported on their own, through ``sre-agent
75
+ memory-demo``. Nothing here claims otherwise.
76
+ """
77
+
78
+ from __future__ import annotations
79
+
80
+ import re
81
+
82
+ from .logging_config import get_logger
83
+ from .models import (
84
+ Evidence,
85
+ Incident,
86
+ IncidentStatus,
87
+ MemoryReport,
88
+ PastIncidentMatch,
89
+ RunbookStats,
90
+ RunbookSuggestion,
91
+ Target,
92
+ )
93
+ from .runbooks import RUNBOOK_PARAMETERS
94
+
95
+ # The report models live in models.py (an incident record can
96
+ # carry a report); they are re-exported here so existing
97
+ # imports from this module keep working.
98
+ __all__ = [
99
+ "IncidentMemory",
100
+ "MemoryReport",
101
+ "PastIncidentMatch",
102
+ "RunbookStats",
103
+ "RunbookSuggestion",
104
+ "demo_incident_memory",
105
+ "demo_memory",
106
+ ]
107
+
108
+ log = get_logger(__name__)
109
+
110
+ # The fixed retrieval points. Same service is the strongest
111
+ # signal — "last time this service broke" is the question being
112
+ # answered — then where it lives, then the words the two records
113
+ # share, capped so wording can never outweigh the target.
114
+ POINTS_SAME_SERVICE = 4
115
+ POINTS_SAME_NAMESPACE = 2
116
+ POINTS_SAME_ENVIRONMENT = 1
117
+ POINTS_PER_SHARED_WORD = 1
118
+ MAX_WORD_POINTS = 3
119
+ # A match must be worth reporting: same environment alone (1) or
120
+ # a couple of shared words alone is not a memory, it is a guess.
121
+ MIN_MATCH_SCORE = 3
122
+
123
+ _WORD_RE = re.compile(r"[a-z0-9]+")
124
+ _STOP_WORDS = {
125
+ "a", "an", "and", "are", "as", "at", "be", "by", "for", "from",
126
+ "in", "is", "it", "of", "on", "or", "the", "to", "was", "were",
127
+ "with", "this", "that", "its",
128
+ }
129
+
130
+
131
+ def _words(text: str) -> set[str]:
132
+ return {w for w in _WORD_RE.findall(text.lower()) if w not in _STOP_WORDS and len(w) > 2}
133
+
134
+
135
+ def _record_words(incident: Incident) -> set[str]:
136
+ words = _words(incident.title)
137
+ if incident.diagnosis:
138
+ words |= _words(incident.diagnosis)
139
+ for hyp in incident.hypotheses:
140
+ words |= _words(hyp.statement)
141
+ return words
142
+
143
+
144
+ def _action_runbook_ids(incident: Incident) -> list[str]:
145
+ """The runbooks that ran on *incident*, from its own records.
146
+
147
+ A runbook run leaves an evidence record of kind ``action``
148
+ whose query begins ``runbook:<id> `` (built in
149
+ ``runbooks.py``). Anything else is not a run and is ignored.
150
+ """
151
+ ids: list[str] = []
152
+ for ev in incident.evidence:
153
+ if ev.kind != "action" or not ev.query.startswith("runbook:"):
154
+ continue
155
+ runbook_id = ev.query.removeprefix("runbook:").split(" ", 1)[0].strip()
156
+ if runbook_id:
157
+ ids.append(runbook_id)
158
+ return ids
159
+
160
+
161
+ def _score(query: Incident, past: Incident) -> tuple[int, list[str]]:
162
+ score = 0
163
+ reasons: list[str] = []
164
+ if past.target.service == query.target.service:
165
+ score += POINTS_SAME_SERVICE
166
+ reasons.append(f"same service ({query.target.service})")
167
+ if past.target.namespace == query.target.namespace:
168
+ score += POINTS_SAME_NAMESPACE
169
+ reasons.append(f"same namespace ({query.target.namespace})")
170
+ if past.target.environment == query.target.environment:
171
+ score += POINTS_SAME_ENVIRONMENT
172
+ reasons.append(f"same environment ({query.target.environment})")
173
+ shared = sorted(_record_words(query) & _record_words(past))
174
+ if shared:
175
+ points = min(len(shared) * POINTS_PER_SHARED_WORD, MAX_WORD_POINTS)
176
+ score += points
177
+ shown = ", ".join(shared[:MAX_WORD_POINTS])
178
+ reasons.append(f"shared words in the records: {shown} (+{points})")
179
+ return score, reasons
180
+
181
+
182
+ def _stats_for(incidents: list[Incident]) -> list[RunbookStats]:
183
+ by_id: dict[str, RunbookStats] = {}
184
+ for incident in incidents:
185
+ for runbook_id in _action_runbook_ids(incident):
186
+ stats = by_id.setdefault(runbook_id, RunbookStats(runbook_id=runbook_id))
187
+ stats.attempts += 1
188
+ if incident.status is IncidentStatus.RESOLVED:
189
+ stats.ended_resolved += 1
190
+ else:
191
+ stats.not_resolved += 1
192
+ return [by_id[key] for key in sorted(by_id)]
193
+
194
+
195
+ class IncidentMemory:
196
+ """Past incidents, stored as copies, behind a small interface."""
197
+
198
+ def __init__(self, incidents: list[Incident] | None = None) -> None:
199
+ self._incidents: list[Incident] = []
200
+ for incident in incidents or []:
201
+ self.add(incident)
202
+
203
+ def __len__(self) -> int:
204
+ return len(self._incidents)
205
+
206
+ def add(self, incident: Incident) -> None:
207
+ """Store a copy of *incident* as a past incident."""
208
+ self._incidents.append(incident.model_copy(deep=True))
209
+
210
+ def incidents(self) -> list[Incident]:
211
+ """Copies of everything held, oldest first."""
212
+ return [incident.model_copy(deep=True) for incident in self._incidents]
213
+
214
+ def find_similar(self, query: Incident, *, limit: int = 5) -> list[PastIncidentMatch]:
215
+ """Past incidents similar to *query*, most similar first.
216
+
217
+ The query incident itself is never its own match. Only
218
+ scores at or above ``MIN_MATCH_SCORE`` are reported.
219
+ Ties are broken by incident id, so the order is stable
220
+ and a test can check it exactly.
221
+ """
222
+ if limit < 1:
223
+ raise ValueError("limit must be at least 1")
224
+ matches: list[PastIncidentMatch] = []
225
+ for past in self._incidents:
226
+ if past.id == query.id:
227
+ continue
228
+ score, reasons = _score(query, past)
229
+ if score < MIN_MATCH_SCORE:
230
+ continue
231
+ matches.append(
232
+ PastIncidentMatch(
233
+ incident_id=past.id,
234
+ title=past.title,
235
+ target=past.target,
236
+ status=past.status,
237
+ diagnosis=past.diagnosis,
238
+ score=score,
239
+ reasons=reasons,
240
+ runbooks_run=_action_runbook_ids(past),
241
+ )
242
+ )
243
+ matches.sort(key=lambda m: (-m.score, m.incident_id))
244
+ return matches[:limit]
245
+
246
+ def runbook_stats(self) -> list[RunbookStats]:
247
+ """Every runbook's record across all past incidents held."""
248
+ return _stats_for(self._incidents)
249
+
250
+ def suggest_runbooks(self, query: Incident, *, limit: int = 3) -> list[RunbookSuggestion]:
251
+ """Runbooks worth considering for *query*, from same-service past.
252
+
253
+ Only matches on the query's own service count toward a
254
+ suggestion (see the module note: neighbours are shown for
255
+ reading, never counted here). Ranked by resolved share on
256
+ those incidents, then by how many runs back that share,
257
+ then by runbook id — a fixed order a test can check.
258
+ Advice only: this builds no proposal and runs nothing.
259
+ """
260
+ if limit < 1:
261
+ raise ValueError("limit must be at least 1")
262
+ matches = self.find_similar(query, limit=len(self._incidents) or 1)
263
+ match_ids = {
264
+ m.incident_id for m in matches if m.target.service == query.target.service
265
+ }
266
+ similar = [i for i in self._incidents if i.id in match_ids]
267
+ suggestions: list[RunbookSuggestion] = []
268
+ for stats in _stats_for(similar):
269
+ if stats.runbook_id not in RUNBOOK_PARAMETERS:
270
+ continue
271
+ supporting = [
272
+ incident.id
273
+ for incident in similar
274
+ if stats.runbook_id in _action_runbook_ids(incident)
275
+ ]
276
+ suggestions.append(
277
+ RunbookSuggestion(
278
+ runbook_id=stats.runbook_id,
279
+ attempts_on_similar=stats.attempts,
280
+ ended_resolved_on_similar=stats.ended_resolved,
281
+ success_rate_on_similar=stats.success_rate,
282
+ supporting_incident_ids=sorted(supporting),
283
+ reason=(
284
+ f"on same-service past incidents it ran {stats.attempts} time(s) and "
285
+ f"{stats.ended_resolved} of those incidents ended resolved "
286
+ f"({stats.success_rate:.0%}); past incident(s): "
287
+ f"{', '.join(sorted(supporting))}"
288
+ ),
289
+ )
290
+ )
291
+ suggestions.sort(
292
+ key=lambda s: (-s.success_rate_on_similar, -s.attempts_on_similar, s.runbook_id)
293
+ )
294
+ return suggestions[:limit]
295
+
296
+ def report(self, query: Incident) -> MemoryReport:
297
+ """One report: similar past, overall stats, suggestions."""
298
+ report = MemoryReport(
299
+ query_incident_id=query.id,
300
+ query_target=query.target,
301
+ past_incidents_held=len(self._incidents),
302
+ matches=self.find_similar(query),
303
+ overall_stats=self.runbook_stats(),
304
+ suggestions=self.suggest_runbooks(query),
305
+ )
306
+ log.info(
307
+ "memory report for incident %s: %d match(es), %d suggestion(s)",
308
+ query.id,
309
+ len(report.matches),
310
+ len(report.suggestions),
311
+ )
312
+ return report
313
+
314
+
315
+ def _past_incident(
316
+ incident_id: str,
317
+ *,
318
+ service: str,
319
+ title: str,
320
+ diagnosis: str,
321
+ runbook_id: str,
322
+ resolved: bool,
323
+ ) -> Incident:
324
+ """One seeded past incident, walked through its real life cycle."""
325
+ target = Target(environment="demo", namespace="shop", service=service)
326
+ incident = Incident(id=incident_id, title=title, target=target)
327
+ incident.transition(IncidentStatus.TRIAGING)
328
+ incident.diagnosis = diagnosis
329
+ incident.transition(IncidentStatus.DIAGNOSED)
330
+ incident.add_evidence(
331
+ Evidence(
332
+ kind="action",
333
+ query=f"runbook:{runbook_id} on {service}/shop (demo)",
334
+ summary=f"{runbook_id} ran on {service} in the demo.",
335
+ )
336
+ )
337
+ if resolved:
338
+ incident.transition(IncidentStatus.MITIGATING)
339
+ incident.transition(IncidentStatus.RESOLVED)
340
+ else:
341
+ incident.transition(IncidentStatus.ESCALATED)
342
+ return incident
343
+
344
+
345
+ def demo_incident_memory() -> IncidentMemory:
346
+ """The seeded demo past, as a memory a caller can wire in.
347
+
348
+ The same four past incidents ``demo_memory`` reports on,
349
+ handed back as the memory itself, so the reasoning loop or
350
+ the server can be built with it (the shipped server app is)
351
+ and attach its advice to live demo diagnoses.
352
+ """
353
+ return IncidentMemory(
354
+ [
355
+ _past_incident(
356
+ "past-checkout-1",
357
+ service="checkout",
358
+ title="HighErrorRate on checkout",
359
+ diagnosis="Most likely: a call that checkout depends on is timing out",
360
+ runbook_id="restart-workload",
361
+ resolved=True,
362
+ ),
363
+ _past_incident(
364
+ "past-checkout-2",
365
+ service="checkout",
366
+ title="Checkout errors are above 10%",
367
+ diagnosis="Most likely: checkout is failing on some requests",
368
+ runbook_id="restart-workload",
369
+ resolved=True,
370
+ ),
371
+ _past_incident(
372
+ "past-checkout-3",
373
+ service="checkout",
374
+ title="Checkout errors are above 10%",
375
+ diagnosis="Most likely: checkout is failing on some requests",
376
+ runbook_id="scale-replicas",
377
+ resolved=False,
378
+ ),
379
+ _past_incident(
380
+ "past-search-1",
381
+ service="search",
382
+ title="Search errors are above 10%",
383
+ diagnosis="Most likely: search is failing on some requests",
384
+ runbook_id="restart-workload",
385
+ resolved=True,
386
+ ),
387
+ ]
388
+ )
389
+
390
+
391
+ def demo_memory() -> MemoryReport:
392
+ """A seeded memory report for a new checkout incident.
393
+
394
+ The past is the four demo incidents held by
395
+ ``demo_incident_memory``: checkout broke twice and a
396
+ restart was run both times, and both incidents ended
397
+ resolved; checkout broke once more, a scale was run, and
398
+ that incident was escalated, not resolved; and the search
399
+ service broke once, a restart was run, and it ended
400
+ resolved. The query is a fresh checkout incident, so the
401
+ report shows retrieval favouring the same service, overall
402
+ restart stats of 3 runs and 3 resolved, and a suggestion
403
+ order — restart before scale — that comes only from the
404
+ same-service (checkout) past, not from the search record.
405
+ """
406
+ query = Incident(
407
+ id="new-checkout-1",
408
+ title="Checkout errors are above 10%",
409
+ target=Target(environment="demo", namespace="shop", service="checkout"),
410
+ )
411
+ return demo_incident_memory().report(query)