Spaces:
Sleeping
Sleeping
| """Custom tools exposed to the deep agent. | |
| Tools are organised in **privilege tiers** so we can hand the right subset to | |
| the right sub-agent (least-privilege principle): | |
| - `read_only_tools` — never mutate the repo or GitHub | |
| - `edit_tools` — can run shell commands that mutate files locally (lint --fix, format) | |
| - `migrate_tools` — codemod / structural rewrite (ast-grep). Powerful but scoped. | |
| - `perf_tools` — read-only profiling (py-spy, cProfile, benchmark runs) | |
| - `i18n_tools` — extract/sync translation strings, check parity | |
| - `finalize_tools` — can commit/push and open/update PRs (LEAD AGENT ONLY) | |
| Heavy file I/O is still handled by the Deep Agents backend (LocalShellBackend or | |
| remote sandbox). We add only the GitHub-specific verbs + safety nets + ergonomic | |
| typed wrappers around shell commands. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import re | |
| import shutil | |
| import subprocess | |
| import sys | |
| import tempfile | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Optional, TYPE_CHECKING | |
| from langchain_core.tools import tool | |
| from .github_client import GitHubOps, IssueRef | |
| if TYPE_CHECKING: | |
| from .backends import BackendHandle | |
| # ================================================================= | |
| # TOOLBOX FACTORY | |
| # ================================================================= | |
| class Toolbox: | |
| """Bundle of tools split by privilege. Pass the right subset to each sub-agent.""" | |
| read_only: list | |
| edit: list | |
| migrate: list | |
| perf: list | |
| i18n: list | |
| finalize: list | |
| def all(self) -> list: | |
| return self.read_only + self.edit + self.migrate + self.perf + self.i18n + self.finalize | |
| def for_role(self, role: str) -> list: | |
| """Return the right subset for a sub-agent role.""" | |
| if role == "lead": | |
| return self.all | |
| if role in {"planner", "reviewer", "security", "docs-reader"}: | |
| return self.read_only | |
| if role in {"coder", "tester", "debugger", "deps-manager", "docs-writer"}: | |
| return self.read_only + self.edit | |
| if role == "migrator": | |
| # read + edit (to apply formatters after a rewrite) + migrate tools | |
| return self.read_only + self.edit + self.migrate | |
| if role == "perf-analyst": | |
| # read-only profiling + ability to run tests/benchmarks | |
| return self.read_only + self.perf + [t for t in self.edit if t.name == "run_tests"] | |
| if role == "i18n": | |
| return self.read_only + self.edit + self.i18n | |
| raise ValueError(f"Unknown role: {role}") | |
| def make_toolbox( | |
| repo_path: Path, | |
| repo_full_name: str, | |
| issue_ref: Optional[IssueRef] = None, | |
| backend_handle: Optional["BackendHandle"] = None, | |
| base_branch: Optional[str] = None, | |
| existing_branch: Optional[str] = None, | |
| ) -> Toolbox: | |
| """Build the full tool registry, segmented by privilege.""" | |
| gh = GitHubOps() | |
| # ---------------------------------------------------------- READ ONLY | |
| def fetch_issue(issue_number: int) -> str: | |
| """Fetch a GitHub issue (title, body, labels, comments) as JSON. | |
| Use when you need full context for an issue you don't already have. | |
| """ | |
| ref = IssueRef(*repo_full_name.split("/"), number=issue_number) | |
| return json.dumps(gh.fetch_issue_context(ref), indent=2, default=str) | |
| def list_project_files(glob: str = "**/*") -> str: | |
| """List repo files matching a glob (default: all). For orientation. Returns up to 500 paths.""" | |
| paths = sorted(str(p.relative_to(repo_path)) for p in repo_path.glob(glob) if p.is_file()) | |
| return "\n".join(paths[:500]) | |
| def search_code(pattern: str, glob: str = "", max_results: int = 80) -> str: | |
| """Ripgrep-style search across the repo. Returns `path:line: match` lines. | |
| Args: | |
| pattern: regex (PCRE2 if rg, else POSIX). Quote special chars. | |
| glob: optional file glob, e.g. "*.py" or "src/**/*.ts". | |
| max_results: cap on returned lines. | |
| """ | |
| rg = shutil.which("rg") | |
| if rg: | |
| cmd = [rg, "-n", "--no-heading", "--color", "never", "-S", pattern] | |
| if glob: | |
| cmd += ["-g", glob] | |
| else: | |
| cmd = ["grep", "-rnIE", "--color=never", pattern, "."] | |
| if glob: | |
| cmd = ["bash", "-lc", f"grep -rnIE --include='{glob}' --color=never {pattern!r} ."] | |
| try: | |
| out = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, timeout=60).stdout | |
| except Exception as e: # pragma: no cover | |
| return f"search failed: {e}" | |
| lines = out.splitlines()[:max_results] | |
| return "\n".join(lines) or "(no matches)" | |
| def read_file_range(path: str, start: int = 1, end: int = 200) -> str: | |
| """Read a 1-indexed line range from a file (capped at 800 lines).""" | |
| end = min(end, start + 800) | |
| p = repo_path / path | |
| if not p.is_file(): | |
| return f"not a file: {path}" | |
| lines = p.read_text(errors="replace").splitlines() | |
| slice_ = lines[start - 1 : end] | |
| return "\n".join(f"{i + start}: {ln}" for i, ln in enumerate(slice_)) | |
| def git_log(path: str = "", n: int = 10) -> str: | |
| """Show the last `n` commits (oneline). Optionally scoped to a path.""" | |
| cmd = ["git", "log", "--oneline", f"-n{n}"] | |
| if path: | |
| cmd += ["--", path] | |
| return subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True).stdout or "(no commits)" | |
| def git_blame(path: str, line: int) -> str: | |
| """Show `git blame -L line,line path` for line-level archaeology.""" | |
| out = subprocess.run( | |
| ["git", "blame", "-L", f"{line},{line}", path], | |
| cwd=repo_path, capture_output=True, text=True, | |
| ) | |
| return out.stdout or out.stderr or "(no output)" | |
| def summarize_diff(against: str = "HEAD") -> str: | |
| """Return the current diff vs `against` (default HEAD, truncated to 12k chars).""" | |
| if backend_handle and backend_handle.is_remote: | |
| backend_handle.sync_to_host() | |
| try: | |
| diff = subprocess.check_output( | |
| ["git", "diff", against], cwd=repo_path, text=True, stderr=subprocess.DEVNULL | |
| ) | |
| except subprocess.CalledProcessError as e: | |
| return f"git diff failed: {e}" | |
| return diff[:12000] or "(no changes)" | |
| def analyze_test_failure(test_output: str) -> str: | |
| """Extract a structured summary from raw pytest/jest/go test output. | |
| Returns JSON: {"runner": ..., "failed": [{"name","file","line","error","trace"}], "passed": N, "failed_count": N} | |
| """ | |
| text = test_output or "" | |
| result = {"runner": _detect_runner(text), "failed": [], "passed": 0, "failed_count": 0} | |
| # pytest | |
| for m in re.finditer( | |
| r"_+ (?P<name>\S+) _+\n(?P<body>.*?)(?=\n_+ \S+ _+\n|\n=+ short test summary|$)", | |
| text, re.S, | |
| ): | |
| body = m.group("body") | |
| file_line = re.search(r"^(?P<file>[\w./-]+):(?P<line>\d+):", body, re.M) | |
| err = re.search(r"^E\s+(?P<err>.+)", body, re.M) | |
| result["failed"].append({ | |
| "name": m.group("name"), | |
| "file": file_line.group("file") if file_line else None, | |
| "line": int(file_line.group("line")) if file_line else None, | |
| "error": err.group("err") if err else None, | |
| "trace": body[-1200:], | |
| }) | |
| m = re.search(r"=+ (?P<f>\d+) failed,? ?(?P<p>\d+)? passed", text) | |
| if m: | |
| result["failed_count"] = int(m.group("f")) | |
| result["passed"] = int(m.group("p") or 0) | |
| return json.dumps(result, indent=2) | |
| # ---------------------------------------------------------- EDIT | |
| def run_tests(command: str = "") -> str: | |
| """Run the project's test suite. Returns stdout+stderr (truncated). | |
| With no arg, auto-detects pytest / npm test / go test / cargo test / make test. | |
| """ | |
| cmd = command or _autodetect_test_cmd(repo_path) | |
| if not cmd: | |
| return "No test runner detected. Skipping." | |
| if backend_handle and backend_handle.is_remote: | |
| res = backend_handle.backend.execute(f"cd /workspace/repo && {cmd}") | |
| out = getattr(res, "output", "") or "" | |
| return f"$ {cmd}\n[exit {getattr(res, 'exit_code', '?')}]\n{out[-8000:]}" | |
| proc = subprocess.run( | |
| cmd, shell=True, cwd=repo_path, capture_output=True, text=True, timeout=900 | |
| ) | |
| return f"$ {cmd}\n[exit {proc.returncode}]\n{(proc.stdout or '') + (proc.stderr or '')}"[-8000:] | |
| def lint_check(fix: bool = False) -> str: | |
| """Run the project's linter. Auto-detects ruff / eslint / golangci-lint / clippy. | |
| Set fix=True to apply autofixes. | |
| """ | |
| cmds = _autodetect_lint_cmd(repo_path, fix=fix) | |
| if not cmds: | |
| return "No linter detected." | |
| outputs = [] | |
| for cmd in cmds: | |
| p = subprocess.run(cmd, shell=True, cwd=repo_path, capture_output=True, text=True, timeout=300) | |
| outputs.append(f"$ {cmd}\n[exit {p.returncode}]\n{(p.stdout or '') + (p.stderr or '')}"[-3000:]) | |
| return "\n\n".join(outputs) | |
| def format_code() -> str: | |
| """Run the project's formatter (ruff format / prettier / gofmt / cargo fmt).""" | |
| cmd = _autodetect_fmt_cmd(repo_path) | |
| if not cmd: | |
| return "No formatter detected." | |
| p = subprocess.run(cmd, shell=True, cwd=repo_path, capture_output=True, text=True, timeout=300) | |
| return f"$ {cmd}\n[exit {p.returncode}]\n{(p.stdout or '') + (p.stderr or '')}"[-4000:] | |
| def dependency_audit() -> str: | |
| """Audit dependencies for known CVEs (pip-audit / npm audit / cargo audit).""" | |
| cmd = _autodetect_audit_cmd(repo_path) | |
| if not cmd: | |
| return "No supported dependency manager found." | |
| p = subprocess.run(cmd, shell=True, cwd=repo_path, capture_output=True, text=True, timeout=300) | |
| return f"$ {cmd}\n[exit {p.returncode}]\n{(p.stdout or '') + (p.stderr or '')}"[-6000:] | |
| def scan_secrets() -> str: | |
| """Run `gitleaks detect` on the working tree. Returns its report (or 'no leaks').""" | |
| if not shutil.which("gitleaks"): | |
| return "gitleaks not installed; SKIP (install with `brew install gitleaks` or apt)." | |
| p = subprocess.run( | |
| ["gitleaks", "detect", "--no-banner", "--redact", "--source", str(repo_path)], | |
| capture_output=True, text=True, timeout=120, | |
| ) | |
| return f"[exit {p.returncode}]\n{(p.stdout or '') + (p.stderr or '')}"[-6000:] or "no leaks" | |
| # ========================================================== MIGRATE | |
| def ast_grep_search(pattern: str, lang: str = "", path: str = "") -> str: | |
| """Structural code search via ast-grep. Returns matches with file:line. | |
| Pattern uses ast-grep's pattern syntax (e.g. `requests.get($URL)` to | |
| match any call). Falls back to a clear message if ast-grep is missing. | |
| Args: | |
| pattern: ast-grep pattern. Use `$VAR` for placeholders, `$$$ARGS` for variadic. | |
| lang: source language hint (python, javascript, typescript, go, rust, ...). | |
| path: scope to a subpath. Defaults to whole repo. | |
| """ | |
| if not shutil.which("ast-grep"): | |
| return "ast-grep not installed. Install: `cargo install ast-grep` or `npm i -g @ast-grep/cli`." | |
| cmd = ["ast-grep", "run", "--pattern", pattern] | |
| if lang: | |
| cmd += ["--lang", lang] | |
| if path: | |
| cmd += [path] | |
| try: | |
| out = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, timeout=120) | |
| except Exception as e: # pragma: no cover | |
| return f"ast-grep failed: {e}" | |
| body = (out.stdout or "") + (out.stderr or "") | |
| return body[-8000:] or "(no matches)" | |
| def ast_grep_rewrite( | |
| pattern: str, | |
| rewrite: str, | |
| lang: str = "", | |
| path: str = "", | |
| apply: bool = False, | |
| ) -> str: | |
| """Structural rewrite. By default returns a dry-run diff; set apply=True to write. | |
| Use for bulk transformations the LLM is *guaranteed* to get right | |
| (rename function, swap call sites, modernise API). For each change you | |
| can't express structurally, fall back to per-file edits. | |
| Args: | |
| pattern: source pattern. e.g. `foo($X)`. | |
| rewrite: replacement template referencing the same metavars. e.g. `bar($X, default=None)`. | |
| lang: source language. | |
| path: subpath scope. | |
| apply: write changes when True; otherwise show diff only. | |
| """ | |
| if not shutil.which("ast-grep"): | |
| return "ast-grep not installed. Cannot perform structural rewrite." | |
| cmd = ["ast-grep", "run", "--pattern", pattern, "--rewrite", rewrite] | |
| if lang: | |
| cmd += ["--lang", lang] | |
| if path: | |
| cmd += [path] | |
| if apply: | |
| cmd += ["--update-all"] | |
| try: | |
| out = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, timeout=300) | |
| except Exception as e: # pragma: no cover | |
| return f"ast-grep failed: {e}" | |
| body = (out.stdout or "") + (out.stderr or "") | |
| verdict = "APPLIED" if apply else "DRY-RUN (rerun with apply=True to write)" | |
| return f"[{verdict}]\n{body[-8000:]}" | |
| def codemod_python(script: str, glob: str = "**/*.py") -> str: | |
| """Apply a Python codemod on every file matching `glob`. | |
| `script` must define a `transform(source: str, path: str) -> str` function. | |
| Useful when ast-grep patterns aren't expressive enough (e.g. need to | |
| cross-reference imports, manipulate decorators, infer types). | |
| The script can import `libcst` (install separately) for concrete-syntax | |
| manipulation, or use plain string operations / ast / re for simpler cases. | |
| SECURITY: the (LLM-authored) script is executed in an **isolated child | |
| process** with a scrubbed environment and a hard timeout — never in the | |
| long-lived agent process. The migrator sub-agent is the only one allowed | |
| to use this tool. | |
| """ | |
| return _run_codemod_subprocess(repo_path, script, glob) | |
| # ========================================================== PERF | |
| def benchmark_run(command: str, runs: int = 5) -> str: | |
| """Run a shell command multiple times and report min/median/max wall time. | |
| Use to validate a perf hypothesis (before/after numbers). Caps at 10 runs. | |
| """ | |
| import statistics as _stats | |
| import time as _time | |
| runs = max(1, min(runs, 10)) | |
| times: list[float] = [] | |
| last_out = "" | |
| for _ in range(runs): | |
| t0 = _time.perf_counter() | |
| p = subprocess.run( | |
| command, shell=True, cwd=repo_path, capture_output=True, text=True, timeout=900 | |
| ) | |
| times.append(_time.perf_counter() - t0) | |
| last_out = (p.stdout or "") + (p.stderr or "") | |
| if p.returncode != 0: | |
| return f"$ {command}\n[exit {p.returncode}]\n{last_out[-2000:]}" | |
| return ( | |
| f"$ {command} (x{runs})\n" | |
| f" min = {min(times):.3f}s\n" | |
| f" median = {_stats.median(times):.3f}s\n" | |
| f" max = {max(times):.3f}s\n" | |
| f" stdev = {(_stats.pstdev(times) if len(times) > 1 else 0):.3f}s\n" | |
| f"---\n{last_out[-1500:]}" | |
| ) | |
| def profile_python(script_or_module: str, duration: int = 10, native: bool = False) -> str: | |
| """Sample-profile a Python target with py-spy, then return the top hotspots. | |
| Args: | |
| script_or_module: command to launch (e.g. `python -m mypkg.main args`). | |
| duration: how many seconds to sample (capped at 60). | |
| native: include C-extension frames (numpy, pandas). | |
| """ | |
| if not shutil.which("py-spy"): | |
| return "py-spy not installed. `pip install py-spy` to enable Python profiling." | |
| duration = max(1, min(duration, 60)) | |
| out_path = repo_path / ".gh-deepagent-profile.svg" | |
| cmd = [ | |
| "py-spy", "record", "-o", str(out_path), | |
| "--duration", str(duration), "--format", "flamegraph", | |
| ] | |
| if native: | |
| cmd.append("--native") | |
| cmd += ["--"] + script_or_module.split() | |
| try: | |
| proc = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, timeout=duration + 30) | |
| except subprocess.TimeoutExpired: | |
| return f"py-spy timed out after {duration + 30}s." | |
| # Also grab a 1-shot `dump` for a quick textual hotspot list. | |
| top_text = "" | |
| try: | |
| top = subprocess.run( | |
| ["py-spy", "dump", "--pid", "$(pgrep -nf '" + script_or_module.split()[-1] + "')"], | |
| shell=False, capture_output=True, text=True, timeout=10, | |
| ) | |
| top_text = top.stdout | |
| except Exception: | |
| pass | |
| return ( | |
| f"profile written to {out_path} (open in browser)\n" | |
| f"[exit {proc.returncode}]\n{(proc.stdout or '') + (proc.stderr or '')}\n" | |
| f"--- live dump ---\n{top_text[:2000]}" | |
| ) | |
| def cprofile_run(command: str, top: int = 25) -> str: | |
| """Run a Python command under cProfile and return the top-N hottest funcs. | |
| `command` is run as `python -c "..."` if it starts with `-`, otherwise | |
| executed verbatim. We capture cProfile stats and dump them as text. | |
| """ | |
| if not command.strip(): | |
| return "command required." | |
| wrapper = ( | |
| "import cProfile, pstats, io, runpy, sys; " | |
| "pr = cProfile.Profile(); pr.enable(); " | |
| f"exec(compile(open({command.split()[-1]!r}).read(),{command.split()[-1]!r},'exec')) " | |
| "if False else __import__('subprocess').run(" | |
| f"{command.split()!r}, check=False); " | |
| "pr.disable(); s=io.StringIO(); " | |
| f"pstats.Stats(pr, stream=s).sort_stats('cumulative').print_stats({top}); " | |
| "print(s.getvalue())" | |
| ) | |
| # Simpler: invoke python -m cProfile if the user passed a python invocation. | |
| if command.startswith("python "): | |
| real = ["python", "-m", "cProfile", "-s", "cumulative"] + command.split()[1:] | |
| p = subprocess.run(real, cwd=repo_path, capture_output=True, text=True, timeout=600) | |
| return f"$ {' '.join(real)}\n[exit {p.returncode}]\n{(p.stdout or '')[-8000:]}" | |
| # Otherwise, run as-is and just time it. | |
| p = subprocess.run(command, shell=True, cwd=repo_path, capture_output=True, text=True, timeout=600) | |
| return f"$ {command}\n[exit {p.returncode}]\n{(p.stdout or '')[-8000:]}" | |
| def perf_compare(label_a: str, command_a: str, label_b: str, command_b: str, runs: int = 5) -> str: | |
| """Run two commands `runs` times each and report a side-by-side comparison.""" | |
| a = benchmark_run.invoke({"command": command_a, "runs": runs}) | |
| b = benchmark_run.invoke({"command": command_b, "runs": runs}) | |
| return f"## {label_a}\n{a}\n\n## {label_b}\n{b}\n" | |
| # ========================================================== I18N | |
| def i18n_list_locales() -> str: | |
| """Find translation catalogues in the repo and return their paths + key counts. | |
| Heuristics: `*.po`, `*.pot`, `locales/<lang>/*.json`, `messages/<lang>.json`, | |
| `i18n/<lang>.yml`, `translations/**`. | |
| """ | |
| patterns = [ | |
| "**/*.po", "**/*.pot", | |
| "**/locales/**/*.json", "**/locales/**/*.yml", "**/locales/**/*.yaml", | |
| "**/messages/**/*.json", "**/i18n/**/*.json", "**/i18n/**/*.yml", | |
| "**/translations/**/*.json", | |
| ] | |
| found: dict[str, int] = {} | |
| for pat in patterns: | |
| for p in repo_path.glob(pat): | |
| if not p.is_file() or ".git" in p.parts: | |
| continue | |
| try: | |
| text = p.read_text(encoding="utf-8", errors="replace") | |
| except Exception: | |
| continue | |
| # Crude key count | |
| if p.suffix in {".po", ".pot"}: | |
| keys = text.count("\nmsgid ") | |
| elif p.suffix == ".json": | |
| keys = text.count('":') | |
| else: | |
| keys = text.count(":") | |
| found[str(p.relative_to(repo_path))] = keys | |
| if not found: | |
| return "(no translation catalogues found)" | |
| return "\n".join(f"{path}\t{keys} keys" for path, keys in sorted(found.items())) | |
| def i18n_extract(catalog: str = "") -> str: | |
| """Extract translatable strings from the source into the catalog. | |
| Auto-detects framework: | |
| - Python: pybabel (django/flask) or xgettext if available | |
| - JS/TS: i18next-parser, formatjs extract, or grep `t("...")` | |
| - Go: goi18n | |
| Returns the command run and its output. | |
| """ | |
| # Python | |
| if (repo_path / "babel.cfg").exists() and shutil.which("pybabel"): | |
| cat = catalog or "messages.pot" | |
| cmd = f"pybabel extract -F babel.cfg -o {cat} ." | |
| elif shutil.which("xgettext") and any(repo_path.glob("**/*.py")): | |
| cat = catalog or "messages.pot" | |
| files = " ".join(str(p) for p in repo_path.rglob("*.py")) | |
| cmd = f"xgettext --language=Python --keyword=_ --keyword=gettext --output={cat} {files}" | |
| # JS/TS via i18next-parser | |
| elif (repo_path / "i18next-parser.config.js").exists() and shutil.which("npx"): | |
| cmd = "npx --no-install i18next-parser" | |
| # Fallback: grep | |
| else: | |
| return ( | |
| "no i18n extractor detected.\n" | |
| "Install one of: pybabel, xgettext, i18next-parser.\n" | |
| "As a fallback, use `search_code` with patterns like `t\\(\"` or `gettext\\(`." | |
| ) | |
| p = subprocess.run(cmd, shell=True, cwd=repo_path, capture_output=True, text=True, timeout=300) | |
| return f"$ {cmd}\n[exit {p.returncode}]\n{((p.stdout or '') + (p.stderr or ''))[-4000:]}" | |
| def i18n_check_parity(reference: str = "") -> str: | |
| """Check that all locale files have the same keys as the reference locale. | |
| Args: | |
| reference: path to the reference locale (e.g. `locales/en.json`). | |
| If empty, picks the largest `*.json` under `locales/` or `i18n/`. | |
| Returns a Markdown report of missing/extra keys per locale. | |
| """ | |
| import json as _json | |
| # Find candidate files | |
| candidates = sorted( | |
| list(repo_path.glob("**/locales/*.json")) | |
| + list(repo_path.glob("**/i18n/*.json")) | |
| + list(repo_path.glob("**/messages/*.json")) | |
| ) | |
| if not candidates: | |
| return "(no JSON locale files found)" | |
| if reference: | |
| ref_path = repo_path / reference | |
| else: | |
| ref_path = max(candidates, key=lambda p: p.stat().st_size) | |
| def flatten(d, prefix=""): | |
| out = set() | |
| for k, v in d.items(): | |
| key = f"{prefix}.{k}" if prefix else k | |
| if isinstance(v, dict): | |
| out |= flatten(v, key) | |
| else: | |
| out.add(key) | |
| return out | |
| try: | |
| ref_keys = flatten(_json.loads(ref_path.read_text(encoding="utf-8"))) | |
| except Exception as e: | |
| return f"failed to read reference {ref_path}: {e}" | |
| report = [f"reference: {ref_path.relative_to(repo_path)} ({len(ref_keys)} keys)"] | |
| for c in candidates: | |
| if c == ref_path: | |
| continue | |
| try: | |
| keys = flatten(_json.loads(c.read_text(encoding="utf-8"))) | |
| except Exception as e: | |
| report.append(f"\n### {c.relative_to(repo_path)} — UNREADABLE ({e})") | |
| continue | |
| missing = sorted(ref_keys - keys) | |
| extra = sorted(keys - ref_keys) | |
| status = "OK" if not missing and not extra else "DRIFT" | |
| report.append(f"\n### {c.relative_to(repo_path)} — {status}") | |
| if missing: | |
| report.append(f" missing ({len(missing)}): " + ", ".join(missing[:20])) | |
| if extra: | |
| report.append(f" extra ({len(extra)}): " + ", ".join(extra[:20])) | |
| return "\n".join(report) | |
| # ---------------------------------------------------------- FINALIZE | |
| def finalize_patch(branch_name: str, commit_message: str, pr_title: str, pr_body: str) -> str: | |
| """Commit, push, and open/update a PR. **LEAD AGENT ONLY.** Call once at the end. | |
| If an existing branch is configured (iterate-on-PR mode), pushes to it | |
| and posts an update comment instead of creating a new PR. | |
| """ | |
| if backend_handle and backend_handle.is_remote: | |
| backend_handle.sync_to_host() | |
| # Safety net: refuse to push to default-protected branches. | |
| if branch_name in {"main", "master", base_branch or ""}: | |
| return f"REFUSED: branch_name {branch_name!r} would target the protected base." | |
| target_branch = existing_branch or branch_name | |
| if not _branch_exists(repo_path, target_branch): | |
| gh.create_branch(repo_path, target_branch) | |
| else: | |
| subprocess.run(["git", "checkout", target_branch], cwd=repo_path, check=False) | |
| committed = gh.commit_all(repo_path, commit_message) | |
| if not committed: | |
| return "No changes to commit. Aborting PR update." | |
| gh.push(repo_path, target_branch, full_name=repo_full_name) | |
| if existing_branch: | |
| pr = _find_open_pr_for_branch(gh, repo_full_name, existing_branch) | |
| if pr: | |
| pr.create_issue_comment(f"🤖 gh-deepagent pushed an update:\n\n{pr_body}") | |
| return f"PR updated: {pr.html_url}" | |
| body = pr_body | |
| if issue_ref: | |
| body += f"\n\nCloses #{issue_ref.number}" | |
| pr = gh.open_pr(repo_full_name, target_branch, pr_title, body, base=base_branch or "main") | |
| return f"PR opened: {pr.html_url}" | |
| # ---------------------------------------------------------- BUNDLE | |
| return Toolbox( | |
| read_only=[ | |
| fetch_issue, list_project_files, search_code, read_file_range, | |
| git_log, git_blame, summarize_diff, analyze_test_failure, | |
| ], | |
| edit=[run_tests, lint_check, format_code, dependency_audit, scan_secrets], | |
| migrate=[ast_grep_search, ast_grep_rewrite, codemod_python], | |
| perf=[benchmark_run, profile_python, cprofile_run, perf_compare], | |
| i18n=[i18n_list_locales, i18n_extract, i18n_check_parity], | |
| finalize=[finalize_patch], | |
| ) | |
| # Backwards-compat alias used by old callers. | |
| def make_tools(*args, **kwargs): # pragma: no cover - thin shim | |
| return make_toolbox(*args, **kwargs).all | |
| # ================================================================= | |
| # CODEMOD (ISOLATED) | |
| # ================================================================= | |
| # Harness executed in a child process. It imports the user-supplied `transform`, | |
| # applies it across the glob, writes changes, and prints a report in the SAME | |
| # format the in-process implementation used to (so behaviour is unchanged). | |
| _CODEMOD_HARNESS = r''' | |
| import sys | |
| from pathlib import Path | |
| script_path, repo_str, glob = sys.argv[1], sys.argv[2], sys.argv[3] | |
| repo_path = Path(repo_str) | |
| source_code = Path(script_path).read_text(encoding="utf-8") | |
| ns = {} | |
| try: | |
| exec(compile(source_code, "<codemod>", "exec"), ns) | |
| except Exception as e: | |
| print(f"codemod script failed to compile: {e}") | |
| sys.exit(0) | |
| transform = ns.get("transform") | |
| if not callable(transform): | |
| print("codemod script must define `transform(source, path) -> str`.") | |
| sys.exit(0) | |
| changed = [] | |
| errors = [] | |
| for p in repo_path.glob(glob): | |
| if not p.is_file() or ".git" in p.parts: | |
| continue | |
| try: | |
| src = p.read_text(encoding="utf-8") | |
| except UnicodeDecodeError: | |
| continue | |
| try: | |
| new = transform(src, str(p.relative_to(repo_path))) | |
| except Exception as e: | |
| errors.append(f"{p}: {e}") | |
| continue | |
| if new is not None and new != src: | |
| p.write_text(new, encoding="utf-8") | |
| changed.append(str(p.relative_to(repo_path))) | |
| report = [f"changed: {len(changed)} file(s)"] | |
| report += [f" {c}" for c in changed[:50]] | |
| if errors: | |
| report.append(f"errors: {len(errors)}") | |
| report += [f" {e}" for e in errors[:20]] | |
| print("\n".join(report)) | |
| ''' | |
| def _run_codemod_subprocess(repo_path: Path, script: str, glob: str) -> str: | |
| """Execute an LLM-authored codemod in an isolated child process. | |
| Isolation properties vs the old in-process ``exec``: | |
| - separate process → cannot corrupt/inspect the long-lived agent's memory; | |
| - scrubbed environment → no GITHUB_TOKEN / API keys leak into the script; | |
| - hard timeout → a runaway/infinite codemod can't hang the agent. | |
| """ | |
| with tempfile.TemporaryDirectory() as td: | |
| script_file = Path(td) / "codemod_script.py" | |
| harness_file = Path(td) / "codemod_harness.py" | |
| script_file.write_text(script, encoding="utf-8") | |
| harness_file.write_text(_CODEMOD_HARNESS, encoding="utf-8") | |
| # Minimal env: keep PATH/HOME (and PYTHONPATH if the agent set one) so | |
| # the child can still import libcst etc. from site-packages, but drop | |
| # every secret-bearing variable. | |
| safe_env = {"PATH": os.environ.get("PATH", ""), "HOME": os.environ.get("HOME", "")} | |
| if "PYTHONPATH" in os.environ: | |
| safe_env["PYTHONPATH"] = os.environ["PYTHONPATH"] | |
| try: | |
| proc = subprocess.run( | |
| [sys.executable, str(harness_file), str(script_file), str(repo_path), glob], | |
| capture_output=True, text=True, timeout=300, env=safe_env, cwd=str(repo_path), | |
| ) | |
| except subprocess.TimeoutExpired: | |
| return "codemod timed out after 300s (aborted, no further changes)." | |
| out = (proc.stdout or "").strip() | |
| if proc.returncode != 0 and not out: | |
| return f"codemod failed [exit {proc.returncode}]:\n{(proc.stderr or '')[-2000:]}" | |
| return out | |
| # ================================================================= | |
| # DETECTORS | |
| # ================================================================= | |
| def _autodetect_test_cmd(repo_path: Path) -> str: | |
| if (repo_path / "pyproject.toml").exists() or (repo_path / "pytest.ini").exists(): | |
| return "pytest -q" | |
| if (repo_path / "package.json").exists(): | |
| return "npm test --silent || yarn test" | |
| if (repo_path / "go.mod").exists(): | |
| return "go test ./..." | |
| if (repo_path / "Cargo.toml").exists(): | |
| return "cargo test --quiet" | |
| if (repo_path / "Makefile").exists(): | |
| return "make test" | |
| return "" | |
| def _autodetect_lint_cmd(repo_path: Path, fix: bool = False) -> list[str]: | |
| cmds: list[str] = [] | |
| if (repo_path / "pyproject.toml").exists() or list(repo_path.glob("*.py")): | |
| cmds.append("ruff check --fix ." if fix else "ruff check .") | |
| if (repo_path / "package.json").exists(): | |
| cmds.append("npx --no-install eslint --fix ." if fix else "npx --no-install eslint .") | |
| if (repo_path / "go.mod").exists(): | |
| cmds.append("golangci-lint run --fix ./..." if fix else "golangci-lint run ./...") | |
| if (repo_path / "Cargo.toml").exists(): | |
| cmds.append("cargo clippy --fix --allow-dirty --allow-staged" if fix else "cargo clippy") | |
| return cmds | |
| def _autodetect_fmt_cmd(repo_path: Path) -> str: | |
| if (repo_path / "pyproject.toml").exists() or list(repo_path.glob("*.py")): | |
| return "ruff format ." | |
| if (repo_path / "package.json").exists(): | |
| return "npx --no-install prettier --write ." | |
| if (repo_path / "go.mod").exists(): | |
| return "gofmt -w ." | |
| if (repo_path / "Cargo.toml").exists(): | |
| return "cargo fmt" | |
| return "" | |
| def _autodetect_audit_cmd(repo_path: Path) -> str: | |
| if (repo_path / "pyproject.toml").exists() or (repo_path / "requirements.txt").exists(): | |
| return "pip-audit --strict || true" | |
| if (repo_path / "package.json").exists(): | |
| return "npm audit --omit=dev --json || true" | |
| if (repo_path / "Cargo.toml").exists(): | |
| return "cargo audit || true" | |
| return "" | |
| def _detect_runner(text: str) -> str: | |
| if "pytest" in text or "test_" in text or " failed" in text and " passed" in text: | |
| return "pytest" | |
| if "PASS " in text or "FAIL " in text or "describe(" in text: | |
| return "jest" | |
| if "ok\t" in text or "--- FAIL:" in text: | |
| return "go test" | |
| if "running" in text and "test result:" in text: | |
| return "cargo test" | |
| return "unknown" | |
| def _branch_exists(repo_path: Path, branch: str) -> bool: | |
| try: | |
| subprocess.check_output( | |
| ["git", "rev-parse", "--verify", branch], cwd=repo_path, stderr=subprocess.DEVNULL | |
| ) | |
| return True | |
| except subprocess.CalledProcessError: | |
| return False | |
| def _find_open_pr_for_branch(gh: GitHubOps, repo_full_name: str, branch: str): | |
| repo = gh.get_repo(repo_full_name) | |
| pulls = repo.get_pulls(state="open", head=f"{repo.owner.login}:{branch}") | |
| for pr in pulls: | |
| return pr | |
| return None | |