File size: 8,993 Bytes
df6cd5e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
"""
core/auto_debug.py β€” parse β†’ focused fix β†’ re-test loop (max 3 attempts).

v4.6 additions
--------------
* Queries :mod:`core.patch_memory` for previously-failed patches that match
  the current error signature; results are injected into the fix prompt as
  "known-bad" guidance so the coder is nudged away from repeating them.
* On loop exhaustion, records the final failed patch back into patch memory
  so future runs benefit from the same signal.
"""

from __future__ import annotations

import logging
import re
from datetime import datetime
from typing import Any, Dict, List, Optional

try:
    from core.patch_memory import get_default_memory
except Exception:  # pragma: no cover β€” memory is optional
    get_default_memory = None  # type: ignore[assignment]


class AutoDebugLoop:
    MAX_AUTO_DEBUG_ATTEMPTS = 3

    ERROR_PATTERNS = {
        "python":  r'File "(?P<file>[^"]+)", line (?P<line>\d+).*?\n(?P<error_type>\w+(?:Error|Exception)?):\s*(?P<message>.*)',
        "node":    r'(?P<error_type>\w+(?:Error|Exception)?):\s*(?P<message>.*)\s+at\s+(?P<file>[^:]+):(?P<line>\d+)',
        "generic": r'(?P<error_type>\w+):\s*(?P<message>.*)',
    }

    # v4.6 β€” a lightweight fingerprint for lint-only failures so patch memory
    # can distinguish "test failed" from "lint rejected the patch".
    _LINT_MARKER = "[static-analysis] lint gate failed"

    def __init__(self, memory=None) -> None:
        # Injected for tests; falls back to the module-level singleton so
        # existing call sites (``AutoDebugLoop()``) keep working unchanged.
        self._memory = memory

    # ------------------------------------------------------------------
    # Memory access (lazy so tests can monkey-patch the module)
    # ------------------------------------------------------------------
    def _get_memory(self):
        if self._memory is not None:
            return self._memory
        if get_default_memory is None:
            return None
        try:
            return get_default_memory()
        except Exception as exc:  # pragma: no cover β€” never fatal
            logging.warning("patch_memory unavailable: %s", exc)
            return None

    # ------------------------------------------------------------------
    # Error parsing
    # ------------------------------------------------------------------
    def parse_error(self, log: str, language: str = "python") -> Dict[str, Any]:
        pat = self.ERROR_PATTERNS.get(language, self.ERROR_PATTERNS["generic"])
        m = re.search(pat, log or "", re.DOTALL)
        if m:
            g = m.groupdict()
            line_s = g.get("line", "0")
            diag = {
                "error_type": g.get("error_type", "Unknown"),
                "file_path": g.get("file", "unknown"),
                "line_number": int(line_s) if str(line_s).isdigit() else 0,
                "message": (g.get("message") or "").strip(),
                "raw_log": log,
            }
        else:
            diag = {
                "error_type": "Unknown", "file_path": "unknown", "line_number": 0,
                "message": (log or "")[:200], "raw_log": log,
            }
        if log and self._LINT_MARKER in log:
            # Tag lint-only failures so patch memory can filter them later.
            diag["error_type"] = diag.get("error_type") or "LintError"
            diag["tags"] = ["lint"]
        return diag

    # ------------------------------------------------------------------
    # Fix request (with patch-memory advisory injection)
    # ------------------------------------------------------------------
    def request_fix(self, diag: Dict[str, Any], coder, state: Dict[str, Any]
                    ) -> Optional[str]:
        advisory = ""
        memory = self._get_memory()
        if memory is not None:
            try:
                matches = memory.query_similar(diag)
                if matches:
                    advisory = "\n\n" + memory.format_advisory(matches) + "\n\n"
                    state.setdefault("patch_memory_matches", []).extend(matches)
            except Exception as exc:  # pragma: no cover β€” memory is advisory
                logging.warning("patch_memory query failed: %s", exc)

        instruction = (
            f"URGENT FIX: file `{diag['file_path']}` line {diag['line_number']}\n"
            f"Error: {diag['error_type']}: {diag['message']}\n"
            f"{advisory}"
            f"Provide ONLY a minimal Unified Diff patch (or replacement code "
            f"in a fenced block) fixing this exact error. "
            f"Do NOT rewrite the entire file."
        )
        try:
            new_state = coder.run(state.copy(), instruction=instruction)
            return new_state.get("generated_code")
        except Exception as exc:
            logging.error("AutoDebug coder call failed: %s", exc)
            return None

    # ------------------------------------------------------------------
    # Main loop
    # ------------------------------------------------------------------
    def run(self, error_log: str, coder, tester, state: Dict[str, Any],
            language: str = "python") -> Dict[str, Any]:
        timeline: List[Dict[str, Any]] = []
        current = error_log
        last_patch: Optional[str] = None
        last_diag: Dict[str, Any] = {}

        for attempt in range(1, self.MAX_AUTO_DEBUG_ATTEMPTS + 1):
            diag = self.parse_error(current, language)
            last_diag = diag
            timeline.append({
                "timestamp": datetime.now().strftime("%H:%M:%S"),
                "agent": "AutoDebug", "status": "running",
                "message": f"attempt {attempt}/{self.MAX_AUTO_DEBUG_ATTEMPTS}: "
                           f"fixing {diag['error_type']}",
            })
            patch = self.request_fix(diag, coder, state)
            if not patch:
                timeline.append({
                    "timestamp": datetime.now().strftime("%H:%M:%S"),
                    "agent": "AutoDebug", "status": "failed",
                    "message": f"attempt {attempt}: coder returned empty",
                })
                continue
            last_patch = patch

            state["generated_code"] = patch
            state["file_path"] = diag["file_path"]

            prev_flag = state.get("auto_debug_enabled", True)
            state["auto_debug_enabled"] = False
            try:
                test_state = tester.run(state)
            finally:
                state["auto_debug_enabled"] = prev_flag

            if test_state.get("test_passed"):
                timeline.append({
                    "timestamp": datetime.now().strftime("%H:%M:%S"),
                    "agent": "AutoDebug", "status": "success",
                    "message": f"attempt {attempt}: fixed",
                })
                return {
                    "success": True, "attempts": attempt,
                    "final_logs": test_state.get("test_logs", ""),
                    "timeline_entries": timeline,
                }
            current = test_state.get("test_logs", current)

        # ------------------------------------------------------------------
        # Exhausted β†’ record final failed patch into memory for next time.
        # ------------------------------------------------------------------
        memory = self._get_memory()
        if memory is not None and last_patch:
            try:
                stored = memory.record_failure(
                    diagnostic=last_diag,
                    patch_text=last_patch,
                    attempts=self.MAX_AUTO_DEBUG_ATTEMPTS,
                    tags=list(last_diag.get("tags", []) or []) + ["autodebug-exhausted"],
                    extra={
                        "language": language,
                        "command": state.get("command"),
                    },
                )
                state.setdefault("patch_memory_recorded", []).append(stored)
                timeline.append({
                    "timestamp": datetime.now().strftime("%H:%M:%S"),
                    "agent": "PatchMemory", "status": "OK",
                    "message": f"recorded failed patch {stored.get('id')}",
                })
            except Exception as exc:  # pragma: no cover β€” never fatal
                timeline.append({
                    "timestamp": datetime.now().strftime("%H:%M:%S"),
                    "agent": "PatchMemory", "status": "FAIL",
                    "message": f"record failed: {exc}",
                })

        timeline.append({
            "timestamp": datetime.now().strftime("%H:%M:%S"),
            "agent": "AutoDebug", "status": "failed",
            "message": f"all {self.MAX_AUTO_DEBUG_ATTEMPTS} attempts failed",
        })
        return {
            "success": False,
            "attempts": self.MAX_AUTO_DEBUG_ATTEMPTS,
            "final_logs": current,
            "timeline_entries": timeline,
        }


__all__ = ["AutoDebugLoop"]