codeshield-runtime 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.
codeshield/loop.py ADDED
@@ -0,0 +1,420 @@
1
+ """Self-healing execution loop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import re
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+
11
+ from codeshield.analyzer import validate_syntax_and_safety
12
+ from codeshield.classifier import TracebackClassifier
13
+ from codeshield.environment import SandboxManager
14
+ from codeshield.runner import SubprocessRunner
15
+ from codeshield.schemas import (
16
+ CodeExecutionRequest,
17
+ ErrorDiagnosis,
18
+ ExecutionResult,
19
+ PatchProposal,
20
+ ValidationReport,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ DEFAULT_MAX_ITERATIONS = 3
27
+ DEFAULT_MAX_LLM_RETRIES = 3
28
+ DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"
29
+
30
+
31
+ class SelfHealingError(RuntimeError):
32
+ """Raised when the self-healing loop cannot complete execution safely."""
33
+
34
+
35
+ class LLMPatchError(RuntimeError):
36
+ """Raised when the LLM patch generator cannot produce a safe correction."""
37
+
38
+
39
+ class GeminiPatchGenerator:
40
+ """Generate patches using the Google Gemini API with AST validation."""
41
+
42
+ def __init__(
43
+ self,
44
+ api_key: str | None = None,
45
+ model: str | None = None,
46
+ max_llm_retries: int = DEFAULT_MAX_LLM_RETRIES,
47
+ ) -> None:
48
+ """Initialize the Gemini patch generator.
49
+
50
+ Args:
51
+ api_key: Gemini API key. If ``None``, ``GEMINI_API_KEY`` env var is used.
52
+ model: Gemini model name. Defaults to ``GEMINI_MODEL`` env var or
53
+ ``gemini-2.5-flash``.
54
+ max_llm_retries: Maximum attempts to ask Gemini for a valid patch.
55
+ """
56
+ self._api_key = api_key or os.environ.get("GEMINI_API_KEY")
57
+ self._model = model or os.environ.get("GEMINI_MODEL") or DEFAULT_GEMINI_MODEL
58
+ self._max_llm_retries = max(1, max_llm_retries)
59
+
60
+ @property
61
+ def is_configured(self) -> bool:
62
+ """Return ``True`` when an API key is available."""
63
+ return bool(self._api_key)
64
+
65
+ def __call__(self, code: str, diagnosis: ErrorDiagnosis) -> str | None:
66
+ """Generate a patched version of ``code`` using Gemini, or ``None``."""
67
+ if not self.is_configured:
68
+ return None
69
+
70
+ for attempt in range(1, self._max_llm_retries + 1):
71
+ logger.info(
72
+ "Requesting patch from Gemini (attempt %d/%d)",
73
+ attempt,
74
+ self._max_llm_retries,
75
+ )
76
+ try:
77
+ raw_response = self._call_gemini(code, diagnosis)
78
+ except Exception as exc: # noqa: BLE001
79
+ logger.warning("Gemini API call failed: %s", exc)
80
+ continue
81
+
82
+ if not raw_response:
83
+ continue
84
+
85
+ patched_code = self._extract_code(raw_response)
86
+ if patched_code == code or not patched_code.strip():
87
+ continue
88
+
89
+ report = validate_syntax_and_safety(patched_code)
90
+ if report.is_valid:
91
+ logger.info("Gemini patch passed AST validation")
92
+ return patched_code
93
+
94
+ logger.warning(
95
+ "Gemini patch failed AST validation: %s",
96
+ report.violations,
97
+ )
98
+
99
+ logger.warning("Gemini could not produce a valid patch after all retries")
100
+ return None
101
+
102
+ def _call_gemini(self, code: str, diagnosis: ErrorDiagnosis) -> str:
103
+ """Call the Gemini API and return the raw text response."""
104
+ try:
105
+ from google import genai
106
+ from google.genai import types
107
+ except ImportError as exc:
108
+ raise LLMPatchError(
109
+ "google-genai is not installed. "
110
+ "Install it with: pip install 'autonomous-code-execution-engine[llm]'"
111
+ ) from exc
112
+
113
+ prompt = self._build_prompt(code, diagnosis)
114
+ client = genai.Client(api_key=self._api_key)
115
+ response = client.models.generate_content(
116
+ model=self._model,
117
+ contents=[
118
+ types.Content(
119
+ role="user",
120
+ parts=[types.Part.from_text(text=prompt)],
121
+ ),
122
+ ],
123
+ config=types.GenerateContentConfig(
124
+ temperature=0.2,
125
+ max_output_tokens=2048,
126
+ automatic_function_calling=types.AutomaticFunctionCallingConfig(
127
+ disable=True
128
+ ),
129
+ ),
130
+ )
131
+
132
+ if not response or not response.text:
133
+ return ""
134
+ return response.text
135
+
136
+ @staticmethod
137
+ def _build_prompt(code: str, diagnosis: ErrorDiagnosis) -> str:
138
+ """Build a deterministic prompt for Gemini."""
139
+ context = "\n".join(diagnosis.context) if diagnosis.context else "N/A"
140
+ return (
141
+ "You are an expert Python debugger. "
142
+ "Given the code, the runtime error and the surrounding context, "
143
+ "return ONLY the corrected Python code. "
144
+ "Do not include explanations, comments or markdown formatting.\n\n"
145
+ f"Error type: {diagnosis.error_type}\n"
146
+ f"Error message: {diagnosis.message}\n"
147
+ f"Failing line: {diagnosis.root_cause_line}\n"
148
+ f"Context:\n{context}\n\n"
149
+ f"Code:\n{code}\n\n"
150
+ "Corrected code:"
151
+ )
152
+
153
+ @staticmethod
154
+ def _extract_code(raw: str) -> str:
155
+ """Extract a Python code block from a markdown-wrapped response."""
156
+ fenced = re.search(r"```python\n(.*?)\n```", raw, re.DOTALL | re.IGNORECASE)
157
+ if fenced:
158
+ return fenced.group(1).strip()
159
+
160
+ plain = re.search(r"```\n(.*?)\n```", raw, re.DOTALL)
161
+ if plain:
162
+ return plain.group(1).strip()
163
+
164
+ return raw.strip()
165
+
166
+
167
+ class SelfHealingEngine:
168
+ """Orchestrate AST validation, sandboxed execution and self-healing retries."""
169
+
170
+ def __init__(
171
+ self,
172
+ max_iterations: int = DEFAULT_MAX_ITERATIONS,
173
+ sandbox: SandboxManager | None = None,
174
+ runner: SubprocessRunner | None = None,
175
+ classifier: TracebackClassifier | None = None,
176
+ patch_generator: Callable[[str, ErrorDiagnosis], str | None] | None = None,
177
+ gemini_api_key: str | None = None,
178
+ gemini_model: str | None = None,
179
+ use_llm: bool = True,
180
+ ) -> None:
181
+ """Initialize the self-healing engine.
182
+
183
+ Args:
184
+ max_iterations: Maximum number of validation/execution iterations.
185
+ sandbox: Optional ``SandboxManager``; a temporary one is created when
186
+ ``None``.
187
+ runner: Optional ``SubprocessRunner``; built from ``sandbox`` when ``None``.
188
+ classifier: Optional ``TracebackClassifier``.
189
+ patch_generator: Optional callable ``(code, diagnosis) -> patched_code``
190
+ used to produce corrections. Overrides the Gemini/local default.
191
+ gemini_api_key: Optional Gemini API key. Falls back to ``GEMINI_API_KEY``
192
+ environment variable.
193
+ gemini_model: Optional Gemini model name. Falls back to ``GEMINI_MODEL``
194
+ environment variable or ``gemini-2.5-flash``.
195
+ use_llm: If ``True`` and a Gemini API key is available, use Gemini for
196
+ patch generation; otherwise fall back to the local deterministic
197
+ generator.
198
+ """
199
+ if max_iterations < 1:
200
+ raise SelfHealingError("max_iterations must be at least 1")
201
+
202
+ self._max_iterations = max_iterations
203
+ self._sandbox = sandbox or SandboxManager()
204
+ self._runner = runner or SubprocessRunner(self._sandbox)
205
+ self._classifier = classifier or TracebackClassifier()
206
+
207
+ if patch_generator is not None:
208
+ self._patch_generator = patch_generator
209
+ elif use_llm:
210
+ gemini = GeminiPatchGenerator(
211
+ api_key=gemini_api_key,
212
+ model=gemini_model,
213
+ )
214
+ self._patch_generator = (
215
+ gemini if gemini.is_configured else self._default_patch_generator
216
+ )
217
+ else:
218
+ self._patch_generator = self._default_patch_generator
219
+
220
+ def run(
221
+ self,
222
+ request: CodeExecutionRequest | str,
223
+ timeout: float | None = None,
224
+ ) -> tuple[ExecutionResult, ErrorDiagnosis | None]:
225
+ """Run code through the AST -> sandbox -> heal loop.
226
+
227
+ Args:
228
+ request: Either a ``CodeExecutionRequest`` or a raw source string.
229
+ timeout: Optional execution timeout override.
230
+
231
+ Returns:
232
+ The final ``ExecutionResult`` and an optional ``ErrorDiagnosis``.
233
+
234
+ Raises:
235
+ SelfHealingError: when the loop exhausts all iterations without a
236
+ clean result.
237
+ """
238
+ if isinstance(request, str):
239
+ request = CodeExecutionRequest(code=request)
240
+
241
+ diagnosis: ErrorDiagnosis | None = None
242
+ for attempt in range(1, self._max_iterations + 1):
243
+ logger.info("Self-healing iteration %d/%d", attempt, self._max_iterations)
244
+
245
+ report = validate_syntax_and_safety(request.code)
246
+ if not report.is_valid:
247
+ if report.exception is not None:
248
+ return self._syntax_failure_result(request, report), None
249
+
250
+ diagnosis = ErrorDiagnosis(
251
+ error_type="StaticSafetyViolation",
252
+ root_cause_line=None,
253
+ message="; ".join(report.violations),
254
+ context=[],
255
+ )
256
+ patched = self._generate_and_validate_patch(request.code, diagnosis)
257
+ if patched is None:
258
+ raise SelfHealingError(
259
+ f"Static safety violations cannot be auto-patched: {report.violations}"
260
+ )
261
+ request = self._apply_patch(request, patched, attempt)
262
+ continue
263
+
264
+ result = self._runner.run(request, timeout=timeout)
265
+
266
+ if self._is_success(result):
267
+ return result, None
268
+
269
+ diagnosis = self._classifier.classify_from_result(request.code, result.stderr)
270
+ patched = self._generate_and_validate_patch(request.code, diagnosis)
271
+
272
+ if patched is None:
273
+ logger.warning(
274
+ "No deterministic patch available for %s at line %s",
275
+ diagnosis.error_type,
276
+ diagnosis.root_cause_line,
277
+ )
278
+ return result, diagnosis
279
+
280
+ request = self._apply_patch(request, patched, attempt)
281
+
282
+ raise SelfHealingError(
283
+ f"Self-healing loop exhausted after {self._max_iterations} attempts. "
284
+ f"Last diagnosis: {diagnosis.error_type}"
285
+ )
286
+
287
+ def _is_success(self, result: ExecutionResult) -> bool:
288
+ """Return ``True`` when the result represents a clean execution."""
289
+ return (
290
+ result.exit_code == 0
291
+ and not result.silent_failure_detected
292
+ and not result.timed_out
293
+ )
294
+
295
+ def _generate_and_validate_patch(
296
+ self,
297
+ code: str,
298
+ diagnosis: ErrorDiagnosis,
299
+ ) -> PatchProposal | None:
300
+ """Produce a patch, validate it with the AST and return the proposal."""
301
+ patched_code = self._patch_generator(code, diagnosis)
302
+ if patched_code is None or patched_code == code:
303
+ return None
304
+
305
+ report = validate_syntax_and_safety(patched_code)
306
+ return PatchProposal(
307
+ file_path=Path("<dynamic>"),
308
+ patched_code=patched_code,
309
+ is_syntax_valid=report.is_valid,
310
+ diagnosis=diagnosis,
311
+ )
312
+
313
+ def _apply_patch(
314
+ self,
315
+ request: CodeExecutionRequest,
316
+ proposal: PatchProposal,
317
+ attempt: int,
318
+ ) -> CodeExecutionRequest:
319
+ """Return a new request with the patched code and an updated file name."""
320
+ if not proposal.is_syntax_valid:
321
+ raise SelfHealingError(
322
+ f"Proposed patch failed AST validation: {proposal.patched_code[:200]}"
323
+ )
324
+
325
+ file_name = f"script_attempt_{attempt}.py"
326
+ return CodeExecutionRequest(
327
+ code=proposal.patched_code,
328
+ timeout_seconds=request.timeout_seconds,
329
+ requirements=list(request.requirements),
330
+ file_name=file_name,
331
+ )
332
+
333
+ def _syntax_failure_result(
334
+ self,
335
+ request: CodeExecutionRequest,
336
+ report: ValidationReport,
337
+ ) -> ExecutionResult:
338
+ """Build a synthetic ``ExecutionResult`` for a syntax validation failure."""
339
+ stderr = report.violations[0] if report.violations else "Syntax validation failed"
340
+ return ExecutionResult(
341
+ stdout="",
342
+ stderr=stderr,
343
+ exit_code=1,
344
+ duration_seconds=0.0,
345
+ silent_failure_detected=False,
346
+ timed_out=False,
347
+ )
348
+
349
+ def _default_patch_generator(
350
+ self,
351
+ code: str,
352
+ diagnosis: ErrorDiagnosis,
353
+ ) -> str | None:
354
+ """Generate a deterministic patch based on the diagnosis.
355
+
356
+ The default generator only handles a small, safe subset of runtime errors:
357
+ - ``NameError``: add an ``import`` or placeholder definition.
358
+ - ``ImportError`` / ``ModuleNotFoundError``: try a well-known alias.
359
+ - ``SyntaxError``: not auto-patched (returns ``None``).
360
+
361
+ More complex errors require a model-based patch generator.
362
+ """
363
+ if diagnosis.error_type == "NameError":
364
+ missing = self._classifier.extract_missing_name(diagnosis)
365
+ if missing is None:
366
+ return None
367
+
368
+ if missing in {
369
+ "math",
370
+ "json",
371
+ "os",
372
+ "sys",
373
+ "re",
374
+ "time",
375
+ "datetime",
376
+ "collections",
377
+ "itertools",
378
+ "pathlib",
379
+ "typing",
380
+ }:
381
+ return f"import {missing}\n{code}"
382
+ return f"{missing} = None\n{code}"
383
+
384
+ if diagnosis.error_type in {"ImportError", "ModuleNotFoundError"}:
385
+ missing = self._classifier.extract_missing_module(diagnosis)
386
+ if missing is None:
387
+ return None
388
+
389
+ # Simple alias fallback for common data-science package names.
390
+ aliases: dict[str, str] = {
391
+ "sklearn": "import sklearn",
392
+ "pandas": "import pandas as pd",
393
+ "numpy": "import numpy as np",
394
+ "matplotlib": "import matplotlib",
395
+ }
396
+ if missing in aliases:
397
+ return f"{aliases[missing]}\n{code}"
398
+ return None
399
+
400
+ if diagnosis.error_type in {
401
+ "IndexError",
402
+ "TypeError",
403
+ "AttributeError",
404
+ "ZeroDivisionError",
405
+ "ValueError",
406
+ "KeyError",
407
+ }:
408
+ # These categories require domain knowledge to patch safely.
409
+ # Gemini handles them when the LLM path is configured.
410
+ return None
411
+
412
+ return None
413
+
414
+ def __enter__(self) -> SelfHealingEngine:
415
+ """Ensure the sandbox exists when used as a context manager."""
416
+ self._sandbox.create()
417
+ return self
418
+
419
+ def __exit__(self, *exc: object) -> None:
420
+ self._sandbox.cleanup()
codeshield/runner.py ADDED
@@ -0,0 +1,221 @@
1
+ """Subprocess runner that executes Python code inside a ``uv`` sandbox."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import logging
7
+ import os
8
+ import re
9
+ import subprocess
10
+ import threading
11
+ import time
12
+ from collections.abc import Iterable
13
+
14
+ from codeshield.environment import SandboxError, SandboxManager
15
+ from codeshield.schemas import CodeExecutionRequest, ExecutionResult
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ DEFAULT_TIMEOUT_SECONDS: float = 60.0
21
+ DEFAULT_SILENT_PATTERNS: tuple[str, ...] = (
22
+ r"empty\s+[Dd]ata[Ff]rame",
23
+ r"all\s+[Nn]a[Nn]",
24
+ r"Traceback",
25
+ r"Pipeline\s+failed",
26
+ r"[Ff]atal\s+[Ee]rror",
27
+ )
28
+
29
+
30
+ class SubprocessRunnerError(RuntimeError):
31
+ """Raised when the runner cannot prepare or launch a code execution."""
32
+
33
+
34
+ class SubprocessRunner:
35
+ """Execute Python source code in an isolated subprocess with streaming I/O."""
36
+
37
+ def __init__(
38
+ self,
39
+ sandbox: SandboxManager,
40
+ default_timeout: float = DEFAULT_TIMEOUT_SECONDS,
41
+ silent_failure_patterns: Iterable[str] | None = None,
42
+ ) -> None:
43
+ """Initialize the runner.
44
+
45
+ Args:
46
+ sandbox: ``SandboxManager`` that provides the isolated interpreter.
47
+ default_timeout: Default execution timeout in seconds.
48
+ silent_failure_patterns: Optional regex patterns used to detect silent
49
+ failures inside process output.
50
+ """
51
+ if default_timeout <= 0:
52
+ raise SubprocessRunnerError("default_timeout must be greater than 0")
53
+
54
+ self._sandbox = sandbox
55
+ self._default_timeout = default_timeout
56
+ self._silent_patterns = [
57
+ re.compile(pattern, re.IGNORECASE)
58
+ for pattern in (silent_failure_patterns or DEFAULT_SILENT_PATTERNS)
59
+ ]
60
+
61
+ def run(
62
+ self,
63
+ request: CodeExecutionRequest | str,
64
+ timeout: float | None = None,
65
+ ) -> ExecutionResult:
66
+ """Run ``request.code`` inside the sandbox and return the result.
67
+
68
+ Args:
69
+ request: Either a ``CodeExecutionRequest`` or a raw source string.
70
+ timeout: Optional override for the execution timeout. Defaults to
71
+ ``default_timeout`` or ``request.timeout_seconds`` when a request
72
+ object is supplied.
73
+
74
+ Returns:
75
+ An ``ExecutionResult`` with captured output, timing and flags.
76
+
77
+ Raises:
78
+ SubprocessRunnerError: when the sandbox interpreter is missing.
79
+ """
80
+ if isinstance(request, str):
81
+ request = CodeExecutionRequest(code=request)
82
+
83
+ effective_timeout = timeout if timeout is not None else request.timeout_seconds
84
+ file_name = request.file_name or "script.py"
85
+ script_path = self._sandbox.workspace / file_name
86
+
87
+ try:
88
+ script_path.write_text(request.code, encoding="utf-8")
89
+ except OSError as exc:
90
+ raise SubprocessRunnerError(f"Failed to write script to {script_path}: {exc}") from exc
91
+
92
+ if not self._sandbox.python_executable.exists():
93
+ raise SubprocessRunnerError(
94
+ f"Sandbox interpreter not found at {self._sandbox.python_executable}"
95
+ )
96
+
97
+ self._install_requirements_if_needed(request.requirements)
98
+
99
+ cmd = [str(self._sandbox.python_executable), str(script_path)]
100
+ env = self._build_environment()
101
+
102
+ logger.info("Executing %s with timeout %.1fs", script_path, effective_timeout)
103
+ return self._run_subprocess(cmd, env, effective_timeout)
104
+
105
+ def _install_requirements_if_needed(self, packages: list[str]) -> None:
106
+ """Install requested packages in the sandbox before execution."""
107
+ if not packages:
108
+ return
109
+ try:
110
+ self._sandbox.install_requirements(packages)
111
+ except SandboxError as exc:
112
+ raise SubprocessRunnerError(f"Failed to install requirements: {exc}") from exc
113
+
114
+ def _build_environment(self) -> dict[str, str]:
115
+ """Return the environment for the subprocess.
116
+
117
+ Inherit ``PATH`` from the parent so that ``uv``-managed binaries work,
118
+ but remove Python-specific variables that could leak the parent venv.
119
+ """
120
+ env = os.environ.copy()
121
+ for key in ("VIRTUAL_ENV", "PYTHONHOME", "PYTHONPATH"):
122
+ env.pop(key, None)
123
+ env["PYTHONUNBUFFERED"] = "1"
124
+ return env
125
+
126
+ def _run_subprocess(
127
+ self,
128
+ cmd: list[str],
129
+ env: dict[str, str],
130
+ timeout: float,
131
+ ) -> ExecutionResult:
132
+ """Launch the process, stream I/O, enforce timeout and return result."""
133
+ start = time.monotonic()
134
+ try:
135
+ process = subprocess.Popen(
136
+ cmd,
137
+ stdout=subprocess.PIPE,
138
+ stderr=subprocess.PIPE,
139
+ text=True,
140
+ env=env,
141
+ bufsize=1,
142
+ )
143
+ except OSError as exc:
144
+ raise SubprocessRunnerError(f"Failed to start subprocess: {exc}") from exc
145
+
146
+ stdout_lines: list[str] = []
147
+ stderr_lines: list[str] = []
148
+
149
+ def reader(pipe, sink: list[str]) -> None:
150
+ """Read lines from a pipe without blocking the main thread."""
151
+ try:
152
+ for line in pipe:
153
+ sink.append(line)
154
+ except Exception as exc: # noqa: BLE001
155
+ logger.debug("Stream reader terminated: %s", exc)
156
+ finally:
157
+ pipe.close()
158
+
159
+ stdout_thread = threading.Thread(
160
+ target=reader,
161
+ args=(process.stdout, stdout_lines),
162
+ daemon=True,
163
+ )
164
+ stderr_thread = threading.Thread(
165
+ target=reader,
166
+ args=(process.stderr, stderr_lines),
167
+ daemon=True,
168
+ )
169
+ stdout_thread.start()
170
+ stderr_thread.start()
171
+
172
+ timed_out = False
173
+ try:
174
+ process.wait(timeout=timeout)
175
+ except subprocess.TimeoutExpired:
176
+ timed_out = True
177
+ logger.warning("Process exceeded timeout %.1fs; terminating", timeout)
178
+ self._terminate_process(process)
179
+
180
+ # Give the reader threads a short grace period to flush remaining bytes.
181
+ stdout_thread.join(timeout=1.0)
182
+ stderr_thread.join(timeout=1.0)
183
+
184
+ duration = time.monotonic() - start
185
+ stdout = "".join(stdout_lines)
186
+ stderr = "".join(stderr_lines)
187
+
188
+ exit_code = process.returncode
189
+ if exit_code is None:
190
+ exit_code = -1
191
+
192
+ silent_failure = self._detect_silent_failure(exit_code, stdout, stderr)
193
+
194
+ return ExecutionResult(
195
+ stdout=stdout,
196
+ stderr=stderr,
197
+ exit_code=exit_code,
198
+ duration_seconds=round(duration, 6),
199
+ silent_failure_detected=silent_failure,
200
+ timed_out=timed_out,
201
+ )
202
+
203
+ def _terminate_process(self, process: subprocess.Popen) -> None:
204
+ """Gracefully terminate and, if necessary, kill the process."""
205
+ with contextlib.suppress(OSError):
206
+ process.terminate()
207
+
208
+ try:
209
+ process.wait(timeout=2.0)
210
+ except subprocess.TimeoutExpired:
211
+ with contextlib.suppress(OSError):
212
+ process.kill()
213
+ process.wait(timeout=2.0)
214
+
215
+ def _detect_silent_failure(self, exit_code: int, stdout: str, stderr: str) -> bool:
216
+ """Return ``True`` when a zero exit code hides suspicious output patterns."""
217
+ if exit_code != 0:
218
+ return False
219
+
220
+ combined = f"{stdout}\n{stderr}"
221
+ return any(pattern.search(combined) for pattern in self._silent_patterns)