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/reasoning.py ADDED
@@ -0,0 +1,547 @@
1
+ """Reasoning loop v1: turn gathered evidence into a diagnosis.
2
+
3
+ In plain words, this is the part that looks at an incident and
4
+ writes down what it thinks is wrong — and *why* it thinks so.
5
+
6
+ How v1 works, honestly:
7
+
8
+ - **A backend reads the evidence, and the default is plain
9
+ rules.** The loop gathers evidence the same way no matter
10
+ what, then hands it to a diagnosis backend (see
11
+ ``model_backend.py``). The default backend is deterministic:
12
+ a small set of plain rules, no model call, no API key, so
13
+ the tests can check every diagnosis exactly, word for word.
14
+ An operator may instead hand the loop a language-model
15
+ backend built around their own model client — off by
16
+ default, enabled only by building it in code and passing it
17
+ in, with no client shipped in this repository. Whichever
18
+ backend runs, the report has the same shape and names the
19
+ backend, and the loop's own checks below apply to both: a
20
+ claim or cause citing evidence that is not on the incident
21
+ is refused, and a backend that returns no causes gets the
22
+ same "I don't know" escalation. The deterministic backend
23
+ reads counts, statuses and measured values only; the model
24
+ backend's reply is checked as untrusted input before any of
25
+ it can become a claim (``model_backend.py`` lists the rules).
26
+ - **A fixed, bounded plan.** The loop runs at most four read-only
27
+ queries, in a set order: service status, error logs, the error
28
+ metric, then the request metric. Each query counts as one
29
+ step against a step budget. The same query is never run
30
+ twice in one diagnosis. If the budget runs out before the
31
+ plan finishes, the loop stops gathering and escalates.
32
+ - **Selected adapters are extra steps, never a new plan.**
33
+ An operator may select read adapters (Kubernetes, Datadog,
34
+ Azure Monitor — see ``adapters.py``) as extra evidence
35
+ sources. Each selected adapter is asked exactly one fixed
36
+ question, after the four core questions, and counts as one
37
+ more step. Adapter questions are built only from the
38
+ incident's target names, and only when a name is a plain
39
+ service or namespace name — a name carrying query syntax is
40
+ never built into a Datadog or KQL question, and that source
41
+ is skipped for that incident. Adapters are extras, so the
42
+ rules differ from the core plan in one way: if the step
43
+ budget runs out before an adapter step, that step is simply
44
+ skipped and the core diagnosis stands, and an adapter that
45
+ fails is skipped with the failure logged. Adapter answers
46
+ are read as counts and measured values only, exactly like
47
+ the core tools — raw adapter text never becomes a claim.
48
+ - **Memory advice is attached last, and changes nothing.**
49
+ An operator may hand the loop a memory of past incidents
50
+ (``memory.py``). After the diagnosis above is decided — never
51
+ before, never as an input to it — the loop asks that memory
52
+ for its report on this incident and attaches it to the
53
+ incident and to this report: similar past incidents, each
54
+ runbook's record, and suggested runbooks, as advice only.
55
+ The advice proposes nothing and runs nothing; any suggested
56
+ fix still goes through the policy engine and a human
57
+ approval like any other. A memory that fails is skipped
58
+ with the failure logged, exactly like a failing adapter —
59
+ advice can never discard a diagnosis.
60
+ - **Every claim cites evidence.** A claim or a hypothesis with
61
+ no evidence behind it is refused by its own data model, and
62
+ the loop checks again that every cited id is a real evidence
63
+ record on the incident before it writes anything down.
64
+ - **"I don't know" is a normal answer.** If the gathered
65
+ evidence shows no clear problem — the service looks healthy,
66
+ there are no error logs, and the error metric is quiet — the
67
+ loop says so, sets no diagnosis, and moves the incident to
68
+ "escalated" for a human. Guessing confidently is a bug here.
69
+ - **Telemetry is data, never orders.** Log lines can say
70
+ anything, including attacks. The default backend's claims
71
+ are built from counts, statuses and measured values only;
72
+ raw log text is never copied into a claim, and nothing in a
73
+ report can propose or run an action. Fixes come in a later
74
+ phase, through the policy engine and a human approval.
75
+ """
76
+
77
+ from __future__ import annotations
78
+
79
+ import re
80
+ from enum import Enum
81
+
82
+ from pydantic import BaseModel, Field
83
+
84
+ from .adapters import (
85
+ AdapterSources,
86
+ AzureQueryResult,
87
+ KqlQueryRequest,
88
+ KubernetesPodsResult,
89
+ ListPodsRequest,
90
+ )
91
+ from .logging_config import get_logger
92
+ from .memory import IncidentMemory
93
+ from .model_backend import (
94
+ BackendContext,
95
+ DeterministicBackend,
96
+ DiagnosisBackend,
97
+ )
98
+ from .models import (
99
+ DiagnosisClaim,
100
+ Evidence,
101
+ Hypothesis,
102
+ Incident,
103
+ IncidentStatus,
104
+ MemoryReport,
105
+ Target,
106
+ )
107
+ from .tools import (
108
+ QueryMetricsRequest,
109
+ QueryMetricsResult,
110
+ SearchLogsRequest,
111
+ ToolLayer,
112
+ demo_tool_layer,
113
+ )
114
+
115
+ log = get_logger(__name__)
116
+
117
+ # The fixed evidence plan: these two metrics, in this order.
118
+ ERROR_METRIC = "http_request_errors_total"
119
+ REQUEST_METRIC = "http_requests_total"
120
+ DEFAULT_MAX_STEPS = 25
121
+
122
+ # A name that may be built into a fixed adapter question: a plain
123
+ # service name — letters, digits, dots, underscores, dashes — and
124
+ # nothing else. A target name carrying spaces, quotes or query
125
+ # syntax fails this check, and the Datadog and Azure steps are
126
+ # skipped for that incident rather than built from it.
127
+ _SAFE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
128
+
129
+
130
+ class DiagnosisOutcome(str, Enum):
131
+ DIAGNOSED = "diagnosed"
132
+ INSUFFICIENT_EVIDENCE = "insufficient_evidence"
133
+
134
+
135
+ class DiagnosisReport(BaseModel):
136
+ """The full result of one run of the reasoning loop."""
137
+
138
+ incident_id: str
139
+ outcome: DiagnosisOutcome
140
+ summary: str
141
+ claims: list[DiagnosisClaim] = Field(default_factory=list)
142
+ hypotheses: list[Hypothesis] = Field(default_factory=list)
143
+ gathered_evidence: list[Evidence] = Field(default_factory=list)
144
+ steps_used: int = 0
145
+ queries_run: list[str] = Field(default_factory=list)
146
+ # Which backend produced the claims and causes above:
147
+ # "deterministic" (the default) or "language-model". The
148
+ # shape is the same either way; the name is recorded so a
149
+ # score can always be tied to the backend that earned it.
150
+ backend_name: str = "deterministic"
151
+ # Past-incident advice, attached after the diagnosis was
152
+ # decided when the loop was built with a memory. Advice
153
+ # only: it influenced no claim, cause or action above.
154
+ memory_report: MemoryReport | None = None
155
+
156
+ def render_text(self) -> str:
157
+ """Write the report as plain text a human can check.
158
+
159
+ Every claim is printed with the exact query behind the
160
+ evidence it cites, so a reader can re-run that query and
161
+ see the same thing the agent saw.
162
+ """
163
+ by_id = {ev.id: ev for ev in self.gathered_evidence}
164
+ lines = [
165
+ f"Diagnosis for incident {self.incident_id}",
166
+ f"Outcome: {self.outcome.value}",
167
+ f"Backend: {self.backend_name}",
168
+ f"Summary: {self.summary}",
169
+ f"Steps used: {self.steps_used}",
170
+ "Claims (each one backed by the evidence named under it):",
171
+ ]
172
+ if not self.claims:
173
+ lines.append("- No claims: the evidence did not support a diagnosis.")
174
+ for claim in self.claims:
175
+ lines.append(f"- {claim.statement} (confidence {claim.confidence:.2f})")
176
+ for ev_id in claim.evidence_ids:
177
+ ev = by_id.get(ev_id)
178
+ if ev is not None:
179
+ lines.append(f" evidence: {ev.query}")
180
+ if self.hypotheses:
181
+ lines.append("Possible causes, most likely first:")
182
+ for hyp in self.hypotheses:
183
+ lines.append(
184
+ f"- {hyp.statement} (confidence {hyp.confidence:.2f}, "
185
+ f"supported by {len(hyp.supporting_evidence)} evidence record(s))"
186
+ )
187
+ if self.memory_report is not None:
188
+ report = self.memory_report
189
+ lines.append(
190
+ "Memory advice (advice only — it changed no claim, "
191
+ "cause or action):"
192
+ )
193
+ if not report.matches:
194
+ lines.append("- No similar past incident was found.")
195
+ for match in report.matches:
196
+ lines.append(
197
+ f"- Past incident {match.incident_id} "
198
+ f"[{match.status.value}] score {match.score}: "
199
+ f"{match.title}"
200
+ )
201
+ if not report.suggestions:
202
+ lines.append(
203
+ "- No runbook is suggested: no similar past "
204
+ "incident ran one."
205
+ )
206
+ for suggestion in report.suggestions:
207
+ lines.append(
208
+ f"- Suggested runbook {suggestion.runbook_id}: "
209
+ f"{suggestion.reason}"
210
+ )
211
+ return "\n".join(lines)
212
+
213
+
214
+ class ReasoningLoop:
215
+ """The bounded reasoning loop, over a pluggable backend.
216
+
217
+ Gathering is the loop's; reading the gathered evidence is
218
+ the backend's. With no backend given, the deterministic
219
+ backend runs — the default, everywhere this loop is used.
220
+ """
221
+
222
+ def __init__(
223
+ self,
224
+ tools: ToolLayer,
225
+ max_steps: int = DEFAULT_MAX_STEPS,
226
+ adapters: AdapterSources | None = None,
227
+ memory: IncidentMemory | None = None,
228
+ backend: DiagnosisBackend | None = None,
229
+ ) -> None:
230
+ if max_steps < 1:
231
+ raise ValueError("max_steps must be at least 1")
232
+ self._tools = tools
233
+ self._max_steps = max_steps
234
+ self._adapters = adapters
235
+ self._memory = memory
236
+ self._backend: DiagnosisBackend = (
237
+ backend if backend is not None else DeterministicBackend()
238
+ )
239
+
240
+ @property
241
+ def backend_name(self) -> str:
242
+ """The name of the backend this loop diagnoses with."""
243
+ return self._backend.name
244
+
245
+ def diagnose(self, incident: Incident) -> DiagnosisReport:
246
+ """Gather evidence for *incident* and write a diagnosis.
247
+
248
+ Moves the incident to "diagnosed" when the evidence
249
+ supports one, or to "escalated" when it does not (or the
250
+ step budget ran out). Any other starting state is a
251
+ programming error and raises.
252
+ """
253
+ if incident.status is IncidentStatus.OPEN:
254
+ incident.transition(IncidentStatus.TRIAGING)
255
+ if incident.status is not IncidentStatus.TRIAGING:
256
+ raise ValueError(
257
+ f"cannot diagnose an incident in status {incident.status.value!r}"
258
+ )
259
+
260
+ gathered: list[Evidence] = []
261
+ queries_run: list[str] = []
262
+ seen_queries: set[str] = set()
263
+ steps = 0
264
+ budget_exhausted = False
265
+
266
+ def run_once(key: str) -> bool:
267
+ """Count one step. False means: stop, budget is gone.
268
+
269
+ The same query key is never counted or run twice.
270
+ """
271
+ nonlocal steps, budget_exhausted
272
+ if key in seen_queries:
273
+ return True
274
+ if steps >= self._max_steps:
275
+ budget_exhausted = True
276
+ return False
277
+ seen_queries.add(key)
278
+ queries_run.append(key)
279
+ steps += 1
280
+ return True
281
+
282
+ def run_adapter_once(key: str) -> bool:
283
+ """Count one adapter step. False means: skip this
284
+ source — the budget is gone, but adapters are extras,
285
+ so skipping one never discards the core diagnosis the
286
+ way an unfinished core plan does."""
287
+ nonlocal steps
288
+ if key in seen_queries:
289
+ return True
290
+ if steps >= self._max_steps:
291
+ return False
292
+ seen_queries.add(key)
293
+ queries_run.append(key)
294
+ steps += 1
295
+ return True
296
+
297
+ target = incident.target
298
+
299
+ # Step 1: is the service itself up, degraded, or down?
300
+ status_result = None
301
+ status_ev: Evidence | None = None
302
+ if run_once(f"service status: {target.service}"):
303
+ status_result = self._tools.get_service_status(target)
304
+ status_ev = status_result.to_evidence()
305
+ gathered.append(status_ev)
306
+ incident.add_evidence(status_ev)
307
+
308
+ # Step 2: what do its error logs say (in counts, not words)?
309
+ logs_result = None
310
+ logs_ev: Evidence | None = None
311
+ if not budget_exhausted and run_once(f"error logs: {target.service}"):
312
+ logs_result = self._tools.search_logs(
313
+ SearchLogsRequest(
314
+ service=target.service,
315
+ namespace=target.namespace,
316
+ level="ERROR",
317
+ )
318
+ )
319
+ logs_ev = logs_result.to_evidence()
320
+ gathered.append(logs_ev)
321
+ incident.add_evidence(logs_ev)
322
+
323
+ # Step 3: are errors showing up in the metrics?
324
+ errors_result = None
325
+ errors_ev: Evidence | None = None
326
+ if not budget_exhausted and run_once(f"promql: {ERROR_METRIC}"):
327
+ errors_result = self._tools.query_metrics(
328
+ QueryMetricsRequest(query=ERROR_METRIC)
329
+ )
330
+ errors_ev = errors_result.to_evidence()
331
+ gathered.append(errors_ev)
332
+ incident.add_evidence(errors_ev)
333
+
334
+ # Step 4: how much traffic is there, for context? It is
335
+ # gathered and stored, but it never creates a claim or a
336
+ # hypothesis on its own: traffic alone diagnoses nothing.
337
+ if not budget_exhausted and run_once(f"promql: {REQUEST_METRIC}"):
338
+ requests_ev = self._tools.query_metrics(
339
+ QueryMetricsRequest(query=REQUEST_METRIC)
340
+ ).to_evidence()
341
+ gathered.append(requests_ev)
342
+ incident.add_evidence(requests_ev)
343
+
344
+ # Extra steps, only for adapters the operator selected.
345
+ # Each is one fixed question. A source that fails, or a
346
+ # target name that may not be built into its question,
347
+ # skips that source only — the diagnosis proceeds with
348
+ # whatever the other sources said.
349
+ adapters = self._adapters
350
+ pods_result: KubernetesPodsResult | None = None
351
+ pods_ev: Evidence | None = None
352
+ datadog_result: QueryMetricsResult | None = None
353
+ datadog_ev: Evidence | None = None
354
+ azure_result: AzureQueryResult | None = None
355
+ azure_ev: Evidence | None = None
356
+ if adapters is not None and not budget_exhausted:
357
+ if adapters.kubernetes is not None and run_adapter_once(
358
+ f"kubernetes pods: {target.namespace}/{target.service}"
359
+ ):
360
+ try:
361
+ pods_result = adapters.kubernetes.list_pods(
362
+ ListPodsRequest(
363
+ namespace=target.namespace, service=target.service
364
+ )
365
+ )
366
+ pods_ev = pods_result.to_evidence()
367
+ gathered.append(pods_ev)
368
+ incident.add_evidence(pods_ev)
369
+ except Exception:
370
+ log.exception(
371
+ "kubernetes adapter failed for incident %s; "
372
+ "that source is skipped",
373
+ incident.id,
374
+ )
375
+ pods_result = None
376
+ pods_ev = None
377
+ if adapters.datadog is not None:
378
+ if not _SAFE_NAME_RE.match(target.service):
379
+ log.warning(
380
+ "datadog adapter skipped for incident %s: the "
381
+ "service name is not a plain name",
382
+ incident.id,
383
+ )
384
+ elif run_adapter_once(f"datadog: {ERROR_METRIC}"):
385
+ try:
386
+ datadog_result = adapters.datadog.query_metrics(
387
+ QueryMetricsRequest(
388
+ query=(
389
+ f"sum:{ERROR_METRIC}"
390
+ f"{{service:{target.service}}}"
391
+ )
392
+ )
393
+ )
394
+ datadog_ev = datadog_result.to_evidence()
395
+ gathered.append(datadog_ev)
396
+ incident.add_evidence(datadog_ev)
397
+ except Exception:
398
+ log.exception(
399
+ "datadog adapter failed for incident %s; "
400
+ "that source is skipped",
401
+ incident.id,
402
+ )
403
+ datadog_result = None
404
+ datadog_ev = None
405
+ if adapters.azure is not None:
406
+ if not _SAFE_NAME_RE.match(target.service):
407
+ log.warning(
408
+ "azure adapter skipped for incident %s: the "
409
+ "service name is not a plain name",
410
+ incident.id,
411
+ )
412
+ elif run_adapter_once("azure kql: error rows"):
413
+ try:
414
+ azure_result = adapters.azure.query(
415
+ KqlQueryRequest(
416
+ query=(
417
+ "AppTraces | where "
418
+ f"Service == '{target.service}' "
419
+ "and Level == 'Error'"
420
+ )
421
+ )
422
+ )
423
+ azure_ev = azure_result.to_evidence()
424
+ gathered.append(azure_ev)
425
+ incident.add_evidence(azure_ev)
426
+ except Exception:
427
+ log.exception(
428
+ "azure adapter failed for incident %s; "
429
+ "that source is skipped",
430
+ incident.id,
431
+ )
432
+ azure_result = None
433
+ azure_ev = None
434
+
435
+ claims, hypotheses = self._backend.reason(
436
+ BackendContext(
437
+ incident=incident,
438
+ evidence=list(gathered),
439
+ status_result=status_result,
440
+ status_ev=status_ev,
441
+ logs_result=logs_result,
442
+ logs_ev=logs_ev,
443
+ errors_result=errors_result,
444
+ errors_ev=errors_ev,
445
+ pods_result=pods_result,
446
+ pods_ev=pods_ev,
447
+ datadog_result=datadog_result,
448
+ datadog_ev=datadog_ev,
449
+ azure_result=azure_result,
450
+ azure_ev=azure_ev,
451
+ )
452
+ )
453
+ # The same shaping for every backend: causes most
454
+ # likely first, at most three of them.
455
+ hypotheses = sorted(
456
+ hypotheses, key=lambda h: h.confidence, reverse=True
457
+ )[:3]
458
+
459
+ # Final honesty check: nothing may cite evidence that is
460
+ # not really on this incident.
461
+ known_ids = {ev.id for ev in incident.evidence}
462
+ for claim in claims:
463
+ missing = [i for i in claim.evidence_ids if i not in known_ids]
464
+ if missing:
465
+ raise ValueError(f"claim cites evidence not on the incident: {missing}")
466
+ for hyp in hypotheses:
467
+ missing = [i for i in hyp.supporting_evidence if i not in known_ids]
468
+ if missing:
469
+ raise ValueError(
470
+ f"hypothesis cites evidence not on the incident: {missing}"
471
+ )
472
+
473
+ if budget_exhausted:
474
+ summary = (
475
+ "I don't know: the step budget ran out before the evidence "
476
+ "plan finished, so this needs a human."
477
+ )
478
+ incident.transition(IncidentStatus.ESCALATED)
479
+ outcome = DiagnosisOutcome.INSUFFICIENT_EVIDENCE
480
+ # A half-finished plan supports no claims at all, even
481
+ # ones the gathered part suggested on its own.
482
+ claims = []
483
+ hypotheses = []
484
+ elif hypotheses:
485
+ summary = f"Most likely: {hypotheses[0].statement}"
486
+ incident.diagnosis = summary
487
+ incident.transition(IncidentStatus.DIAGNOSED)
488
+ outcome = DiagnosisOutcome.DIAGNOSED
489
+ else:
490
+ summary = (
491
+ "I don't know: the evidence gathered shows no clear problem, "
492
+ "so this needs a human rather than a guess."
493
+ )
494
+ incident.transition(IncidentStatus.ESCALATED)
495
+ outcome = DiagnosisOutcome.INSUFFICIENT_EVIDENCE
496
+
497
+ incident.hypotheses.extend(hypotheses)
498
+
499
+ # Memory advice comes last, on purpose: the outcome,
500
+ # claims and causes above are already decided, so the
501
+ # advice cannot have influenced them, and a memory that
502
+ # fails is skipped rather than discarding the diagnosis.
503
+ memory_report: MemoryReport | None = None
504
+ if self._memory is not None:
505
+ try:
506
+ memory_report = self._memory.report(incident)
507
+ incident.memory_report = memory_report
508
+ except Exception:
509
+ log.exception(
510
+ "memory failed for incident %s; its advice is "
511
+ "skipped and the diagnosis stands",
512
+ incident.id,
513
+ )
514
+ memory_report = None
515
+
516
+ log.info(
517
+ "diagnosed incident %s -> %s in %d step(s)",
518
+ incident.id,
519
+ outcome.value,
520
+ steps,
521
+ )
522
+ return DiagnosisReport(
523
+ incident_id=incident.id,
524
+ outcome=outcome,
525
+ summary=summary,
526
+ claims=claims,
527
+ hypotheses=hypotheses,
528
+ gathered_evidence=gathered,
529
+ steps_used=steps,
530
+ queries_run=queries_run,
531
+ backend_name=self._backend.name,
532
+ memory_report=memory_report,
533
+ )
534
+
535
+ def demo_diagnosis() -> DiagnosisReport:
536
+ """Run the loop once on the seeded demo data, with no setup.
537
+
538
+ The demo incident is the checkout service from
539
+ ``demo_tool_layer``: degraded, timing out, with errors in its
540
+ metrics. The report claims nothing about any real system.
541
+ """
542
+ incident = Incident(
543
+ title="HighErrorRate on checkout",
544
+ target=Target(environment="demo", namespace="shop", service="checkout"),
545
+ )
546
+ incident.transition(IncidentStatus.TRIAGING)
547
+ return ReasoningLoop(demo_tool_layer()).diagnose(incident)