devopsiq 0.1.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.
agent/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Agent package.
2
+
3
+ Phase 2: the DevOpsAgent holds the system prompt + conversation history and
4
+ runs the tool-use loop (see agent.py and the tool registry in tools/).
5
+ """
agent/agent.py ADDED
@@ -0,0 +1,232 @@
1
+ """Core agent: the conversation + tool-use loop to the LLM backend.
2
+
3
+ Phase 2 builds the tool-use loop on top of the Phase 1 skeleton, Phase 4
4
+ adds the investigation loop on top of that, and Phase 5 broadens coverage
5
+ from Kubernetes-only to the wider DevOps surface:
6
+
7
+ user reports a problem
8
+ -> the model opens an investigation (investigation_begin)
9
+ -> it gathers evidence with the read-only tools — Kubernetes,
10
+ Linux systemd/journal/journal/ports/processes, Docker containers,
11
+ Terraform state/plan, git/gh — tracking hypotheses and evidence in
12
+ a first-class record (investigation_record)
13
+ -> it concludes with root cause + remediation + verification
14
+ (investigation_conclude), ending with a structured report
15
+
16
+ The tool-use loop itself is unchanged: every model call funnels through the
17
+ single `_complete()` chokepoint, and the investigation tools are just three
18
+ more registered tools — they persist state in agent/investigation.py, which
19
+ mutates nothing outside the agent's own memory. The read-only guarantee
20
+ holds: every tool is a fixed, allowlisted argv template; no tool can touch
21
+ a cluster, a file, or infrastructure beyond reading it.
22
+
23
+ Phase 7 makes that record durable: every mutation is auto-saved to the
24
+ InvestigationStore (agent/store.py), so the investigation survives CLI
25
+ exits; the delegates below expose resume/list/store-location to the CLI.
26
+ """
27
+
28
+ import os
29
+
30
+ from openai import OpenAI
31
+
32
+ from agent.prompts import SYSTEM_PROMPT
33
+ from agent.store import InvestigationStore
34
+ from tools import ( # noqa: F401 — side effect: each module registers its tools
35
+ ansible,
36
+ argocd,
37
+ cloud,
38
+ docker,
39
+ git_ci,
40
+ helm,
41
+ investigation,
42
+ istio,
43
+ kubernetes,
44
+ monitoring,
45
+ newrelic,
46
+ preflight,
47
+ system,
48
+ terraform,
49
+ trivy,
50
+ )
51
+ from tools.investigation import (
52
+ finish_investigation,
53
+ list_saved_text,
54
+ report_json,
55
+ report_text,
56
+ resume_investigation as load_resumable_investigation,
57
+ set_store as set_investigation_store,
58
+ start_investigation,
59
+ status_text,
60
+ store_directory_text,
61
+ )
62
+ from tools.registry import execute_tool, get_tools
63
+
64
+ DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
65
+ DEFAULT_MODEL = "z-ai/glm-5.3"
66
+ PLACEHOLDER_KEY = "your_key_here"
67
+
68
+ # Safety valve: the model gets at most this many tool-use turns before we stop.
69
+ MAX_TOOL_ITERATIONS = 10
70
+
71
+
72
+ class DevOpsAgent:
73
+ """Minimal DevOps investigation assistant (read-only by design)."""
74
+
75
+ def __init__(
76
+ self,
77
+ api_key: str | None = None,
78
+ model: str | None = None,
79
+ base_url: str | None = None,
80
+ ) -> None:
81
+ # Configuration resolution order: explicit argument > environment > default.
82
+ self.api_key = api_key or os.getenv("OPENROUTER_API_KEY")
83
+ if not self.api_key:
84
+ raise ValueError(
85
+ "OPENROUTER_API_KEY is not set. Copy .env.example to .env and "
86
+ "fill in your key, or export OPENROUTER_API_KEY in your shell."
87
+ )
88
+ if self.api_key.strip().lower() == PLACEHOLDER_KEY:
89
+ raise ValueError(
90
+ "OPENROUTER_API_KEY still has the placeholder value. Edit .env "
91
+ "and replace `your_key_here` with your real key."
92
+ )
93
+
94
+ self.model = model or os.getenv("OPENROUTER_MODEL", DEFAULT_MODEL)
95
+ self.base_url = base_url or os.getenv("OPENROUTER_BASE_URL", DEFAULT_BASE_URL)
96
+
97
+ # OpenRouter exposes an OpenAI-compatible API, so the official OpenAI
98
+ # SDK is a drop-in client — just pointed at OpenRouter's base URL.
99
+ self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
100
+
101
+ # Short-term conversation history. Starts with the system prompt;
102
+ # grows as user, model, and tool results exchange turns.
103
+ self.messages: list[dict] = [{"role": "system", "content": SYSTEM_PROMPT}]
104
+
105
+ # Tools the model may call this session (read-only by construction).
106
+ self.tools = get_tools()
107
+
108
+ def ask(self, user_message: str) -> str:
109
+ """Send one user message; run the tool-use loop; return the final answer.
110
+
111
+ The message, any tool exchanges, and the final assistant reply are all
112
+ kept in this session's history so the model retains context across
113
+ turns. Raises ValueError on empty input.
114
+ """
115
+ message = user_message.strip()
116
+ if not message:
117
+ raise ValueError("Cannot send an empty user message.")
118
+
119
+ self.messages.append({"role": "user", "content": message})
120
+ reply = self._complete()
121
+ self.messages.append({"role": "assistant", "content": reply})
122
+ return reply
123
+
124
+ # --- investigation lifecycle (Phase 4) — thin CLI-facing delegates --------
125
+ #
126
+ # The investigation record itself lives in agent/investigation.py; the
127
+ # model drives it via the investigation_* tools inside the normal loop.
128
+ # These methods let the CLI inspect and control the same state (/investigate,
129
+ # /investigation, /report, /endinvestigation).
130
+
131
+ def begin_investigation(self, problem: str) -> str:
132
+ """Open a formal investigation for `problem` (the /investigate command)."""
133
+ return start_investigation(problem)
134
+
135
+ def investigation_status_text(self) -> str | None:
136
+ """Live tracker text, or None when no investigation is active."""
137
+ return status_text()
138
+
139
+ def investigation_report_text(self) -> str | None:
140
+ """Canonical report once concluded (tracker while in progress), or None."""
141
+ return report_text()
142
+
143
+ def investigation_report_json(self) -> dict | None:
144
+ """Structured JSON export of the investigation (for CI/automation), or None."""
145
+ return report_json()
146
+
147
+ def end_investigation(self) -> str:
148
+ """Clear the active investigation (read-only: discards only memory)."""
149
+ return finish_investigation()
150
+
151
+ # --- persistence (Phase 7) — thin CLI-facing delegates ---------------------
152
+ #
153
+ # The record is auto-saved by the mutation chokepoint in
154
+ # tools/investigation.py; these methods expose resume/list/store-location
155
+ # to the CLI (REPL startup auto-resume, /investigations, --store-dir).
156
+
157
+ @property
158
+ def store_dir(self) -> str | None:
159
+ """Directory records are saved to, or None when persistence is off."""
160
+ return store_directory_text()
161
+
162
+ def list_investigations(self) -> str | None:
163
+ """Saved investigation records, newest first, or None if none."""
164
+ return list_saved_text()
165
+
166
+ def resume_investigation(self) -> str | None:
167
+ """Continue the newest in-progress record, or None if there is none."""
168
+ return load_resumable_investigation()
169
+
170
+ def use_store_dir(self, directory: str) -> None:
171
+ """Point persistence at `directory` (--store-dir; tests use tmpdirs)."""
172
+ set_investigation_store(InvestigationStore(directory))
173
+
174
+ def _complete(self) -> str:
175
+ """The tool-use loop — the single chokepoint where the backend is called.
176
+
177
+ Each turn sends the full history (with tool schemas attached when tools
178
+ are available). If the reply requests tools, every requested tool is
179
+ executed locally, the results are appended, and the loop calls the
180
+ model again. Stops when the model answers in plain text, or when the
181
+ iteration cap is reached.
182
+ """
183
+ for _ in range(MAX_TOOL_ITERATIONS):
184
+ request: dict = {"model": self.model, "messages": self.messages}
185
+ if self.tools:
186
+ request["tools"] = [tool.schema() for tool in self.tools]
187
+
188
+ response = self.client.chat.completions.create(**request)
189
+ message = response.choices[0].message
190
+
191
+ if not message.tool_calls:
192
+ content = message.content
193
+ if content is None:
194
+ raise RuntimeError("The model returned an empty response.")
195
+ return content
196
+
197
+ # Echo the assistant tool-request turn verbatim, then answer each
198
+ # call with a sibling "tool" message referencing its call id.
199
+ self.messages.append(_echo_tool_request(message))
200
+ for call in message.tool_calls:
201
+ result = execute_tool(call.function.name, call.function.arguments)
202
+ self.messages.append(
203
+ {"role": "tool", "tool_call_id": call.id, "content": result}
204
+ )
205
+
206
+ raise RuntimeError(
207
+ f"The model did not finish after {MAX_TOOL_ITERATIONS} tool-use turns."
208
+ )
209
+
210
+
211
+ def _echo_tool_request(message) -> dict:
212
+ """Rebuild the assistant turn that requested tools, verbatim.
213
+
214
+ The OpenAI-compatible API requires the assistant tool-request turn to be
215
+ echoed into history with the exact tool_calls, each one answered by a
216
+ sibling {"role": "tool"} message that references it via tool_call_id.
217
+ """
218
+ return {
219
+ "role": "assistant",
220
+ "content": message.content or "",
221
+ "tool_calls": [
222
+ {
223
+ "id": call.id,
224
+ "type": "function",
225
+ "function": {
226
+ "name": call.function.name,
227
+ "arguments": call.function.arguments,
228
+ },
229
+ }
230
+ for call in message.tool_calls
231
+ ],
232
+ }
agent/investigation.py ADDED
@@ -0,0 +1,339 @@
1
+ """Investigation state for the DevOps agent (Phase 4).
2
+
3
+ A first-class, in-memory record of an ongoing incident investigation: the
4
+ problem statement, tracked hypotheses with verdicts, evidence notes, and —
5
+ once concluded — root cause, remediation, and verification.
6
+
7
+ The model drives this state through the investigation_* tools in
8
+ tools/investigation.py; the CLI exposes the same state via /investigate,
9
+ /investigation and /report. This module is PURE DATA — no model calls, no
10
+ I/O — so it is fully unit-testable.
11
+
12
+ Read-only guarantee: recording a hypothesis or a verdict mutates only this
13
+ agent's in-memory state. Nothing here touches a cluster, a file, or any
14
+ real system.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, field
20
+
21
+ HYPOTHESIS_STATUSES = ("proposed", "supported", "refuted", "confirmed")
22
+ CONFIDENCE_LEVELS = ("high", "medium", "low")
23
+
24
+
25
+ class InvestigationError(Exception):
26
+ """Raised on invalid state transitions (e.g. unknown hypothesis id)."""
27
+
28
+
29
+ @dataclass
30
+ class Hypothesis:
31
+ id: str
32
+ statement: str
33
+ status: str = "proposed"
34
+ notes: list[str] = field(default_factory=list)
35
+
36
+ def set_status(self, status: str) -> None:
37
+ if status not in HYPOTHESIS_STATUSES:
38
+ raise InvestigationError(
39
+ f"invalid hypothesis status {status!r}; expected one of "
40
+ f"{HYPOTHESIS_STATUSES}"
41
+ )
42
+ self.status = status
43
+
44
+
45
+ @dataclass
46
+ class EvidenceNote:
47
+ id: str
48
+ content: str
49
+ hypothesis_id: str | None = None # which tracked hypothesis this bears on
50
+
51
+
52
+ @dataclass
53
+ class Conclusion:
54
+ summary: str
55
+ root_cause: str
56
+ remediation: list[str]
57
+ verification: list[str]
58
+ confidence: str
59
+
60
+
61
+ class Investigation:
62
+ """Tracks one incident investigation from problem statement to report."""
63
+
64
+ def __init__(
65
+ self,
66
+ problem: str,
67
+ initial_hypotheses: list[str] | None = None,
68
+ ) -> None:
69
+ if not problem or not problem.strip():
70
+ raise InvestigationError("problem statement must not be empty")
71
+ self.problem = problem.strip()
72
+ self.hypotheses: list[Hypothesis] = []
73
+ self.evidence: list[EvidenceNote] = []
74
+ self.conclusion: Conclusion | None = None
75
+ self._next_h = 1
76
+ self._next_e = 1
77
+ for statement in initial_hypotheses or []:
78
+ if statement and statement.strip():
79
+ self.add_hypothesis(statement)
80
+
81
+ # --- mutations; each returns a short confirmation for the tool result ---
82
+
83
+ def add_hypothesis(self, statement: str) -> str:
84
+ clean = (statement or "").strip()
85
+ if not clean:
86
+ raise InvestigationError("hypothesis statement must not be empty")
87
+ h = Hypothesis(id=f"H{self._next_h}", statement=clean)
88
+ self._next_h += 1
89
+ self.hypotheses.append(h)
90
+ return f"recorded hypothesis {h.id}: {clean}"
91
+
92
+ def verify_hypothesis(
93
+ self, hypothesis_id: str, status: str, note: str | None = None
94
+ ) -> str:
95
+ h = self._get_hypothesis(hypothesis_id)
96
+ h.set_status(status)
97
+ if note and note.strip():
98
+ h.notes.append(note.strip())
99
+ detail = f" — {note.strip()}" if note and note.strip() else ""
100
+ return f"{h.id} is now [{h.status}]: {h.statement}{detail}"
101
+
102
+ def record_evidence(self, content: str, hypothesis_id: str | None = None) -> str:
103
+ clean = (content or "").strip()
104
+ if not clean:
105
+ raise InvestigationError("evidence note must not be empty")
106
+ if hypothesis_id is not None:
107
+ self._get_hypothesis(hypothesis_id) # validates the link target
108
+ note = EvidenceNote(
109
+ id=f"E{self._next_e}", content=clean, hypothesis_id=hypothesis_id
110
+ )
111
+ self._next_e += 1
112
+ self.evidence.append(note)
113
+ link = f" (→ {hypothesis_id})" if hypothesis_id else ""
114
+ return f"recorded evidence {note.id}: {clean}{link}"
115
+
116
+ def conclude(
117
+ self,
118
+ *,
119
+ summary: str,
120
+ root_cause: str,
121
+ remediation: str | list[str],
122
+ verification: str | list[str],
123
+ confidence: str = "medium",
124
+ ) -> str:
125
+ if self.conclusion is not None:
126
+ return "this investigation is already concluded — /report shows it"
127
+ if not (summary or "").strip() or not (root_cause or "").strip():
128
+ raise InvestigationError("summary and root_cause are required")
129
+ if confidence not in CONFIDENCE_LEVELS:
130
+ raise InvestigationError(
131
+ f"invalid confidence {confidence!r}; expected one of {CONFIDENCE_LEVELS}"
132
+ )
133
+ self.conclusion = Conclusion(
134
+ summary=summary.strip(),
135
+ root_cause=root_cause.strip(),
136
+ remediation=_string_list(remediation, "remediation"),
137
+ verification=_string_list(verification, "verification"),
138
+ confidence=confidence,
139
+ )
140
+ return (
141
+ "investigation concluded. End your answer with the structured "
142
+ "report; it is also available via /report."
143
+ )
144
+
145
+ # --- read-only views for the model and the CLI ---
146
+
147
+ def render_status(self) -> str:
148
+ """Compact tracker appended to every investigation tool result."""
149
+ lines = ["**Investigation tracker**", f"Problem: {self.problem}"]
150
+ if self.conclusion is None:
151
+ lines.append("Status: in progress")
152
+ else:
153
+ lines.append(f"Status: concluded (confidence {self.conclusion.confidence})")
154
+ if self.hypotheses:
155
+ lines.append("Hypotheses:")
156
+ lines += [f"- {h.id} [{h.status}] {h.statement}" for h in self.hypotheses]
157
+ else:
158
+ lines.append("Hypotheses: none yet")
159
+ if self.evidence:
160
+ lines.append("Evidence:")
161
+ lines += [
162
+ f"- {e.id} {e.content}"
163
+ + (f" (→ {e.hypothesis_id})" if e.hypothesis_id else "")
164
+ for e in self.evidence
165
+ ]
166
+ else:
167
+ lines.append("Evidence: none recorded yet")
168
+ return "\n".join(lines)
169
+
170
+ def render_report(self) -> str:
171
+ """Canonical final report (deterministic — always rendered from state)."""
172
+ if self.conclusion is None:
173
+ return self.render_status()
174
+
175
+ c = self.conclusion
176
+ lines = [
177
+ "# Investigation report",
178
+ "",
179
+ f"**Problem:** {self.problem}",
180
+ "",
181
+ "## Facts / evidence",
182
+ ]
183
+ if self.evidence:
184
+ lines += [
185
+ f"- {e.content}"
186
+ + (f" (→ {e.hypothesis_id})" if e.hypothesis_id else "")
187
+ for e in self.evidence
188
+ ]
189
+ else:
190
+ lines.append("- (none recorded)")
191
+ lines += ["", "## Hypotheses"]
192
+ if self.hypotheses:
193
+ lines += [
194
+ f"- **{h.id}** [{h.status}] {h.statement}" for h in self.hypotheses
195
+ ]
196
+ else:
197
+ lines.append("- (none)")
198
+ lines += ["", "## Root cause", "", c.root_cause]
199
+ lines += ["", "## Remediation (recommendations — nothing was executed)"]
200
+ lines += [f"- {item}" for item in c.remediation] or ["- (none)"]
201
+ lines += ["", "## Verification steps"]
202
+ lines += [f"- {item}" for item in c.verification] or ["- (none)"]
203
+ lines += [
204
+ "",
205
+ f"**Summary:** {c.summary}",
206
+ f"**Confidence:** {c.confidence}",
207
+ ]
208
+ return "\n".join(lines)
209
+
210
+ def render_report_json(self) -> dict:
211
+ """Structured export of the investigation, for CI/automation tooling.
212
+
213
+ Always returns the same shape whether or not the investigation is
214
+ concluded: hypotheses and evidence are lists; `conclusion` is present
215
+ only once concluded. No model text is trusted — everything is
216
+ re-derived deterministically from the tracked record.
217
+ """
218
+ out: dict = {
219
+ "problem": self.problem,
220
+ "status": "concluded" if self.conclusion is not None else "in_progress",
221
+ "hypotheses": [
222
+ {
223
+ "id": h.id,
224
+ "statement": h.statement,
225
+ "status": h.status,
226
+ "notes": list(h.notes),
227
+ }
228
+ for h in self.hypotheses
229
+ ],
230
+ "evidence": [
231
+ {
232
+ "id": e.id,
233
+ "content": e.content,
234
+ "hypothesis_id": e.hypothesis_id,
235
+ }
236
+ for e in self.evidence
237
+ ],
238
+ }
239
+ if self.conclusion is not None:
240
+ c = self.conclusion
241
+ out["conclusion"] = {
242
+ "summary": c.summary,
243
+ "root_cause": c.root_cause,
244
+ "remediation": list(c.remediation),
245
+ "verification": list(c.verification),
246
+ "confidence": c.confidence,
247
+ }
248
+ return out
249
+
250
+ # --- persistence (Phase 7) — lossless round-trip for the store ------------
251
+
252
+ def to_dict(self, *, saved_at: str | None = None) -> dict:
253
+ """Full serializable state, for the store to write to disk.
254
+
255
+ render_report_json() is already a lossless view of the record, so
256
+ persistence reuses it and adds storage metadata: `schema` (format
257
+ version, bumped only on breaking changes) and `saved_at` (ISO
258
+ timestamp, set by the store). from_dict() round-trips this exactly.
259
+ """
260
+ out = self.render_report_json()
261
+ out["schema"] = 1
262
+ if saved_at is not None:
263
+ out["saved_at"] = saved_at
264
+ return out
265
+
266
+ @classmethod
267
+ def from_dict(cls, data: dict) -> "Investigation":
268
+ """Rebuild an Investigation from to_dict() output.
269
+
270
+ Tolerant of unknown keys (forward compatible) and strict about our
271
+ own: a corrupt or truncated file raises InvestigationError (or
272
+ KeyError for a missing problem), which the store turns into "skip
273
+ this file" rather than a crash.
274
+ """
275
+ inv = cls(data["problem"])
276
+ for h in data.get("hypotheses") or []:
277
+ hyp = Hypothesis(
278
+ id=str(h.get("id", "")),
279
+ statement=str(h.get("statement", "")),
280
+ status="proposed",
281
+ notes=[str(n) for n in (h.get("notes") or [])],
282
+ )
283
+ hyp.set_status(str(h.get("status", "proposed")))
284
+ number = _id_number(hyp.id, "H")
285
+ if number is None:
286
+ raise InvestigationError(f"malformed hypothesis id {hyp.id!r}")
287
+ inv.hypotheses.append(hyp)
288
+ if number >= inv._next_h:
289
+ inv._next_h = number + 1
290
+ for e in data.get("evidence") or []:
291
+ hypothesis_id = e.get("hypothesis_id")
292
+ note = EvidenceNote(
293
+ id=str(e.get("id", "")),
294
+ content=str(e.get("content", "")),
295
+ hypothesis_id=str(hypothesis_id) if hypothesis_id else None,
296
+ )
297
+ number = _id_number(note.id, "E")
298
+ if number is None:
299
+ raise InvestigationError(f"malformed evidence id {note.id!r}")
300
+ if note.hypothesis_id is not None:
301
+ inv._get_hypothesis(note.hypothesis_id) # validate the link
302
+ inv.evidence.append(note)
303
+ if number >= inv._next_e:
304
+ inv._next_e = number + 1
305
+ c = data.get("conclusion")
306
+ if c is not None:
307
+ inv.conclude(
308
+ summary=str(c.get("summary", "")),
309
+ root_cause=str(c.get("root_cause", "")),
310
+ remediation=c.get("remediation") or [],
311
+ verification=c.get("verification") or [],
312
+ confidence=str(c.get("confidence", "medium")),
313
+ )
314
+ return inv
315
+
316
+ def _get_hypothesis(self, hypothesis_id: str) -> Hypothesis:
317
+ found = next((h for h in self.hypotheses if h.id == hypothesis_id), None)
318
+ if found is None:
319
+ raise InvestigationError(
320
+ f"unknown hypothesis id {hypothesis_id!r}; known ids: "
321
+ f"{', '.join(h.id for h in self.hypotheses) or 'none'}"
322
+ )
323
+ return found
324
+
325
+
326
+ def _string_list(value: str | list[str], label: str) -> list[str]:
327
+ items = [value] if isinstance(value, str) else list(value or [])
328
+ cleaned = [str(item).strip() for item in items if str(item).strip()]
329
+ if not cleaned:
330
+ raise InvestigationError(f"{label} must contain at least one item")
331
+ return cleaned
332
+
333
+
334
+ def _id_number(value: str, prefix: str) -> int | None:
335
+ """Numeric suffix of an H*/E* id (H12 -> 12), or None if malformed."""
336
+ if not value.startswith(prefix):
337
+ return None
338
+ tail = value[len(prefix):]
339
+ return int(tail) if tail.isdigit() else None