gitops-by-veera 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.
- gitops_by_veera/__init__.py +12 -0
- gitops_by_veera/agent.py +436 -0
- gitops_by_veera/cli.py +287 -0
- gitops_by_veera/config.py +209 -0
- gitops_by_veera/constants.py +249 -0
- gitops_by_veera/exceptions.py +104 -0
- gitops_by_veera/logger.py +82 -0
- gitops_by_veera/models.py +134 -0
- gitops_by_veera/validator.py +222 -0
- gitops_by_veera-1.0.0.dist-info/METADATA +269 -0
- gitops_by_veera-1.0.0.dist-info/RECORD +14 -0
- gitops_by_veera-1.0.0.dist-info/WHEEL +4 -0
- gitops_by_veera-1.0.0.dist-info/entry_points.txt +2 -0
- gitops_by_veera-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""gitops-by-veera — conversational autonomous Git and GitHub operations coordinator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__version__ = "1.0.0"
|
|
6
|
+
__author__ = "Veera"
|
|
7
|
+
__license__ = "MIT"
|
|
8
|
+
|
|
9
|
+
from gitops_by_veera.agent import GitOpsAgent
|
|
10
|
+
from gitops_by_veera.exceptions import GitOpsError
|
|
11
|
+
|
|
12
|
+
__all__ = ["GitOpsAgent", "GitOpsError", "__version__"]
|
gitops_by_veera/agent.py
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"""Central orchestration engine: LLM calling, model cascading, execution, and self-healing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
|
|
14
|
+
from gitops_by_veera.config import find_repo_root
|
|
15
|
+
from gitops_by_veera.constants import (
|
|
16
|
+
DIRECT_EVAL_MAP,
|
|
17
|
+
DIRECT_EVAL_PATTERNS,
|
|
18
|
+
GITHUB_API_BASE,
|
|
19
|
+
GITHUB_RATE_LIMIT_REMAINING_HEADER,
|
|
20
|
+
GITHUB_RATE_LIMIT_RESET_HEADER,
|
|
21
|
+
GROQ_API_URL,
|
|
22
|
+
GROQ_CONNECT_TIMEOUT,
|
|
23
|
+
GROQ_MAX_RETRIES,
|
|
24
|
+
GROQ_READ_TIMEOUT,
|
|
25
|
+
GROQ_RETRY_STATUSES,
|
|
26
|
+
MODEL_CASCADE,
|
|
27
|
+
MODEL_TIER_3,
|
|
28
|
+
REMEDIATION_PROMPT,
|
|
29
|
+
SYSTEM_PROMPT,
|
|
30
|
+
)
|
|
31
|
+
from gitops_by_veera.exceptions import (
|
|
32
|
+
ExecutionError,
|
|
33
|
+
GitHubAPIError,
|
|
34
|
+
GitHubRateLimitError,
|
|
35
|
+
GroqAPIError,
|
|
36
|
+
GroqRateLimitError,
|
|
37
|
+
GroqTimeoutError,
|
|
38
|
+
ModelCascadeExhaustedError,
|
|
39
|
+
ParseError,
|
|
40
|
+
RemediationLimitError,
|
|
41
|
+
)
|
|
42
|
+
from gitops_by_veera.logger import get_logger
|
|
43
|
+
from gitops_by_veera.models import (
|
|
44
|
+
CloudOperation,
|
|
45
|
+
ExecutionPlan,
|
|
46
|
+
LocalOperation,
|
|
47
|
+
OperationResult,
|
|
48
|
+
SessionMetrics,
|
|
49
|
+
)
|
|
50
|
+
from gitops_by_veera.validator import validate_plan
|
|
51
|
+
|
|
52
|
+
logger = get_logger("gitops.agent")
|
|
53
|
+
|
|
54
|
+
MAX_REMEDIATION_CYCLES = 2
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class GitOpsAgent:
|
|
58
|
+
"""Orchestrates LLM planning, validation, execution, and remediation."""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
groq_api_key: str,
|
|
63
|
+
github_token: str,
|
|
64
|
+
repo_root: Optional[Path] = None,
|
|
65
|
+
debug: bool = False,
|
|
66
|
+
) -> None:
|
|
67
|
+
self._groq_key = groq_api_key
|
|
68
|
+
self._github_token = github_token
|
|
69
|
+
self._repo_root = repo_root or find_repo_root() or Path.cwd()
|
|
70
|
+
self._debug = debug
|
|
71
|
+
self._prompt_cache: dict[str, ExecutionPlan] = {}
|
|
72
|
+
self.metrics = SessionMetrics()
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
# Public interface
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def build_plan(self, prompt: str) -> ExecutionPlan:
|
|
79
|
+
"""Convert a natural-language prompt into a validated execution plan.
|
|
80
|
+
|
|
81
|
+
Uses the direct-evaluation bypass for trivial inspection commands.
|
|
82
|
+
Falls back to LLM cascade otherwise. Results are cached per prompt.
|
|
83
|
+
"""
|
|
84
|
+
direct = self._try_direct_eval(prompt)
|
|
85
|
+
if direct is not None:
|
|
86
|
+
logger.debug("Direct evaluation bypassed LLM for prompt: %r", prompt)
|
|
87
|
+
return direct
|
|
88
|
+
|
|
89
|
+
if prompt in self._prompt_cache:
|
|
90
|
+
logger.debug("Cache hit for prompt: %r", prompt)
|
|
91
|
+
return self._prompt_cache[prompt]
|
|
92
|
+
|
|
93
|
+
plan = self._call_llm_cascade(prompt, SYSTEM_PROMPT)
|
|
94
|
+
self._prompt_cache[prompt] = plan
|
|
95
|
+
return plan
|
|
96
|
+
|
|
97
|
+
def validate(self, plan: ExecutionPlan) -> list[str]:
|
|
98
|
+
"""Validate the plan and return warning messages for risky operations."""
|
|
99
|
+
return validate_plan(plan, self._repo_root)
|
|
100
|
+
|
|
101
|
+
def execute(self, plan: ExecutionPlan, dry_run: bool = False) -> list[OperationResult]:
|
|
102
|
+
"""Execute all operations in the plan sequentially.
|
|
103
|
+
|
|
104
|
+
If dry_run is True, operations are validated but not executed.
|
|
105
|
+
Failures trigger the self-healing remediation loop.
|
|
106
|
+
"""
|
|
107
|
+
start = time.time()
|
|
108
|
+
results: list[OperationResult] = []
|
|
109
|
+
remediation_cycles = 0
|
|
110
|
+
|
|
111
|
+
for idx, op in enumerate(plan.operations):
|
|
112
|
+
if dry_run:
|
|
113
|
+
logger.info("[DRY-RUN] Would execute operation %d: %s", idx, op.type)
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
if isinstance(op, LocalOperation):
|
|
117
|
+
result = self._run_local(idx, op)
|
|
118
|
+
else:
|
|
119
|
+
result = self._run_cloud(idx, op) # type: ignore[arg-type]
|
|
120
|
+
|
|
121
|
+
results.append(result)
|
|
122
|
+
|
|
123
|
+
if not result.success:
|
|
124
|
+
self.metrics.record_failure()
|
|
125
|
+
if remediation_cycles >= MAX_REMEDIATION_CYCLES:
|
|
126
|
+
raise RemediationLimitError(
|
|
127
|
+
f"Remediation limit ({MAX_REMEDIATION_CYCLES}) reached. "
|
|
128
|
+
"Please resolve the issue manually."
|
|
129
|
+
)
|
|
130
|
+
remediation_cycles += 1
|
|
131
|
+
return results # caller handles remediation loop
|
|
132
|
+
|
|
133
|
+
self.metrics.record_success()
|
|
134
|
+
|
|
135
|
+
self.metrics.total_execution_duration_seconds = time.time() - start
|
|
136
|
+
return results
|
|
137
|
+
|
|
138
|
+
def remediate(self, failed_result: OperationResult) -> ExecutionPlan:
|
|
139
|
+
"""Ask the Tier 3 model for a remediation plan for a failed operation."""
|
|
140
|
+
self.metrics.record_remediation()
|
|
141
|
+
safe_context = {
|
|
142
|
+
"operation_type": failed_result.operation_type,
|
|
143
|
+
"error_message": failed_result.error_message,
|
|
144
|
+
"returncode": failed_result.returncode,
|
|
145
|
+
"stderr": failed_result.stderr[:500],
|
|
146
|
+
}
|
|
147
|
+
prompt = (
|
|
148
|
+
f"The following operation failed:\n{json.dumps(safe_context, indent=2)}\n\n"
|
|
149
|
+
"Suggest a safe remediation plan."
|
|
150
|
+
)
|
|
151
|
+
return self._call_llm_cascade(prompt, REMEDIATION_PROMPT, prefer_tier3=True)
|
|
152
|
+
|
|
153
|
+
# ------------------------------------------------------------------
|
|
154
|
+
# Direct evaluation
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def _try_direct_eval(self, prompt: str) -> Optional[ExecutionPlan]:
|
|
158
|
+
for pattern in DIRECT_EVAL_PATTERNS:
|
|
159
|
+
if pattern.match(prompt.strip()):
|
|
160
|
+
key = self._classify_direct(prompt)
|
|
161
|
+
args = DIRECT_EVAL_MAP.get(key, ["status"])
|
|
162
|
+
raw = {
|
|
163
|
+
"operations": [
|
|
164
|
+
{
|
|
165
|
+
"type": "local",
|
|
166
|
+
"binary": "git",
|
|
167
|
+
"args": args,
|
|
168
|
+
"risk_level": "safe",
|
|
169
|
+
"reason": f"Direct evaluation of '{prompt.strip()}'.",
|
|
170
|
+
}
|
|
171
|
+
]
|
|
172
|
+
}
|
|
173
|
+
return ExecutionPlan(**raw)
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _classify_direct(prompt: str) -> str:
|
|
178
|
+
p = prompt.strip().lower()
|
|
179
|
+
if "diff" in p:
|
|
180
|
+
return "diff"
|
|
181
|
+
if "log" in p or "history" in p:
|
|
182
|
+
return "log"
|
|
183
|
+
return "status"
|
|
184
|
+
|
|
185
|
+
# ------------------------------------------------------------------
|
|
186
|
+
# LLM cascade
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def _call_llm_cascade(
|
|
190
|
+
self, prompt: str, system_prompt: str, prefer_tier3: bool = False
|
|
191
|
+
) -> ExecutionPlan:
|
|
192
|
+
models = [MODEL_TIER_3] if prefer_tier3 else MODEL_CASCADE
|
|
193
|
+
|
|
194
|
+
last_error: Exception = ModelCascadeExhaustedError("No models available.")
|
|
195
|
+
|
|
196
|
+
for model in models:
|
|
197
|
+
try:
|
|
198
|
+
logger.debug("Trying model: %s", model)
|
|
199
|
+
self.metrics.record_llm_call(model)
|
|
200
|
+
raw_json = self._call_groq(model, prompt, system_prompt)
|
|
201
|
+
plan = self._parse_plan(raw_json)
|
|
202
|
+
return plan
|
|
203
|
+
except (ParseError, ValidationError) as exc:
|
|
204
|
+
logger.warning("Model %s produced invalid JSON. Cascading. %s", model, exc)
|
|
205
|
+
self.metrics.record_fallback()
|
|
206
|
+
last_error = exc
|
|
207
|
+
except (GroqRateLimitError, GroqTimeoutError, GroqAPIError) as exc:
|
|
208
|
+
logger.warning("Model %s failed: %s. Cascading.", model, exc)
|
|
209
|
+
self.metrics.record_fallback()
|
|
210
|
+
last_error = exc
|
|
211
|
+
|
|
212
|
+
raise ModelCascadeExhaustedError(
|
|
213
|
+
f"All model tiers exhausted. Last error: {last_error}"
|
|
214
|
+
) from last_error
|
|
215
|
+
|
|
216
|
+
def _call_groq(self, model: str, prompt: str, system_prompt: str) -> dict[str, Any]:
|
|
217
|
+
headers = {
|
|
218
|
+
"Authorization": f"Bearer {self._groq_key}",
|
|
219
|
+
"Content-Type": "application/json",
|
|
220
|
+
}
|
|
221
|
+
body = {
|
|
222
|
+
"model": model,
|
|
223
|
+
"messages": [
|
|
224
|
+
{"role": "system", "content": system_prompt},
|
|
225
|
+
{"role": "user", "content": prompt},
|
|
226
|
+
],
|
|
227
|
+
"response_format": {"type": "json_object"},
|
|
228
|
+
"temperature": 0.1,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
backoff = 1.0
|
|
232
|
+
for attempt in range(GROQ_MAX_RETRIES):
|
|
233
|
+
try:
|
|
234
|
+
response = requests.post(
|
|
235
|
+
GROQ_API_URL,
|
|
236
|
+
headers=headers,
|
|
237
|
+
json=body,
|
|
238
|
+
timeout=(GROQ_CONNECT_TIMEOUT, GROQ_READ_TIMEOUT),
|
|
239
|
+
)
|
|
240
|
+
except requests.Timeout:
|
|
241
|
+
logger.warning(
|
|
242
|
+
"Groq API timeout (attempt %d/%d). Retrying...", attempt + 1, GROQ_MAX_RETRIES
|
|
243
|
+
)
|
|
244
|
+
if attempt < GROQ_MAX_RETRIES - 1:
|
|
245
|
+
time.sleep(backoff)
|
|
246
|
+
backoff *= 2
|
|
247
|
+
continue
|
|
248
|
+
raise GroqTimeoutError("Groq API timed out after all retries.")
|
|
249
|
+
except requests.RequestException as exc:
|
|
250
|
+
raise GroqAPIError(f"Groq network error: {exc}") from exc
|
|
251
|
+
|
|
252
|
+
if response.status_code == 200:
|
|
253
|
+
try:
|
|
254
|
+
return response.json()["choices"][0]["message"]["content"]
|
|
255
|
+
except (KeyError, IndexError, json.JSONDecodeError) as exc:
|
|
256
|
+
raise ParseError(f"Unexpected Groq response structure: {exc}") from exc
|
|
257
|
+
|
|
258
|
+
if response.status_code in GROQ_RETRY_STATUSES:
|
|
259
|
+
logger.warning(
|
|
260
|
+
"Groq returned %d (attempt %d/%d). Retrying in %.0fs...",
|
|
261
|
+
response.status_code,
|
|
262
|
+
attempt + 1,
|
|
263
|
+
GROQ_MAX_RETRIES,
|
|
264
|
+
backoff,
|
|
265
|
+
)
|
|
266
|
+
if attempt < GROQ_MAX_RETRIES - 1:
|
|
267
|
+
time.sleep(backoff)
|
|
268
|
+
backoff *= 2
|
|
269
|
+
continue
|
|
270
|
+
raise GroqRateLimitError(
|
|
271
|
+
f"Groq rate limit persists after {GROQ_MAX_RETRIES} retries.",
|
|
272
|
+
status_code=response.status_code,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
raise GroqAPIError(
|
|
276
|
+
f"Groq API error {response.status_code}: {response.text[:200]}",
|
|
277
|
+
status_code=response.status_code,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
raise GroqAPIError("Groq retry loop exited without a result.") # pragma: no cover
|
|
281
|
+
|
|
282
|
+
def _parse_plan(self, raw: Any) -> ExecutionPlan:
|
|
283
|
+
if isinstance(raw, str):
|
|
284
|
+
try:
|
|
285
|
+
data = json.loads(raw)
|
|
286
|
+
except json.JSONDecodeError as exc:
|
|
287
|
+
raise ParseError(f"LLM returned non-JSON string: {exc}") from exc
|
|
288
|
+
elif isinstance(raw, dict):
|
|
289
|
+
data = raw
|
|
290
|
+
else:
|
|
291
|
+
raise ParseError(f"Unexpected LLM output type: {type(raw)}")
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
return ExecutionPlan(**data)
|
|
295
|
+
except (ValidationError, TypeError) as exc:
|
|
296
|
+
raise ParseError(f"Pydantic validation failed: {exc}") from exc
|
|
297
|
+
|
|
298
|
+
# ------------------------------------------------------------------
|
|
299
|
+
# Local execution
|
|
300
|
+
# ------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
def _run_local(self, idx: int, op: LocalOperation) -> OperationResult:
|
|
303
|
+
cmd = [op.binary] + op.args
|
|
304
|
+
logger.info("Executing local operation %d: %s", idx, " ".join(cmd))
|
|
305
|
+
try:
|
|
306
|
+
proc = subprocess.run(
|
|
307
|
+
cmd,
|
|
308
|
+
cwd=str(self._repo_root),
|
|
309
|
+
capture_output=True,
|
|
310
|
+
text=True,
|
|
311
|
+
shell=False,
|
|
312
|
+
timeout=60,
|
|
313
|
+
)
|
|
314
|
+
success = proc.returncode == 0
|
|
315
|
+
if not success:
|
|
316
|
+
logger.warning(
|
|
317
|
+
"Local operation %d failed (rc=%d): %s", idx, proc.returncode, proc.stderr[:300]
|
|
318
|
+
)
|
|
319
|
+
return OperationResult(
|
|
320
|
+
operation_index=idx,
|
|
321
|
+
operation_type="local",
|
|
322
|
+
success=success,
|
|
323
|
+
stdout=proc.stdout,
|
|
324
|
+
stderr=proc.stderr,
|
|
325
|
+
returncode=proc.returncode,
|
|
326
|
+
error_message=proc.stderr if not success else "",
|
|
327
|
+
)
|
|
328
|
+
except subprocess.TimeoutExpired:
|
|
329
|
+
return OperationResult(
|
|
330
|
+
operation_index=idx,
|
|
331
|
+
operation_type="local",
|
|
332
|
+
success=False,
|
|
333
|
+
error_message="Command timed out after 60 seconds.",
|
|
334
|
+
returncode=-1,
|
|
335
|
+
)
|
|
336
|
+
except FileNotFoundError as exc:
|
|
337
|
+
return OperationResult(
|
|
338
|
+
operation_index=idx,
|
|
339
|
+
operation_type="local",
|
|
340
|
+
success=False,
|
|
341
|
+
error_message=f"Executable not found: {exc}",
|
|
342
|
+
returncode=-1,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# ------------------------------------------------------------------
|
|
346
|
+
# Cloud execution
|
|
347
|
+
# ------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
def _run_cloud(self, idx: int, op: CloudOperation) -> OperationResult:
|
|
350
|
+
url = f"{GITHUB_API_BASE}{op.endpoint}"
|
|
351
|
+
headers = {
|
|
352
|
+
"Authorization": f"Bearer {self._github_token}",
|
|
353
|
+
"Accept": "application/vnd.github+json",
|
|
354
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
355
|
+
"Content-Type": "application/json",
|
|
356
|
+
}
|
|
357
|
+
logger.info("Executing cloud operation %d: %s %s", idx, op.method, op.endpoint)
|
|
358
|
+
|
|
359
|
+
backoff = 2.0
|
|
360
|
+
for attempt in range(3):
|
|
361
|
+
try:
|
|
362
|
+
response = requests.request(
|
|
363
|
+
op.method,
|
|
364
|
+
url,
|
|
365
|
+
headers=headers,
|
|
366
|
+
json=op.payload if op.payload else None,
|
|
367
|
+
timeout=(10, 30),
|
|
368
|
+
)
|
|
369
|
+
except requests.Timeout:
|
|
370
|
+
return OperationResult(
|
|
371
|
+
operation_index=idx,
|
|
372
|
+
operation_type="cloud",
|
|
373
|
+
success=False,
|
|
374
|
+
error_message="GitHub API request timed out.",
|
|
375
|
+
status_code=0,
|
|
376
|
+
)
|
|
377
|
+
except requests.RequestException as exc:
|
|
378
|
+
return OperationResult(
|
|
379
|
+
operation_index=idx,
|
|
380
|
+
operation_type="cloud",
|
|
381
|
+
success=False,
|
|
382
|
+
error_message=f"Network error: {exc}",
|
|
383
|
+
status_code=0,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
if response.status_code in (403, 429):
|
|
387
|
+
remaining = response.headers.get(GITHUB_RATE_LIMIT_REMAINING_HEADER)
|
|
388
|
+
reset = response.headers.get(GITHUB_RATE_LIMIT_RESET_HEADER)
|
|
389
|
+
|
|
390
|
+
if remaining == "0" and reset:
|
|
391
|
+
wait = max(0, int(reset) - int(time.time())) + 1
|
|
392
|
+
logger.warning("GitHub rate limit hit. Waiting %ds until reset.", wait)
|
|
393
|
+
time.sleep(min(wait, 60))
|
|
394
|
+
else:
|
|
395
|
+
logger.warning(
|
|
396
|
+
"GitHub %d response (attempt %d/3). Retrying in %.0fs...",
|
|
397
|
+
response.status_code,
|
|
398
|
+
attempt + 1,
|
|
399
|
+
backoff,
|
|
400
|
+
)
|
|
401
|
+
time.sleep(backoff)
|
|
402
|
+
backoff *= 2
|
|
403
|
+
continue
|
|
404
|
+
|
|
405
|
+
success = response.status_code in (200, 201, 204)
|
|
406
|
+
body_text = ""
|
|
407
|
+
try:
|
|
408
|
+
body_text = json.dumps(response.json())
|
|
409
|
+
except Exception:
|
|
410
|
+
body_text = response.text[:500]
|
|
411
|
+
|
|
412
|
+
if not success:
|
|
413
|
+
logger.warning(
|
|
414
|
+
"Cloud operation %d failed (%d): %s",
|
|
415
|
+
idx,
|
|
416
|
+
response.status_code,
|
|
417
|
+
body_text[:300],
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
return OperationResult(
|
|
421
|
+
operation_index=idx,
|
|
422
|
+
operation_type="cloud",
|
|
423
|
+
success=success,
|
|
424
|
+
stdout=body_text if success else "",
|
|
425
|
+
stderr=body_text if not success else "",
|
|
426
|
+
status_code=response.status_code,
|
|
427
|
+
error_message=body_text if not success else "",
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
return OperationResult(
|
|
431
|
+
operation_index=idx,
|
|
432
|
+
operation_type="cloud",
|
|
433
|
+
success=False,
|
|
434
|
+
error_message="GitHub API rate limit persists after retries. Please try later.",
|
|
435
|
+
status_code=429,
|
|
436
|
+
)
|