autodebug 0.5.1__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.
- autodebug/__init__.py +3 -0
- autodebug/__main__.py +11 -0
- autodebug/cli.py +432 -0
- autodebug/connection.py +219 -0
- autodebug/extension/README.md +16 -0
- autodebug/extension/extension.js +40 -0
- autodebug/extension/mcp.js +98 -0
- autodebug/extension/package.json +28 -0
- autodebug/extension/relay.js +570 -0
- autodebug/hub.py +267 -0
- autodebug/install.py +346 -0
- autodebug/mcp_server.py +270 -0
- autodebug/redact.py +38 -0
- autodebug/report.py +107 -0
- autodebug/session.py +828 -0
- autodebug/tools.py +472 -0
- autodebug-0.5.1.dist-info/METADATA +251 -0
- autodebug-0.5.1.dist-info/RECORD +20 -0
- autodebug-0.5.1.dist-info/WHEEL +4 -0
- autodebug-0.5.1.dist-info/entry_points.txt +2 -0
autodebug/tools.py
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
"""The agent-facing debugging tools (docs/design.md, "Agent tools").
|
|
2
|
+
|
|
3
|
+
`Debugger` keeps one connection to the chosen IDE window, the session's
|
|
4
|
+
timeline for the report, and applies redaction to everything it returns.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
from autodebug import report
|
|
15
|
+
from autodebug.connection import IDE_NAMES, DebugError, Window, choose, discover
|
|
16
|
+
from autodebug.redact import REDACTED, clean_text, clean_value, is_secret_name
|
|
17
|
+
from autodebug.session import Session, connect
|
|
18
|
+
|
|
19
|
+
SHORT_VALUE = 200
|
|
20
|
+
_NO_ANSWER = ("Nobody answered the Approve dialog in {ide} within 2 minutes, so nothing ran; it was probably "
|
|
21
|
+
"behind another window. This was not a Deny. Ask the developer whether to try again.")
|
|
22
|
+
STOPPED = ("paused", "ended", "denied", "build_failed")
|
|
23
|
+
|
|
24
|
+
_NEXT = {
|
|
25
|
+
"awaiting_approval": ("Tell the developer the Approve dialog is open in {ide} (switch to {ide} if it is hidden) "
|
|
26
|
+
"and that it closes unanswered after 2 minutes; then call wait."),
|
|
27
|
+
"denied": "The developer did not approve; nothing ran. Ask before trying again.",
|
|
28
|
+
"building": "The build task is running; call wait.",
|
|
29
|
+
"build_failed": "The build failed (output is in the IDE terminal). Fix it or ask the developer.",
|
|
30
|
+
"starting": "The debugger is starting. Large programs can take minutes to load symbols, so keep calling wait.",
|
|
31
|
+
"running": "The program is running; call wait for the next stop, or pause.",
|
|
32
|
+
"paused": "Paused. Use inspect, stack, step, continue, trace or breakpoints.",
|
|
33
|
+
"ended": "The debug session ended. Call write_report with your findings.",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_ERROR_LINE = re.compile(r"error|exception|fail|traceback|assert|panic|fatal", re.IGNORECASE)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _telling_lines(output: list[str], count: int = 3) -> list[str]:
|
|
41
|
+
"""The output lines that explain an exit: errors and failures first, else the last lines."""
|
|
42
|
+
lines = [line.strip() for line in output if line.strip() and set(line.strip()) - set("-=_*")]
|
|
43
|
+
errors = [line for line in lines if _ERROR_LINE.search(line)]
|
|
44
|
+
return (errors or lines)[-count:]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Event:
|
|
49
|
+
kind: str
|
|
50
|
+
where: str
|
|
51
|
+
detail: str
|
|
52
|
+
at: float = field(default_factory=time.time)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Debugger:
|
|
56
|
+
def __init__(self, find_windows: Callable[[], list[Window]] = discover, connect: Callable[[Window], Session] = connect) -> None:
|
|
57
|
+
self._find_windows = find_windows
|
|
58
|
+
self._connect = connect
|
|
59
|
+
self.connection: Session | None = None
|
|
60
|
+
self.folder: str | None = None
|
|
61
|
+
self.version = 0
|
|
62
|
+
self.timeline: list[Event] = []
|
|
63
|
+
self.plan: dict = {}
|
|
64
|
+
self.sessions = 0
|
|
65
|
+
self._last_status: str | None = None
|
|
66
|
+
self._last_build: dict | None = None
|
|
67
|
+
self._last_exception: str | None = None
|
|
68
|
+
|
|
69
|
+
# -- helpers ------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def _log(self, kind: str, where: str = "", detail: str = "") -> None:
|
|
72
|
+
self.timeline.append(Event(kind, clean_text(where, 300), clean_text(detail, 1000)))
|
|
73
|
+
|
|
74
|
+
def _window(self, folder: str | None, ide: str | None = None) -> Window:
|
|
75
|
+
return choose(self._find_windows(), folder, ide)
|
|
76
|
+
|
|
77
|
+
def _session(self) -> Session:
|
|
78
|
+
if self.connection is None:
|
|
79
|
+
raise DebugError("NOT_FOUND", "no debug session; call start_session first")
|
|
80
|
+
return self.connection
|
|
81
|
+
|
|
82
|
+
def _call(self, op: str, args: dict | None = None, timeout: float = 30.0) -> dict:
|
|
83
|
+
return self._session().call(op, args, timeout=timeout)
|
|
84
|
+
|
|
85
|
+
def _state(self, raw: dict, quiet: bool = False) -> dict:
|
|
86
|
+
"""The session state as the agent sees it, logging changes on the timeline.
|
|
87
|
+
`quiet`: the caller (step, trace) already logged this stop."""
|
|
88
|
+
self.version = max(self.version, int(raw.get("version") or 0))
|
|
89
|
+
status = raw.get("status", "none")
|
|
90
|
+
location = raw.get("location") or None
|
|
91
|
+
where = f"{location['file']}:{location['line']}" if location else ""
|
|
92
|
+
result = {"status": status}
|
|
93
|
+
if where:
|
|
94
|
+
result["location"] = clean_text(where, 300)
|
|
95
|
+
for key in ("reason", "exit_code", "message"):
|
|
96
|
+
if raw.get(key) not in (None, ""):
|
|
97
|
+
result[key] = clean_text(raw[key], 500) if isinstance(raw[key], str) else raw[key]
|
|
98
|
+
if "steps_used" in raw:
|
|
99
|
+
result["steps"] = f"{raw['steps_used']} of {raw['max_steps']} used"
|
|
100
|
+
if raw.get("breakpoints"):
|
|
101
|
+
result["breakpoints"] = [
|
|
102
|
+
{"at": clean_text(f"function {b['function']}" if b.get("function") else f"{b.get('file')}:{b.get('line')}", 300),
|
|
103
|
+
"source": b.get("source", "session"), "verified": b.get("verified", False),
|
|
104
|
+
**({"note": clean_text(b["message"], 200)} if b.get("message") else {})}
|
|
105
|
+
for b in raw["breakpoints"]
|
|
106
|
+
]
|
|
107
|
+
output = [clean_text(line, 500) for line in raw.get("output") or []]
|
|
108
|
+
if output and status == "ended":
|
|
109
|
+
result["output"] = output
|
|
110
|
+
if raw.get("output_note"):
|
|
111
|
+
result["output_note"] = raw["output_note"]
|
|
112
|
+
if raw.get("thread"):
|
|
113
|
+
result["thread"] = raw["thread"]
|
|
114
|
+
if raw.get("exception"):
|
|
115
|
+
result["exception"] = {"id": clean_text(raw["exception"].get("id"), 200),
|
|
116
|
+
"description": clean_text(raw["exception"].get("description"), 500)}
|
|
117
|
+
if result["exception"]["id"] != self._last_exception:
|
|
118
|
+
self._last_exception = result["exception"]["id"]
|
|
119
|
+
self._log("exception", result["exception"]["id"], result["exception"]["description"])
|
|
120
|
+
key = (status, where, raw.get("reason"))
|
|
121
|
+
changed, previous = key != self._last_status, self._last_status
|
|
122
|
+
if changed and previous and previous[0] == "awaiting_approval" and status not in ("denied", "awaiting_approval"):
|
|
123
|
+
self._log("approved", "", "the developer clicked Approve")
|
|
124
|
+
build = raw.get("build")
|
|
125
|
+
if build and build.get("status") != "running" and build != self._last_build:
|
|
126
|
+
self._last_build = build
|
|
127
|
+
self._log("build", str(build.get("task")), f"{build.get('status')}, exit code {build.get('exit_code')}")
|
|
128
|
+
if changed:
|
|
129
|
+
self._last_status = key
|
|
130
|
+
if quiet and status == "paused":
|
|
131
|
+
pass
|
|
132
|
+
elif status == "paused":
|
|
133
|
+
self._log("stopped", where, str(raw.get("reason") or ""))
|
|
134
|
+
elif status == "ended":
|
|
135
|
+
code = raw.get("exit_code")
|
|
136
|
+
last = _telling_lines(output)
|
|
137
|
+
self._log("ended", "", f"{raw.get('reason') or ''}" + (f", exit code {code}" if code is not None else "")
|
|
138
|
+
+ (f"; output: {' / '.join(last)}" if last else ""))
|
|
139
|
+
elif status in ("denied", "build_failed", "running"):
|
|
140
|
+
self._log(status, "", str(raw.get("reason") or raw.get("message") or ""))
|
|
141
|
+
ide = self.connection.window.ide if self.connection else "IDE"
|
|
142
|
+
next_step = _NEXT.get(status, "")
|
|
143
|
+
if status == "denied" and "no answer" in str(raw.get("reason") or ""):
|
|
144
|
+
next_step = _NO_ANSWER
|
|
145
|
+
result["next"] = next_step.format(ide=IDE_NAMES.get(ide, "IDE"))
|
|
146
|
+
if status == "paused":
|
|
147
|
+
result["variables"] = self._short_variables()
|
|
148
|
+
return result
|
|
149
|
+
|
|
150
|
+
def _wait(self, seconds: float, until: tuple[str, ...] = STOPPED) -> dict:
|
|
151
|
+
deadline = time.monotonic() + max(0.0, seconds)
|
|
152
|
+
while True:
|
|
153
|
+
remaining = deadline - time.monotonic()
|
|
154
|
+
wait_ms = int(max(0.0, min(remaining, 25.0)) * 1000)
|
|
155
|
+
raw = self._call("state", {"since": self.version, "wait_ms": wait_ms}, timeout=wait_ms / 1000 + 10)["state"]
|
|
156
|
+
self.version = max(self.version, int(raw.get("version") or 0))
|
|
157
|
+
if raw.get("status") in until or time.monotonic() >= deadline:
|
|
158
|
+
return raw
|
|
159
|
+
|
|
160
|
+
def _raw_variables(self, frame: int | None = None, names: list[str] | None = None, depth: int = 0) -> list[dict]:
|
|
161
|
+
args = {"names": names, "depth": max(0, min(int(depth), 3))}
|
|
162
|
+
if frame is not None:
|
|
163
|
+
args["frame"] = frame
|
|
164
|
+
return self._call("variables", args).get("variables", [])
|
|
165
|
+
|
|
166
|
+
def _short_variables(self, limit: int = 30) -> list[dict]:
|
|
167
|
+
try:
|
|
168
|
+
raw = self._raw_variables()
|
|
169
|
+
except DebugError:
|
|
170
|
+
return []
|
|
171
|
+
return [self._clean_variable(v, SHORT_VALUE) for v in raw[:limit]]
|
|
172
|
+
|
|
173
|
+
def _clean_variable(self, variable: dict, limit: int, parent_secret: bool = False) -> dict:
|
|
174
|
+
secret = parent_secret or is_secret_name(variable.get("name", ""))
|
|
175
|
+
cleaned = {"name": variable.get("name", ""), "type": variable.get("type", ""),
|
|
176
|
+
"value": REDACTED if secret else clean_value(variable.get("name", ""), variable.get("value"), limit)}
|
|
177
|
+
if variable.get("expandable"):
|
|
178
|
+
cleaned["expandable"] = True
|
|
179
|
+
if variable.get("more"):
|
|
180
|
+
cleaned["more"] = variable["more"]
|
|
181
|
+
if variable.get("children"):
|
|
182
|
+
cleaned["children"] = [self._clean_variable(child, limit, secret) for child in variable["children"]]
|
|
183
|
+
return cleaned
|
|
184
|
+
|
|
185
|
+
def _values(self) -> dict[str, str]:
|
|
186
|
+
return {v["name"]: clean_value(v["name"], v.get("value")) for v in self._raw_variables()}
|
|
187
|
+
|
|
188
|
+
# -- tools ---------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
def list_targets(self, workspace_folder: str | None = None, ide: str | None = None) -> dict:
|
|
191
|
+
found = self._find_windows()
|
|
192
|
+
windows = [w for w in found if w.current and (ide is None or w.ide == ide)]
|
|
193
|
+
if workspace_folder:
|
|
194
|
+
windows = [choose(found, workspace_folder, ide)]
|
|
195
|
+
if not windows:
|
|
196
|
+
choose(found, None, ide) # raises the setup or version hint
|
|
197
|
+
found = []
|
|
198
|
+
for window in windows:
|
|
199
|
+
connection = self._connect(window)
|
|
200
|
+
try:
|
|
201
|
+
folders = [workspace_folder] if workspace_folder else list(window.folders)
|
|
202
|
+
targets = [connection.call("targets", {"folder": f}) for f in folders]
|
|
203
|
+
finally:
|
|
204
|
+
connection.close()
|
|
205
|
+
found.append({"ide": window.ide, "folders": targets})
|
|
206
|
+
return {"windows": found,
|
|
207
|
+
"next": "Call start_session with a launch configuration name, the breakpoints and your goal."}
|
|
208
|
+
|
|
209
|
+
def start_session(self, launch_configuration: str, breakpoints: list[dict], goal: str,
|
|
210
|
+
workspace_folder: str | None = None, build_task: str | None = None,
|
|
211
|
+
max_steps: int = 100, max_minutes: int = 30, wait_seconds: float = 30,
|
|
212
|
+
build_minutes: int = 30, process_id: int | None = None,
|
|
213
|
+
source_folders: list[str] | None = None,
|
|
214
|
+
function_breakpoints: list[str] | None = None, ide: str | None = None) -> dict:
|
|
215
|
+
window = self._window(workspace_folder, ide)
|
|
216
|
+
if self.connection is not None:
|
|
217
|
+
self.connection.close()
|
|
218
|
+
self.connection = self._connect(window)
|
|
219
|
+
args = {"folder": workspace_folder, "launch": launch_configuration, "task": build_task,
|
|
220
|
+
"breakpoints": breakpoints, "goal": goal, "max_steps": max_steps, "max_minutes": max_minutes,
|
|
221
|
+
"build_minutes": build_minutes, "process_id": process_id, "source_folders": source_folders or [],
|
|
222
|
+
"function_breakpoints": function_breakpoints or []}
|
|
223
|
+
try:
|
|
224
|
+
raw = self._call("start", args)["state"]
|
|
225
|
+
except DebugError:
|
|
226
|
+
self.connection.close()
|
|
227
|
+
self.connection = None
|
|
228
|
+
raise
|
|
229
|
+
# The timeline runs until the next report, so it covers every session behind it.
|
|
230
|
+
self.version, self._last_status, self._last_build = 0, None, None
|
|
231
|
+
self.sessions += 1
|
|
232
|
+
self.folder = workspace_folder or (window.folders[0] if len(window.folders) == 1 else None)
|
|
233
|
+
self.plan = {"goal": goal, "launch": launch_configuration, "task": build_task,
|
|
234
|
+
"breakpoints": [f"{b.get('file')}:{b.get('line')}" for b in breakpoints], "ide": window.ide}
|
|
235
|
+
self._log(f"session {self.sessions}", "", f"asked the developer to approve running {launch_configuration!r}"
|
|
236
|
+
+ (f" after building {build_task!r}" if build_task else ""))
|
|
237
|
+
self._state(raw)
|
|
238
|
+
return self._state(self._wait(min(max(wait_seconds, 0), 55)))
|
|
239
|
+
|
|
240
|
+
def wait(self, timeout_seconds: float = 30, ask_developer: str | None = None) -> dict:
|
|
241
|
+
if ask_developer is not None and len(ask_developer) > 300:
|
|
242
|
+
raise DebugError("BAD_REQUEST", "ask_developer is at most 300 characters")
|
|
243
|
+
if ask_developer:
|
|
244
|
+
self._log("asked the developer", "", clean_text(ask_developer, 300))
|
|
245
|
+
wait_ms = int(min(max(timeout_seconds, 0), 55) * 1000)
|
|
246
|
+
raw = self._call("state", {"since": self.version, "wait_ms": wait_ms, "ask_developer": ask_developer},
|
|
247
|
+
timeout=wait_ms / 1000 + 10)["state"]
|
|
248
|
+
return self._state(raw)
|
|
249
|
+
return self._state(self._wait(min(max(timeout_seconds, 0), 55)))
|
|
250
|
+
|
|
251
|
+
def step(self, kind: str = "over", count: int = 1, wait_ms: int = 15000) -> dict:
|
|
252
|
+
if kind not in ("over", "into", "out"):
|
|
253
|
+
raise DebugError("BAD_REQUEST", "kind must be over, into or out")
|
|
254
|
+
count = max(1, min(int(count), 20))
|
|
255
|
+
wait_ms = max(0, min(int(wait_ms), 60000))
|
|
256
|
+
before = self._values()
|
|
257
|
+
path = []
|
|
258
|
+
raw = {}
|
|
259
|
+
for _ in range(count):
|
|
260
|
+
raw = self._call("step", {"kind": kind, "wait_ms": wait_ms}, timeout=wait_ms / 1000 + 10)["state"]
|
|
261
|
+
location = raw.get("location")
|
|
262
|
+
if location:
|
|
263
|
+
path.append(f"{location['file']}:{location['line']}")
|
|
264
|
+
if raw.get("status") != "paused":
|
|
265
|
+
break
|
|
266
|
+
after = self._values() if raw.get("status") == "paused" else {}
|
|
267
|
+
changed = [{"name": n, "before": before.get(n, "(new)"), "after": v}
|
|
268
|
+
for n, v in after.items() if before.get(n) != v]
|
|
269
|
+
self._log(f"step {kind}" + (f" ×{len(path)}" if len(path) > 1 else ""), " → ".join(path[-5:]),
|
|
270
|
+
", ".join(f"{c['name']}: {c['before']} → {c['after']}" for c in changed[:8]))
|
|
271
|
+
result = self._state(raw, quiet=True)
|
|
272
|
+
result["changed"] = changed
|
|
273
|
+
if len(path) > 1:
|
|
274
|
+
result["path"] = path
|
|
275
|
+
if raw.get("status") == "running":
|
|
276
|
+
result["next"] = "It's a slow function: call wait or pause."
|
|
277
|
+
return result
|
|
278
|
+
|
|
279
|
+
def continue_(self, timeout_seconds: float = 30, until: dict | None = None) -> dict:
|
|
280
|
+
"""Run to the next stop, or with `until` run past other breakpoints to that line."""
|
|
281
|
+
timeout = min(max(timeout_seconds, 0), 55)
|
|
282
|
+
if until is None:
|
|
283
|
+
self._call("continue")
|
|
284
|
+
return self._state(self._wait(timeout))
|
|
285
|
+
target = f"{until['file']}:{until['line']}"
|
|
286
|
+
existing = {b["file"] + ":" + str(b["line"]) for b in self._call("breakpoints", {})["state"].get("breakpoints", [])}
|
|
287
|
+
if target not in existing:
|
|
288
|
+
self._call("breakpoints", {"add": [{"file": until["file"], "line": until["line"]}]})
|
|
289
|
+
skipped: list[str] = []
|
|
290
|
+
deadline = time.monotonic() + timeout
|
|
291
|
+
try:
|
|
292
|
+
while True:
|
|
293
|
+
self._call("continue")
|
|
294
|
+
raw = self._wait(max(deadline - time.monotonic(), 0), until=("paused", "ended"))
|
|
295
|
+
location = raw.get("location")
|
|
296
|
+
where = f"{location['file']}:{location['line']}" if location else ""
|
|
297
|
+
if raw.get("status") != "paused" or where == target or len(skipped) >= 50 or time.monotonic() >= deadline:
|
|
298
|
+
break
|
|
299
|
+
skipped.append(where)
|
|
300
|
+
finally:
|
|
301
|
+
if target not in existing and self.connection is not None:
|
|
302
|
+
try:
|
|
303
|
+
self._call("breakpoints", {"remove": [{"file": until["file"], "line": until["line"]}]})
|
|
304
|
+
except DebugError:
|
|
305
|
+
pass
|
|
306
|
+
if skipped:
|
|
307
|
+
self._log("continued past", ", ".join(skipped[:5]), f"to reach {target}")
|
|
308
|
+
result = self._state(raw)
|
|
309
|
+
if skipped:
|
|
310
|
+
result["skipped_stops"] = skipped
|
|
311
|
+
return result
|
|
312
|
+
|
|
313
|
+
def pause(self) -> dict:
|
|
314
|
+
self._call("pause")
|
|
315
|
+
return self._state(self._wait(5, until=("paused", "ended")))
|
|
316
|
+
|
|
317
|
+
def threads(self, select: int | None = None) -> dict:
|
|
318
|
+
args = {} if select is None else {"select": select}
|
|
319
|
+
raw = self._call("threads", args)
|
|
320
|
+
result = {"threads": raw.get("threads", []), "current": raw.get("current")}
|
|
321
|
+
if raw.get("state"):
|
|
322
|
+
seen = self._state(raw["state"])
|
|
323
|
+
if seen.get("status"):
|
|
324
|
+
result["status"] = seen["status"]
|
|
325
|
+
if seen.get("location"):
|
|
326
|
+
result["location"] = seen["location"]
|
|
327
|
+
return result
|
|
328
|
+
|
|
329
|
+
def exceptions(self, enable: list[str] | None = None) -> dict:
|
|
330
|
+
return self._call("exceptions", {} if enable is None else {"enable": enable})
|
|
331
|
+
|
|
332
|
+
def inspect(self, names: list[str] | None = None, frame_index: int = 0, depth: int = 0) -> dict:
|
|
333
|
+
frame = None
|
|
334
|
+
if frame_index:
|
|
335
|
+
frames = self._call("stack", {"levels": frame_index + 1}).get("frames", [])
|
|
336
|
+
if frame_index >= len(frames):
|
|
337
|
+
raise DebugError("BAD_REQUEST", f"the stack has {len(frames)} frames")
|
|
338
|
+
frame = frames[frame_index]["id"]
|
|
339
|
+
raw = self._raw_variables(frame, names, depth)
|
|
340
|
+
limit = 2000 if names else SHORT_VALUE
|
|
341
|
+
variables = [self._clean_variable(v, limit) for v in raw]
|
|
342
|
+
self._log("inspected", f"frame {frame_index}", ", ".join(f"{v['name']}={v['value']}" for v in variables[:12]))
|
|
343
|
+
result = {"frame_index": frame_index, "variables": variables}
|
|
344
|
+
if names:
|
|
345
|
+
result["not_found"] = [n for n in names if n not in {v["name"] for v in variables}]
|
|
346
|
+
return result
|
|
347
|
+
|
|
348
|
+
def stack(self, levels: int = 20, start_frame: int = 0) -> dict:
|
|
349
|
+
body = self._call("stack", {"levels": max(1, min(int(levels), 200)), "start_frame": max(0, int(start_frame))})
|
|
350
|
+
frames = body.get("frames", [])
|
|
351
|
+
return {"total": body.get("total", len(frames)),
|
|
352
|
+
"frames": [{"index": start_frame + i, "function": clean_text(f.get("name"), 200),
|
|
353
|
+
"location": "(no source)" if f.get("file") in (None, "(no source)")
|
|
354
|
+
else clean_text(f"{f.get('file')}:{f.get('line')}", 300)}
|
|
355
|
+
for i, f in enumerate(frames)]}
|
|
356
|
+
|
|
357
|
+
def breakpoints(self, add: list[dict] | None = None, remove: list[dict] | None = None,
|
|
358
|
+
add_functions: list[str] | None = None, remove_functions: list[str] | None = None) -> dict:
|
|
359
|
+
raw = self._call("breakpoints", {"add": add or [], "remove": remove or [],
|
|
360
|
+
"add_functions": add_functions or [], "remove_functions": remove_functions or []})["state"]
|
|
361
|
+
changes = [f"{verb} " + ", ".join(f"{b.get('file')}:{b.get('line')}" for b in items)
|
|
362
|
+
for verb, items in (("added", add), ("removed", remove)) if items]
|
|
363
|
+
if add_functions:
|
|
364
|
+
changes.append("added functions " + ", ".join(add_functions))
|
|
365
|
+
if remove_functions:
|
|
366
|
+
changes.append("removed functions " + ", ".join(remove_functions))
|
|
367
|
+
if changes:
|
|
368
|
+
self._log("breakpoints", "", "; ".join(changes))
|
|
369
|
+
return {"breakpoints": self._state(raw).get("breakpoints", [])}
|
|
370
|
+
|
|
371
|
+
def trace(self, file: str, line: int, variables: list[str], hits: int = 10, after: bool = True,
|
|
372
|
+
timeout_seconds: float = 20) -> dict:
|
|
373
|
+
"""Run to `file:line` up to `hits` times. Each row has the caller and each variable as
|
|
374
|
+
`before → after` the line runs (one step over per hit when `after`). Stops early,
|
|
375
|
+
and returns the rows so far, when another breakpoint pauses the program."""
|
|
376
|
+
hits = max(1, min(int(hits), 50))
|
|
377
|
+
target = f"{file}:{line}"
|
|
378
|
+
existing = {f"{b['file']}:{b['line']}" for b in self._call("breakpoints", {})["state"].get("breakpoints", [])}
|
|
379
|
+
raw = self._call("breakpoints", {"add": [{"file": file, "line": line}]})["state"]
|
|
380
|
+
rows: list[dict] = []
|
|
381
|
+
notes: list[str] = []
|
|
382
|
+
|
|
383
|
+
def where(state: dict) -> str:
|
|
384
|
+
location = state.get("location")
|
|
385
|
+
return f"{location['file']}:{location['line']}" if location else ""
|
|
386
|
+
|
|
387
|
+
def values() -> dict[str, str]:
|
|
388
|
+
return {v["name"]: clean_value(v["name"], v.get("value"), SHORT_VALUE)
|
|
389
|
+
for v in self._raw_variables(names=list(variables))}
|
|
390
|
+
|
|
391
|
+
def record() -> dict:
|
|
392
|
+
frames = self._call("stack", {"levels": 2}).get("frames", [])
|
|
393
|
+
caller = clean_text(f"{frames[1]['file']}:{frames[1]['line']}", 300) if len(frames) > 1 else ""
|
|
394
|
+
before, after_values, state = values(), None, raw
|
|
395
|
+
if after:
|
|
396
|
+
try:
|
|
397
|
+
state = self._call("step", {"kind": "over"}, timeout=20)["state"]
|
|
398
|
+
after_values = values() if state.get("status") == "paused" else None
|
|
399
|
+
except DebugError as exc:
|
|
400
|
+
notes.append(f"no after-values: {exc.message}")
|
|
401
|
+
row = {"hit": len(rows) + 1, "caller": caller}
|
|
402
|
+
for name in variables:
|
|
403
|
+
old = before.get(name, "(not defined)")
|
|
404
|
+
new = after_values.get(name, "(not defined)") if after_values is not None else old
|
|
405
|
+
row[name] = old if new == old else f"{old} → {new}"
|
|
406
|
+
rows.append(row)
|
|
407
|
+
return state
|
|
408
|
+
|
|
409
|
+
consumed, first = False, True
|
|
410
|
+
try:
|
|
411
|
+
for _ in range(hits * 4 + 4):
|
|
412
|
+
if len(rows) >= hits:
|
|
413
|
+
break
|
|
414
|
+
status = raw.get("status")
|
|
415
|
+
if status == "paused" and where(raw) == target and not consumed:
|
|
416
|
+
raw = record()
|
|
417
|
+
consumed = not after
|
|
418
|
+
continue
|
|
419
|
+
if status == "paused" and not first and raw.get("reason") == "breakpoint" and where(raw) != target:
|
|
420
|
+
notes.append(f"stopped at {where(raw)} (another breakpoint); returning the rows so far")
|
|
421
|
+
break
|
|
422
|
+
if status == "paused":
|
|
423
|
+
self._call("continue")
|
|
424
|
+
elif status != "running":
|
|
425
|
+
break
|
|
426
|
+
first, consumed = False, False
|
|
427
|
+
raw = self._wait(timeout_seconds, until=("paused", "ended"))
|
|
428
|
+
if raw.get("status") == "running":
|
|
429
|
+
notes.append(f"{target} was not reached within {timeout_seconds:g} s")
|
|
430
|
+
break
|
|
431
|
+
finally:
|
|
432
|
+
if target not in existing and raw.get("status") != "ended":
|
|
433
|
+
try:
|
|
434
|
+
raw = self._call("breakpoints", {"remove": [{"file": file, "line": line}]})["state"]
|
|
435
|
+
except DebugError:
|
|
436
|
+
pass
|
|
437
|
+
self._log("trace", target, "; ".join(
|
|
438
|
+
", ".join(f"{k}={v}" for k, v in row.items() if k not in ("hit", "caller")) for row in rows[:10]))
|
|
439
|
+
result = self._state(raw, quiet=True)
|
|
440
|
+
result.update({"trace": target, "rows": rows})
|
|
441
|
+
if len(rows) < hits and not notes:
|
|
442
|
+
notes.append(f"{len(rows)} of {hits} hits: the program ended first")
|
|
443
|
+
if notes:
|
|
444
|
+
result["note"] = "; ".join(notes)
|
|
445
|
+
return result
|
|
446
|
+
|
|
447
|
+
def stop_session(self) -> dict:
|
|
448
|
+
raw = self._call("stop")["state"]
|
|
449
|
+
return self._state(raw)
|
|
450
|
+
|
|
451
|
+
def write_report(self, title: str, problem: str, root_cause: str, solution: str, confidence: str,
|
|
452
|
+
how_confirmed: str | None = None, verification: str | None = None,
|
|
453
|
+
open_questions: list[str] | None = None, workspace_folder: str | None = None) -> dict:
|
|
454
|
+
folder = workspace_folder or self.folder
|
|
455
|
+
if not folder:
|
|
456
|
+
raise DebugError("BAD_REQUEST", "pass workspace_folder: there is no session to take it from")
|
|
457
|
+
if confidence not in ("confirmed", "likely", "unconfirmed"):
|
|
458
|
+
raise DebugError("BAD_REQUEST", "confidence is confirmed, likely or unconfirmed")
|
|
459
|
+
if confidence == "confirmed" and not how_confirmed:
|
|
460
|
+
raise DebugError("BAD_REQUEST", "confirmed needs how_confirmed (for example: the fix makes the failing test pass)")
|
|
461
|
+
path = report.write(folder, report.Report(
|
|
462
|
+
title=title, problem=problem, root_cause=root_cause, solution=solution, confidence=confidence,
|
|
463
|
+
how_confirmed=how_confirmed, verification=verification, open_questions=list(open_questions or []),
|
|
464
|
+
plan=self.plan, timeline=list(self.timeline),
|
|
465
|
+
))
|
|
466
|
+
self.timeline, self.sessions = [], 0
|
|
467
|
+
return {"report_file": str(path), "next": "Tell the developer where the report is and summarize it."}
|
|
468
|
+
|
|
469
|
+
def close(self) -> None:
|
|
470
|
+
if self.connection is not None:
|
|
471
|
+
self.connection.close()
|
|
472
|
+
self.connection = None
|