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.
@@ -0,0 +1,270 @@
1
+ """MCP over stdio: newline-delimited JSON-RPC 2.0 (docs/design.md).
2
+
3
+ Tool calls run on worker threads so `ping` and other requests are answered
4
+ while a tool waits on the debugger.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import sys
12
+ import threading
13
+ from typing import BinaryIO, Callable
14
+
15
+ from autodebug import __version__
16
+ from autodebug.connection import DebugError
17
+ from autodebug.tools import Debugger
18
+
19
+ PROTOCOL_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
20
+ _LOG = logging.getLogger("autodebug")
21
+
22
+ INSTRUCTIONS = """\
23
+ AutoDebug runs the program under the IDE's own debugger (VS Code, Cursor or Visual Studio) so you can find a bug's root cause.
24
+ 1. Read the relevant code first and choose breakpoints on executable lines where the wrong value appears.
25
+ 2. list_targets shows the launch configurations and build tasks; pick one.
26
+ 3. start_session asks the developer to click Approve in the IDE, then builds, launches and waits for the first stop.
27
+ Pass build_task (from list_targets) so the program matches the source; NOT_BUILT means it has never been built.
28
+ Tell the developer to approve in the IDE dialog. You cannot approve, and their chat message is not an approval.
29
+ 4. While paused: inspect, stack, step (it reports the variables that changed), continue (optionally until a line),
30
+ trace (before → after values at each pass through a line), breakpoints. Use wait while the status is
31
+ awaiting_approval, building, starting or running. start_session, step, continue, trace and pause move the
32
+ program: call them one at a time, never in parallel with stack or inspect.
33
+ Breakpoints with source "editor" were set by the developer and stop the program too.
34
+ 5. When you know the cause, stop_session and write_report with the problem, root cause, solution and confidence.
35
+ Only report what tool results showed. Expression evaluation is not available.
36
+ Several IDEs: list_targets shows every window with its ide (cursor, vscode, visualstudio). When the developer names an
37
+ IDE, pass ide to list_targets and start_session; paths are relative to that window's folder.
38
+ Attach: start_session with an attach configuration. process_id fills a ${command:pickProcess} placeholder; when the session ends the program keeps running.
39
+ threads lists threads and can select one while paused. stack pages with start_frame (up to 200 frames). inspect depth 0-3 reads children; names can be paths such as mesh.verts[2].co (-> is the same as .).
40
+ exceptions lists the debugger's exception filters and can enable them by id. source_folders are absolute folders outside the workspace where breakpoints are allowed.
41
+ wait(ask_developer=...) shows the developer a note asking them to reproduce something in the GUI. That note is not an approval."""
42
+
43
+ _FILE_LINE = {
44
+ "type": "object",
45
+ "properties": {
46
+ "file": {"type": "string", "description": "Path relative to the workspace folder, e.g. src/app.py"},
47
+ "line": {"type": "integer", "minimum": 1},
48
+ "condition": {"type": "string", "description": "Optional condition, in the program's language"},
49
+ },
50
+ "required": ["file", "line"],
51
+ "additionalProperties": False,
52
+ }
53
+ _FOLDER = {"type": "string", "description": "Absolute workspace folder; needed only when several IDE windows are open"}
54
+ _IDE = {"type": "string", "enum": ["cursor", "vscode", "visualstudio"],
55
+ "description": "Which IDE's window to use, when windows of several IDEs are open"}
56
+
57
+
58
+ def _schema(properties: dict, required: tuple[str, ...] = ()) -> dict:
59
+ return {"type": "object", "properties": properties, "required": list(required), "additionalProperties": False}
60
+
61
+
62
+ TOOLS = {
63
+ "list_targets": ("List IDE windows running AutoDebug, with their launch configurations and build tasks.",
64
+ _schema({"workspace_folder": _FOLDER, "ide": _IDE})),
65
+ "start_session": (
66
+ "Resumes the program (call alone). Ask the developer to approve in the IDE, then build (optional), set "
67
+ "breakpoints, launch the configuration under the debugger and wait for the first stop. Returns the status, "
68
+ "location, local variables and every breakpoint (session or editor).",
69
+ _schema({
70
+ "launch_configuration": {"type": "string", "description": "A launch configuration name from list_targets"},
71
+ "breakpoints": {"type": "array", "items": _FILE_LINE, "maxItems": 50},
72
+ "goal": {"type": "string", "description": "One sentence: what you are trying to find out"},
73
+ "workspace_folder": _FOLDER,
74
+ "ide": _IDE,
75
+ "build_task": {"type": "string", "description": "A build task label from list_targets to run first"},
76
+ "max_steps": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 100},
77
+ "max_minutes": {"type": "integer", "minimum": 1, "maximum": 480, "default": 30},
78
+ "build_minutes": {"type": "integer", "minimum": 1, "maximum": 600, "default": 30},
79
+ "wait_seconds": {"type": "number", "minimum": 0, "maximum": 55, "default": 30},
80
+ "process_id": {"type": "integer", "minimum": 1, "description": "PID to attach to; replaces a ${command:…} processId or CodeLLDB pid"},
81
+ "source_folders": {"type": "array", "items": {"type": "string"}, "maxItems": 10,
82
+ "description": "Absolute folders outside the workspace where breakpoints are allowed"},
83
+ "function_breakpoints": {"type": "array", "items": {"type": "string"}, "maxItems": 50},
84
+ }, ("launch_configuration", "breakpoints", "goal"))),
85
+ "wait": ("Wait until the session stops, ends or is answered (approval, build). ask_developer shows an information message; it is not an approval.",
86
+ _schema({"timeout_seconds": {"type": "number", "minimum": 0, "maximum": 55, "default": 30},
87
+ "ask_developer": {"type": "string", "maxLength": 300}})),
88
+ "step": ("Resumes the program (call alone). Step over, into or out of the current line, 1-20 times. Returns the "
89
+ "new location and every variable whose value changed. wait_ms is how long to wait for the step (default 15000, max 60000).",
90
+ _schema({"kind": {"type": "string", "enum": ["over", "into", "out"], "default": "over"},
91
+ "count": {"type": "integer", "minimum": 1, "maximum": 20, "default": 1},
92
+ "wait_ms": {"type": "integer", "minimum": 0, "maximum": 60000, "default": 15000}})),
93
+ "continue": ("Resumes the program (call alone). Run until the next breakpoint or the end and return where it "
94
+ "stopped. With until, run past other breakpoints to that line.",
95
+ _schema({"timeout_seconds": {"type": "number", "minimum": 0, "maximum": 55, "default": 30},
96
+ "until": _FILE_LINE})),
97
+ "pause": ("Pause a running program where it is (for hangs and long loops).", _schema({})),
98
+ "inspect": ("Variables of a frame. Without names: every variable with a short value. With names: full values. depth 0-3 expands children. Names may be paths like mesh.verts[2].co.",
99
+ _schema({"names": {"type": "array", "items": {"type": "string"}, "maxItems": 50},
100
+ "frame_index": {"type": "integer", "minimum": 0, "default": 0,
101
+ "description": "0 is the current frame; see stack"},
102
+ "depth": {"type": "integer", "minimum": 0, "maximum": 3, "default": 0}})),
103
+ "stack": ("The call stack, current frame first. start_frame pages deeper frames. levels is at most 200.",
104
+ _schema({"levels": {"type": "integer", "minimum": 1, "maximum": 200, "default": 20},
105
+ "start_frame": {"type": "integer", "minimum": 0, "default": 0}})),
106
+ "threads": ("List threads. select switches the paused thread used by stack, inspect, step and continue.",
107
+ _schema({"select": {"type": "integer"}})),
108
+ "exceptions": ("List exception breakpoint filters. enable sets them by id.",
109
+ _schema({"enable": {"type": "array", "items": {"type": "string"}, "maxItems": 20}})),
110
+ "breakpoints": ("Add or remove breakpoints during the session; returns all of them and whether they are bound.",
111
+ _schema({"add": {"type": "array", "items": _FILE_LINE, "maxItems": 20},
112
+ "remove": {"type": "array", "items": _FILE_LINE, "maxItems": 20},
113
+ "add_functions": {"type": "array", "items": {"type": "string"}, "maxItems": 20},
114
+ "remove_functions": {"type": "array", "items": {"type": "string"}, "maxItems": 20}})),
115
+ "trace": ("Resumes the program (call alone). Run to a line repeatedly; each row has the caller and every named "
116
+ "variable as 'before → after' the line runs (one step per hit). Stops early at other breakpoints.",
117
+ _schema({"file": {"type": "string"}, "line": {"type": "integer", "minimum": 1},
118
+ "variables": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 20},
119
+ "hits": {"type": "integer", "minimum": 1, "maximum": 50, "default": 10},
120
+ "after": {"type": "boolean", "default": True,
121
+ "description": "Also step over the line to show the values it produces"},
122
+ "timeout_seconds": {"type": "number", "minimum": 1, "maximum": 55, "default": 20}},
123
+ ("file", "line", "variables"))),
124
+ "stop_session": ("End the debug session and remove its breakpoints.", _schema({})),
125
+ "write_report": (
126
+ "Write the root-cause report into <workspace>/autodebug-reports/. The session timeline is added automatically.",
127
+ _schema({
128
+ "title": {"type": "string"},
129
+ "problem": {"type": "string", "description": "What goes wrong, as the user sees it"},
130
+ "root_cause": {"type": "string", "description": "Why, citing what the debugger showed"},
131
+ "solution": {"type": "string", "description": "The fix, with code if short"},
132
+ "confidence": {"type": "string", "enum": ["confirmed", "likely", "unconfirmed"]},
133
+ "how_confirmed": {"type": "string", "description": "Required for confirmed"},
134
+ "verification": {"type": "string", "description": "How the fix was checked, if it was"},
135
+ "open_questions": {"type": "array", "items": {"type": "string"}},
136
+ "workspace_folder": _FOLDER,
137
+ }, ("title", "problem", "root_cause", "solution", "confidence"))),
138
+ }
139
+ _METHODS = {"continue": "continue_"}
140
+
141
+
142
+ def _valid(value: object, schema: dict) -> bool:
143
+ kind = schema.get("type")
144
+ if kind == "object":
145
+ if not isinstance(value, dict) or (schema.get("additionalProperties") is False
146
+ and value.keys() - schema.get("properties", {}).keys()):
147
+ return False
148
+ if any(key not in value for key in schema.get("required", ())):
149
+ return False
150
+ return all(_valid(v, schema["properties"][k]) for k, v in value.items() if k in schema.get("properties", {}))
151
+ if kind == "array":
152
+ return (isinstance(value, list) and len(value) >= schema.get("minItems", 0)
153
+ and len(value) <= schema.get("maxItems", 10**6) and all(_valid(v, schema["items"]) for v in value))
154
+ if kind == "string":
155
+ return isinstance(value, str) and len(value) <= 20000 and value in schema.get("enum", [value])
156
+ if kind == "boolean":
157
+ return isinstance(value, bool)
158
+ if kind == "integer":
159
+ return (isinstance(value, int) and not isinstance(value, bool)
160
+ and schema.get("minimum", -10**9) <= value <= schema.get("maximum", 10**9))
161
+ if kind == "number":
162
+ return (isinstance(value, (int, float)) and not isinstance(value, bool)
163
+ and schema.get("minimum", -10**9) <= value <= schema.get("maximum", 10**9))
164
+ return True
165
+
166
+
167
+ def _terminal_note() -> str:
168
+ from autodebug.cli import run_command
169
+
170
+ return (f"\nIf these tools disappear from your tool list, keep going in the terminal with the same session: "
171
+ f"`{run_command()} run <tool> key=value ...` (`run help` lists them).")
172
+
173
+
174
+ def call_tool(debugger: Debugger, name: str, arguments: dict) -> tuple[dict, bool]:
175
+ """Run one tool on `debugger`: (result, is_error). Arguments must already be valid for TOOLS[name]."""
176
+ try:
177
+ return getattr(debugger, _METHODS.get(name, name))(**arguments), False
178
+ except DebugError as exc:
179
+ return {"error": exc.code, "message": exc.message}, True
180
+ except (OSError, ValueError) as exc:
181
+ return {"error": "IDE_ERROR", "message": str(exc)}, True
182
+ except Exception: # noqa: BLE001 — never crash on one tool
183
+ _LOG.exception("tool %s failed", name)
184
+ return {"error": "IDE_ERROR", "message": "internal error; see the server log"}, True
185
+
186
+
187
+ Runner = Callable[[str, dict], "tuple[dict, bool]"]
188
+
189
+
190
+ class Server:
191
+ def __init__(self, debugger: Debugger | None = None, stdin: BinaryIO | None = None,
192
+ stdout: BinaryIO | None = None, runner: Runner | None = None) -> None:
193
+ """Tools run on `runner` (the shared AutoDebug service) or, without one, on `debugger` in this process."""
194
+ self.debugger = None if runner else (debugger or Debugger())
195
+ self._tool_lock = threading.Lock() # one debugger action at a time
196
+ self.runner = runner or self._local
197
+ self.stdin = stdin or sys.stdin.buffer
198
+ self.stdout = stdout or sys.stdout.buffer
199
+ self._write_lock = threading.Lock()
200
+ self._threads: list[threading.Thread] = []
201
+
202
+ def _local(self, name: str, arguments: dict) -> tuple[dict, bool]:
203
+ with self._tool_lock:
204
+ return call_tool(self.debugger, name, arguments)
205
+
206
+ def _send(self, message: dict) -> None:
207
+ data = json.dumps(message, ensure_ascii=False).encode("utf-8") + b"\n"
208
+ with self._write_lock:
209
+ try:
210
+ self.stdout.write(data)
211
+ self.stdout.flush()
212
+ except (OSError, ValueError):
213
+ pass
214
+
215
+ def _reply(self, request_id, result=None, error=None) -> None:
216
+ message = {"jsonrpc": "2.0", "id": request_id}
217
+ message.update({"error": error} if error else {"result": result})
218
+ self._send(message)
219
+
220
+ def serve(self) -> None:
221
+ for line in self.stdin:
222
+ if not line.strip():
223
+ continue
224
+ try:
225
+ message = json.loads(line)
226
+ except ValueError:
227
+ self._reply(None, error={"code": -32700, "message": "parse error"})
228
+ continue
229
+ if isinstance(message, dict):
230
+ self.handle(message)
231
+ for thread in self._threads:
232
+ thread.join(timeout=5)
233
+ if self.debugger is not None:
234
+ self.debugger.close()
235
+
236
+ def handle(self, message: dict) -> None:
237
+ method, request_id, params = message.get("method"), message.get("id"), message.get("params") or {}
238
+ if request_id is None:
239
+ return # notifications (initialized, cancelled) need no answer
240
+ if method == "initialize":
241
+ asked = params.get("protocolVersion")
242
+ self._reply(request_id, {
243
+ "protocolVersion": asked if asked in PROTOCOL_VERSIONS else PROTOCOL_VERSIONS[0],
244
+ "capabilities": {"tools": {"listChanged": False}},
245
+ "serverInfo": {"name": "autodebug", "version": __version__},
246
+ "instructions": INSTRUCTIONS + _terminal_note(),
247
+ })
248
+ elif method == "ping":
249
+ self._reply(request_id, {})
250
+ elif method == "tools/list":
251
+ self._reply(request_id, {"tools": [{"name": n, "description": d, "inputSchema": s}
252
+ for n, (d, s) in TOOLS.items()]})
253
+ elif method == "tools/call":
254
+ thread = threading.Thread(target=self._call_tool, args=(request_id, params), daemon=True)
255
+ self._threads = [t for t in self._threads if t.is_alive()] + [thread]
256
+ thread.start()
257
+ else:
258
+ self._reply(request_id, error={"code": -32601, "message": f"unknown method {method}"})
259
+
260
+ def _call_tool(self, request_id, params: dict) -> None:
261
+ name, arguments = params.get("name"), params.get("arguments") or {}
262
+ if name not in TOOLS:
263
+ self._reply(request_id, error={"code": -32602, "message": f"unknown tool {name}"})
264
+ return
265
+ if not _valid(arguments, TOOLS[name][1]):
266
+ self._reply(request_id, error={"code": -32602, "message": f"invalid arguments for {name}"})
267
+ return
268
+ result, is_error = self.runner(name, arguments)
269
+ self._reply(request_id, {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=1)}],
270
+ "structuredContent": result, "isError": is_error})
autodebug/redact.py ADDED
@@ -0,0 +1,38 @@
1
+ """Hide secret-looking values before an agent or a report sees them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ REDACTED = "***REDACTED***"
8
+ MAX_VALUE = 2000
9
+
10
+ _SECRET_NAME = re.compile(
11
+ r"pass(word|wd|phrase)?|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|credential|auth(?!or)|cookie|session[_-]?id",
12
+ re.IGNORECASE,
13
+ )
14
+ _SECRET_VALUE = re.compile(
15
+ r"AKIA[0-9A-Z]{16}" # AWS access key ID
16
+ r"|(?:sk|rk|pk)-[A-Za-z0-9_-]{20,}" # API keys with sk-/rk-/pk- prefixes
17
+ r"|gh[pousr]_[A-Za-z0-9]{36,}" # GitHub tokens
18
+ r"|xox[abpr]-[A-Za-z0-9-]{10,}" # Slack tokens
19
+ r"|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}" # JWT
20
+ r"|-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)"
21
+ )
22
+
23
+
24
+ def is_secret_name(name: str) -> bool:
25
+ return bool(_SECRET_NAME.search(name))
26
+
27
+
28
+ def clean_text(text: object, limit: int = MAX_VALUE) -> str:
29
+ """Mask secret-shaped substrings and cap the length."""
30
+ value = _SECRET_VALUE.sub(REDACTED, str(text))
31
+ return value if len(value) <= limit else value[:limit] + f"… ({len(value) - limit} more characters)"
32
+
33
+
34
+ def clean_value(name: str, value: object, limit: int = MAX_VALUE) -> str:
35
+ """A variable's value as the agent may see it."""
36
+ if is_secret_name(name):
37
+ return REDACTED
38
+ return clean_text(value, limit)
autodebug/report.py ADDED
@@ -0,0 +1,107 @@
1
+ """The root-cause report written into the project (docs/design.md, "Report")."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from autodebug.connection import IDE_NAMES
6
+
7
+ import os
8
+ import re
9
+ import time
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ from autodebug.redact import clean_text
14
+
15
+ FOLDER = "autodebug-reports"
16
+ _CONFIDENCE = {"confirmed": "Confirmed", "likely": "Likely", "unconfirmed": "Unconfirmed"}
17
+
18
+
19
+ @dataclass
20
+ class Report:
21
+ title: str
22
+ problem: str
23
+ root_cause: str
24
+ solution: str
25
+ confidence: str
26
+ how_confirmed: str | None = None
27
+ verification: str | None = None
28
+ open_questions: list[str] = field(default_factory=list)
29
+ plan: dict = field(default_factory=dict)
30
+ timeline: list = field(default_factory=list)
31
+
32
+
33
+ def _cell(text: object) -> str:
34
+ return " ".join(clean_text(text, 400).split()).replace("|", "\\|") or "—"
35
+
36
+
37
+ def _prose(text: object) -> str:
38
+ return clean_text(text, 8000).strip()
39
+
40
+
41
+ def render(report: Report, now: float | None = None) -> str:
42
+ stamp = time.strftime("%Y-%m-%d %H:%M", time.localtime(now or time.time()))
43
+ plan = report.plan
44
+ ide = IDE_NAMES.get(plan.get("ide"), "IDE")
45
+ lines = [
46
+ f"# {_prose(report.title)}",
47
+ "",
48
+ "| | |",
49
+ "|---|---|",
50
+ f"| Date | {stamp} |",
51
+ f"| Root cause | **{_CONFIDENCE[report.confidence]}** |",
52
+ f"| Debugged with | {ide}, launch configuration `{_cell(plan.get('launch'))}` |" if plan else "| Debugged with | — |",
53
+ "",
54
+ "## Problem",
55
+ "",
56
+ _prose(report.problem),
57
+ "",
58
+ "## How it was debugged",
59
+ "",
60
+ ]
61
+ if plan:
62
+ lines += [f"- Goal: {_cell(plan.get('goal'))}",
63
+ f"- Build task: `{_cell(plan['task'])}`" if plan.get("task") else "- Build task: none",
64
+ "- Breakpoints: " + (", ".join(f"`{_cell(b)}`" for b in plan.get("breakpoints", [])) or "none")]
65
+ else:
66
+ lines.append("- No debug session was recorded.")
67
+ lines += ["", "### Timeline", ""]
68
+ if report.timeline:
69
+ lines += ["| # | What | Where | Details |", "|---|---|---|---|"]
70
+ lines += [f"| {n} | {_cell(e.kind)} | {_cell(e.where)} | {_cell(e.detail)} |"
71
+ for n, e in enumerate(report.timeline, 1)]
72
+ else:
73
+ lines.append("_Nothing was recorded._")
74
+ lines += ["", "## Root cause", "", _prose(report.root_cause)]
75
+ if report.how_confirmed:
76
+ lines += ["", f"**How it was confirmed:** {_prose(report.how_confirmed)}"]
77
+ lines += ["", "## Solution", "", _prose(report.solution), "", "## Verification", ""]
78
+ lines += ([_prose(report.verification), "", "_Reported by the AI agent._"] if report.verification
79
+ else ["_Not verified yet._"])
80
+ if report.open_questions:
81
+ lines += ["", "## Open questions", ""] + [f"- {_cell(q)}" for q in report.open_questions]
82
+ lines += ["", "---", "", "_Written by AutoDebug. Secret-looking values are shown as `***REDACTED***`._", ""]
83
+ return "\n".join(lines)
84
+
85
+
86
+ def write(workspace_folder: str, report: Report) -> Path:
87
+ """Write the report under `<workspace>/autodebug-reports/`, never overwriting."""
88
+ workspace = Path(workspace_folder)
89
+ if not workspace.is_dir():
90
+ raise ValueError(f"workspace folder not found: {workspace}")
91
+ folder = workspace / FOLDER
92
+ if folder.is_symlink():
93
+ raise ValueError(f"{folder} is a link; refusing to write there")
94
+ folder.mkdir(exist_ok=True)
95
+ slug = re.sub(r"[^a-z0-9]+", "-", report.title.lower()).strip("-")[:60] or "report"
96
+ base = f"{time.strftime('%Y%m%d-%H%M')}-{slug}"
97
+ text = render(report).encode("utf-8")
98
+ for number in range(1, 100):
99
+ path = folder / (f"{base}.md" if number == 1 else f"{base}-{number}.md")
100
+ try:
101
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o644)
102
+ except FileExistsError:
103
+ continue
104
+ with os.fdopen(fd, "wb") as handle:
105
+ handle.write(text)
106
+ return path
107
+ raise ValueError("too many reports with this title in one minute")