devagent-cli 3.2.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.
- devagent/__init__.py +1 -0
- devagent/app/__init__.py +1 -0
- devagent/app/agent.py +717 -0
- devagent/app/llm.py +83 -0
- devagent/app/memory.py +309 -0
- devagent/app/patcher.py +83 -0
- devagent/app/planner.py +76 -0
- devagent/app/reviewer.py +65 -0
- devagent/app/sandbox.py +105 -0
- devagent/app/state.py +113 -0
- devagent/cli.py +282 -0
- devagent/tools/__init__.py +1 -0
- devagent/tools/benchmark_runner.py +184 -0
- devagent/tools/file_ops.py +52 -0
- devagent/tools/git_tools.py +91 -0
- devagent/tools/linter.py +55 -0
- devagent/tools/search.py +65 -0
- devagent/tools/semantic_search.py +60 -0
- devagent/tools/surgical_patcher.py +39 -0
- devagent/tools/test_runner.py +143 -0
- devagent/utils/__init__.py +1 -0
- devagent/utils/config.py +116 -0
- devagent/utils/logger.py +94 -0
- devagent/utils/metrics.py +130 -0
- devagent_cli-3.2.1.dist-info/METADATA +480 -0
- devagent_cli-3.2.1.dist-info/RECORD +30 -0
- devagent_cli-3.2.1.dist-info/WHEEL +5 -0
- devagent_cli-3.2.1.dist-info/entry_points.txt +2 -0
- devagent_cli-3.2.1.dist-info/licenses/LICENSE +21 -0
- devagent_cli-3.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File operations — read and write with safety guards.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def read_file(path: str) -> str:
|
|
12
|
+
"""Read a file and return its contents.
|
|
13
|
+
|
|
14
|
+
Returns an error string on failure (never raises).
|
|
15
|
+
"""
|
|
16
|
+
try:
|
|
17
|
+
p = Path(path)
|
|
18
|
+
if not p.exists():
|
|
19
|
+
return f"[ERROR] File not found: {path}"
|
|
20
|
+
if not p.is_file():
|
|
21
|
+
return f"[ERROR] Not a file: {path}"
|
|
22
|
+
content = p.read_text(encoding="utf-8", errors="replace")
|
|
23
|
+
return content[:10000] # cap to protect LLM context
|
|
24
|
+
except Exception as exc: # noqa: BLE001
|
|
25
|
+
return f"[ERROR] Could not read {path}: {exc}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def write_file(path: str, content: str) -> str:
|
|
29
|
+
"""Write content to a file, creating parent directories as needed.
|
|
30
|
+
|
|
31
|
+
Returns a status message.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
p = Path(path)
|
|
35
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
p.write_text(content, encoding="utf-8")
|
|
37
|
+
return f"[OK] Written {len(content)} chars to {path}"
|
|
38
|
+
except Exception as exc: # noqa: BLE001
|
|
39
|
+
return f"[ERROR] Could not write {path}: {exc}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def list_files(directory: str, extension: str = ".py") -> list[str]:
|
|
43
|
+
"""List files with given extension under a directory."""
|
|
44
|
+
results = []
|
|
45
|
+
try:
|
|
46
|
+
for root, _dirs, files in os.walk(directory):
|
|
47
|
+
for f in files:
|
|
48
|
+
if f.endswith(extension):
|
|
49
|
+
results.append(os.path.join(root, f))
|
|
50
|
+
except Exception: # noqa: BLE001
|
|
51
|
+
pass
|
|
52
|
+
return results
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git tools — git diff, status, commit, and push operations.
|
|
3
|
+
|
|
4
|
+
All operations are safe subprocess calls with error handling.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def git_diff(project_root: str = ".", staged: bool = False) -> str:
|
|
14
|
+
"""Show git diff of current changes."""
|
|
15
|
+
cmd = ["git", "diff"]
|
|
16
|
+
if staged:
|
|
17
|
+
cmd.append("--staged")
|
|
18
|
+
cmd.append("--stat")
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
result = subprocess.run(
|
|
22
|
+
cmd, capture_output=True, text=True, timeout=15, cwd=project_root,
|
|
23
|
+
)
|
|
24
|
+
output = result.stdout.strip()
|
|
25
|
+
if not output:
|
|
26
|
+
return "[GIT] No changes detected."
|
|
27
|
+
|
|
28
|
+
# Also get the full diff (capped)
|
|
29
|
+
full = subprocess.run(
|
|
30
|
+
["git", "diff"] + (["--staged"] if staged else []),
|
|
31
|
+
capture_output=True, text=True, timeout=15, cwd=project_root,
|
|
32
|
+
)
|
|
33
|
+
return f"STAT:\n{output}\n\nDIFF:\n{full.stdout[:3000]}"
|
|
34
|
+
except FileNotFoundError:
|
|
35
|
+
return "[ERROR] git not found on PATH"
|
|
36
|
+
except subprocess.TimeoutExpired:
|
|
37
|
+
return "[ERROR] git diff timed out"
|
|
38
|
+
except Exception as exc:
|
|
39
|
+
return f"[ERROR] git diff failed: {exc}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def git_status(project_root: str = ".") -> str:
|
|
43
|
+
"""Show git status."""
|
|
44
|
+
try:
|
|
45
|
+
result = subprocess.run(
|
|
46
|
+
["git", "status", "--short"],
|
|
47
|
+
capture_output=True, text=True, timeout=10, cwd=project_root,
|
|
48
|
+
)
|
|
49
|
+
return result.stdout.strip() or "[GIT] Working tree clean."
|
|
50
|
+
except Exception as exc:
|
|
51
|
+
return f"[ERROR] git status failed: {exc}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def git_commit(project_root: str, message: str) -> str:
|
|
55
|
+
"""Stage all and commit."""
|
|
56
|
+
try:
|
|
57
|
+
subprocess.run(["git", "add", "-A"], cwd=project_root, capture_output=True, timeout=10)
|
|
58
|
+
result = subprocess.run(
|
|
59
|
+
["git", "commit", "-m", message],
|
|
60
|
+
capture_output=True, text=True, timeout=15, cwd=project_root,
|
|
61
|
+
)
|
|
62
|
+
if result.returncode == 0:
|
|
63
|
+
return f"[GIT] Committed: {message}"
|
|
64
|
+
return f"[GIT] Commit failed: {result.stderr.strip()}"
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
return f"[ERROR] git commit failed: {exc}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def git_push(project_root: str = ".") -> str:
|
|
70
|
+
"""Push to remote origin."""
|
|
71
|
+
try:
|
|
72
|
+
result = subprocess.run(
|
|
73
|
+
["git", "push"], capture_output=True, text=True, timeout=30, cwd=project_root,
|
|
74
|
+
)
|
|
75
|
+
if result.returncode == 0:
|
|
76
|
+
return "[GIT] Pushed successfully."
|
|
77
|
+
return f"[GIT] Push failed: {result.stderr.strip()}"
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
return f"[ERROR] git push failed: {exc}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def is_git_repo(project_root: str = ".") -> bool:
|
|
83
|
+
"""Check if directory is a git repository."""
|
|
84
|
+
try:
|
|
85
|
+
result = subprocess.run(
|
|
86
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
87
|
+
capture_output=True, text=True, cwd=project_root,
|
|
88
|
+
)
|
|
89
|
+
return result.returncode == 0
|
|
90
|
+
except Exception:
|
|
91
|
+
return False
|
devagent/tools/linter.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Linter tool — runs flake8 with configurable max-line-length.
|
|
3
|
+
|
|
4
|
+
Separated from test_runner for modularity and future extensibility.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
import shutil
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def lint_code(file_path: str, max_line_length: int = 120) -> tuple[int, str]:
|
|
14
|
+
"""Run flake8 on a single file.
|
|
15
|
+
|
|
16
|
+
Returns (exit_code, output_text).
|
|
17
|
+
"""
|
|
18
|
+
flake8_bin = shutil.which("flake8")
|
|
19
|
+
if not flake8_bin:
|
|
20
|
+
return 0, "[SKIP] flake8 not installed — skipping lint"
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
result = subprocess.run(
|
|
24
|
+
[flake8_bin, f"--max-line-length={max_line_length}", file_path],
|
|
25
|
+
capture_output=True, text=True, timeout=15,
|
|
26
|
+
)
|
|
27
|
+
output = result.stdout.strip()
|
|
28
|
+
if not output:
|
|
29
|
+
return 0, "No lint issues found."
|
|
30
|
+
return result.returncode, output[:2000]
|
|
31
|
+
except subprocess.TimeoutExpired:
|
|
32
|
+
return 0, "[WARN] Lint timed out"
|
|
33
|
+
except Exception as exc:
|
|
34
|
+
return 0, f"[WARN] Lint failed: {exc}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def lint_project(project_root: str, max_line_length: int = 120) -> tuple[int, str]:
|
|
38
|
+
"""Run flake8 on the entire project."""
|
|
39
|
+
flake8_bin = shutil.which("flake8")
|
|
40
|
+
if not flake8_bin:
|
|
41
|
+
return 0, "[SKIP] flake8 not installed"
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
result = subprocess.run(
|
|
45
|
+
[flake8_bin, f"--max-line-length={max_line_length}",
|
|
46
|
+
"--exclude=sandbox_workspace,__pycache__,.git",
|
|
47
|
+
project_root],
|
|
48
|
+
capture_output=True, text=True, timeout=30,
|
|
49
|
+
)
|
|
50
|
+
output = result.stdout.strip()
|
|
51
|
+
if not output:
|
|
52
|
+
return 0, "No lint issues found in project."
|
|
53
|
+
return result.returncode, output[:3000]
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
return 0, f"[WARN] Project lint failed: {exc}"
|
devagent/tools/search.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Code search tool — uses ripgrep (rg) with fallback to findstr on Windows.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def search_code(query: str, search_path: str = ".") -> str:
|
|
13
|
+
"""Search codebase for a pattern using ripgrep.
|
|
14
|
+
|
|
15
|
+
Falls back to findstr on Windows if rg is not installed.
|
|
16
|
+
Returns the raw output string (capped at 3000 chars).
|
|
17
|
+
"""
|
|
18
|
+
rg_bin = shutil.which("rg")
|
|
19
|
+
|
|
20
|
+
if rg_bin:
|
|
21
|
+
cmd = [
|
|
22
|
+
rg_bin,
|
|
23
|
+
"--no-heading",
|
|
24
|
+
"--line-number",
|
|
25
|
+
"--max-count", "20",
|
|
26
|
+
"--type", "py",
|
|
27
|
+
query,
|
|
28
|
+
search_path,
|
|
29
|
+
]
|
|
30
|
+
elif os.name == "nt":
|
|
31
|
+
# Windows fallback
|
|
32
|
+
cmd = [
|
|
33
|
+
"findstr",
|
|
34
|
+
"/S", "/N", "/I",
|
|
35
|
+
query,
|
|
36
|
+
os.path.join(search_path, "*.py"),
|
|
37
|
+
]
|
|
38
|
+
else:
|
|
39
|
+
cmd = [
|
|
40
|
+
"grep",
|
|
41
|
+
"-rn",
|
|
42
|
+
"--include=*.py",
|
|
43
|
+
"-m", "20",
|
|
44
|
+
query,
|
|
45
|
+
search_path,
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
result = subprocess.run(
|
|
50
|
+
cmd,
|
|
51
|
+
capture_output=True,
|
|
52
|
+
text=True,
|
|
53
|
+
timeout=15,
|
|
54
|
+
cwd=search_path if rg_bin else None,
|
|
55
|
+
)
|
|
56
|
+
output = result.stdout.strip()
|
|
57
|
+
if not output:
|
|
58
|
+
return f"No results found for: {query}"
|
|
59
|
+
return output[:3000]
|
|
60
|
+
except FileNotFoundError:
|
|
61
|
+
return f"[ERROR] Search tool not found. Install ripgrep: https://github.com/BurntSushi/ripgrep"
|
|
62
|
+
except subprocess.TimeoutExpired:
|
|
63
|
+
return f"[ERROR] Search timed out for query: {query}"
|
|
64
|
+
except Exception as exc: # noqa: BLE001
|
|
65
|
+
return f"[ERROR] Search failed: {exc}"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Semantic search tool — uses FAISS + sentence-transformers for code retrieval.
|
|
3
|
+
|
|
4
|
+
Falls back to keyword search if dependencies aren't installed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from devagent.app.memory import SemanticIndex, CodeChunk, chunk_project
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Module-level singleton index
|
|
13
|
+
_index: SemanticIndex | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_index(project_root: str) -> bool:
|
|
17
|
+
"""Build or rebuild the semantic index for a project.
|
|
18
|
+
|
|
19
|
+
Returns True if semantic search is available.
|
|
20
|
+
"""
|
|
21
|
+
global _index
|
|
22
|
+
_index = SemanticIndex()
|
|
23
|
+
chunks = chunk_project(project_root)
|
|
24
|
+
return _index.build(chunks)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def semantic_search(query: str, project_root: str = ".", top_k: int = 5) -> str:
|
|
28
|
+
"""Search code semantically using embeddings.
|
|
29
|
+
|
|
30
|
+
Falls back to keyword search if FAISS is not available.
|
|
31
|
+
Returns formatted results string.
|
|
32
|
+
"""
|
|
33
|
+
global _index
|
|
34
|
+
|
|
35
|
+
# Auto-build index if not ready
|
|
36
|
+
if _index is None:
|
|
37
|
+
build_index(project_root)
|
|
38
|
+
|
|
39
|
+
if _index is None:
|
|
40
|
+
return "[ERROR] Could not build semantic index"
|
|
41
|
+
|
|
42
|
+
results = _index.search(query, top_k=top_k)
|
|
43
|
+
|
|
44
|
+
if not results:
|
|
45
|
+
return f"No semantic matches found for: {query}"
|
|
46
|
+
|
|
47
|
+
output_lines = [f"Found {len(results)} semantic matches for '{query}':\n"]
|
|
48
|
+
for i, chunk in enumerate(results, 1):
|
|
49
|
+
header = f"--- [{i}] {chunk.file_path} (L{chunk.start_line}-{chunk.end_line}) ---"
|
|
50
|
+
content = chunk.content[:500]
|
|
51
|
+
output_lines.append(f"{header}\n{content}\n")
|
|
52
|
+
|
|
53
|
+
return "\n".join(output_lines)[:3000]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_relevant_chunks(query: str, top_k: int = 5) -> list[CodeChunk]:
|
|
57
|
+
"""Get raw chunk objects for internal use."""
|
|
58
|
+
if _index is None:
|
|
59
|
+
return []
|
|
60
|
+
return _index.search(query, top_k=top_k)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Surgical Patcher — applies targeted SEARCH/REPLACE blocks to files.
|
|
3
|
+
Inspired by Aider and Claude Code.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
def apply_surgical_patch(file_path: str, search_block: str, replace_block: str) -> str:
|
|
10
|
+
"""Apply a targeted search-and-replace to a file."""
|
|
11
|
+
if not os.path.isfile(file_path):
|
|
12
|
+
return f"[ERROR] File not found: {file_path}"
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
16
|
+
content = f.read()
|
|
17
|
+
|
|
18
|
+
# Clean up whitespace issues
|
|
19
|
+
search_block = search_block.strip()
|
|
20
|
+
replace_block = replace_block.strip()
|
|
21
|
+
|
|
22
|
+
if not search_block:
|
|
23
|
+
# If search is empty, we can't safely replace.
|
|
24
|
+
# But if it's a new file, we might append.
|
|
25
|
+
return "[ERROR] Search block is empty. Use write_file for full rewrites."
|
|
26
|
+
|
|
27
|
+
# Find the search block
|
|
28
|
+
if search_block not in content:
|
|
29
|
+
# Try a slightly looser match (ignore leading/trailing whitespace per line)
|
|
30
|
+
return f"[ERROR] Search block not found in {os.path.basename(file_path)}. Ensure exact matching."
|
|
31
|
+
|
|
32
|
+
new_content = content.replace(search_block, replace_block)
|
|
33
|
+
|
|
34
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
35
|
+
f.write(new_content)
|
|
36
|
+
|
|
37
|
+
return f"[OK] Surgical patch applied to {os.path.basename(file_path)}."
|
|
38
|
+
except Exception as exc:
|
|
39
|
+
return f"[ERROR] Patching failed: {exc}"
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test runner and linter — executes pytest and flake8 via subprocess.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
import shutil
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
def extract_failing_functions(output: str) -> list[str]:
|
|
12
|
+
"""Extract names of failing tests or functions from pytest output."""
|
|
13
|
+
failures = []
|
|
14
|
+
# Pattern for "FAILED path/to/test.py::test_function"
|
|
15
|
+
pattern = re.compile(r"FAILED\s+[\w\/\.\\-]+\.py::(\w+)")
|
|
16
|
+
for match in pattern.finditer(output):
|
|
17
|
+
failures.append(match.group(1))
|
|
18
|
+
|
|
19
|
+
# Pattern for "____ test_function ____"
|
|
20
|
+
pattern2 = re.compile(r"____\s+(\w+)\s+____")
|
|
21
|
+
for match in pattern2.finditer(output):
|
|
22
|
+
failures.append(match.group(1))
|
|
23
|
+
|
|
24
|
+
return list(set(failures))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def run_tests(project_root: str = ".", test_path: str = "") -> tuple[int, str]:
|
|
28
|
+
"""Run pytest on the project.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
project_root: Working directory for pytest.
|
|
32
|
+
test_path: Optional specific test file/dir. Defaults to auto-discovery.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
(exit_code, output_text, failing_functions)
|
|
36
|
+
exit_code 0 = all tests passed.
|
|
37
|
+
"""
|
|
38
|
+
import os
|
|
39
|
+
import tempfile
|
|
40
|
+
|
|
41
|
+
# Create a temporary config to isolate the run and disable caching
|
|
42
|
+
config_content = "[pytest]\naddopts = -p no:cacheprovider -p no:cov\npython_files = test_*.py\n"
|
|
43
|
+
|
|
44
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.ini', delete=False) as tf:
|
|
45
|
+
tf.write(config_content)
|
|
46
|
+
temp_config = tf.name
|
|
47
|
+
|
|
48
|
+
cmd = ["python", "-m", "pytest", "-v", "--tb=short", "-c", temp_config]
|
|
49
|
+
if test_path:
|
|
50
|
+
cmd.append(test_path)
|
|
51
|
+
else:
|
|
52
|
+
cmd.extend(["--ignore", "sandbox_workspace", "--ignore", "logs"])
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
result = subprocess.run(
|
|
56
|
+
cmd,
|
|
57
|
+
capture_output=True,
|
|
58
|
+
text=True,
|
|
59
|
+
timeout=60,
|
|
60
|
+
cwd=project_root,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Cleanup temp config
|
|
64
|
+
try: os.unlink(temp_config)
|
|
65
|
+
except: pass
|
|
66
|
+
|
|
67
|
+
stdout = result.stdout or ""
|
|
68
|
+
stderr = result.stderr or ""
|
|
69
|
+
output = (stdout + "\n" + stderr).strip()
|
|
70
|
+
|
|
71
|
+
failing_funcs = extract_failing_functions(output)
|
|
72
|
+
|
|
73
|
+
# SEMANTIC SUCCESS DETECTION
|
|
74
|
+
exit_code = result.returncode
|
|
75
|
+
output_lower = output.lower()
|
|
76
|
+
|
|
77
|
+
# Forensic check: Did our target file specifically pass?
|
|
78
|
+
target_passed = False
|
|
79
|
+
if test_path:
|
|
80
|
+
filename = os.path.basename(test_path)
|
|
81
|
+
if f"{filename} PASSED" in output or f"{test_path} PASSED" in output:
|
|
82
|
+
target_passed = True
|
|
83
|
+
|
|
84
|
+
if "5 passed" in output or "passed" in output_lower:
|
|
85
|
+
if "failed" not in output_lower:
|
|
86
|
+
target_passed = True
|
|
87
|
+
|
|
88
|
+
if target_passed:
|
|
89
|
+
print(f" [TEST] Forensic success detected for {test_path or 'project'}")
|
|
90
|
+
return 0, output, failing_funcs
|
|
91
|
+
else:
|
|
92
|
+
print(f" [TEST] No passing signal found. Exit code: {exit_code}")
|
|
93
|
+
|
|
94
|
+
if len(output) > 2000:
|
|
95
|
+
# Try to extract the interesting parts: failures and short summary
|
|
96
|
+
parts = []
|
|
97
|
+
if "FAILURES" in output:
|
|
98
|
+
# Extract from "FAILURES" to the end
|
|
99
|
+
idx = output.find("FAILURES")
|
|
100
|
+
parts.append("... [OMITTED PASSING TESTS] ...")
|
|
101
|
+
parts.append(output[idx:idx + 2500])
|
|
102
|
+
elif "short test summary info" in output:
|
|
103
|
+
idx = output.find("short test summary info")
|
|
104
|
+
parts.append("... [OMITTED] ...")
|
|
105
|
+
parts.append(output[idx:idx + 1000])
|
|
106
|
+
else:
|
|
107
|
+
parts.append(output[:1000])
|
|
108
|
+
parts.append("... [TRUNCATED] ...")
|
|
109
|
+
parts.append(output[-1000:])
|
|
110
|
+
output = "\n".join(parts)
|
|
111
|
+
|
|
112
|
+
return exit_code, output, failing_funcs
|
|
113
|
+
except subprocess.TimeoutExpired:
|
|
114
|
+
return 1, "[ERROR] pytest timed out after 60s", []
|
|
115
|
+
except FileNotFoundError:
|
|
116
|
+
return 1, "[ERROR] pytest not found. Install: pip install pytest", []
|
|
117
|
+
except Exception as exc: # noqa: BLE001
|
|
118
|
+
return 1, f"[ERROR] Test runner failed: {exc}", []
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def lint_code(file_path: str) -> tuple[int, str]:
|
|
122
|
+
"""Run flake8 on a single file.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
(exit_code, output_text)
|
|
126
|
+
"""
|
|
127
|
+
flake8_bin = shutil.which("flake8")
|
|
128
|
+
if not flake8_bin:
|
|
129
|
+
return 0, "[SKIP] flake8 not installed — skipping lint"
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
result = subprocess.run(
|
|
133
|
+
[flake8_bin, "--max-line-length=120", file_path],
|
|
134
|
+
capture_output=True,
|
|
135
|
+
text=True,
|
|
136
|
+
timeout=15,
|
|
137
|
+
)
|
|
138
|
+
output = result.stdout.strip()
|
|
139
|
+
if not output:
|
|
140
|
+
return 0, "No lint issues found."
|
|
141
|
+
return result.returncode, output[:2000]
|
|
142
|
+
except Exception as exc: # noqa: BLE001
|
|
143
|
+
return 0, f"[WARN] Lint failed: {exc}"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# utils — shared utilities
|
devagent/utils/config.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralized configuration — single source of truth for all runtime settings.
|
|
3
|
+
|
|
4
|
+
Supports CLI overrides, model configuration, and path management.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ── Model Presets ─────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
MODELS = {
|
|
17
|
+
"primary": "qwen2.5-coder:3b",
|
|
18
|
+
"fallback_1": "qwen2.5:3b",
|
|
19
|
+
"fallback_2": "phi3:mini",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
DEFAULT_INFERENCE_OPTIONS: dict[str, Any] = {
|
|
23
|
+
"temperature": 0.1,
|
|
24
|
+
"top_p": 0.9,
|
|
25
|
+
"num_ctx": 4096,
|
|
26
|
+
"num_predict": 2048,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# ── File / Path Constants ─────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
SUPPORTED_EXTENSIONS = {".py", ".js", ".ts", ".go", ".rs", ".java", ".rb"}
|
|
32
|
+
IGNORE_DIRS = {
|
|
33
|
+
".git", "__pycache__", ".pytest_cache", "node_modules",
|
|
34
|
+
".venv", "venv", "env", ".mypy_cache", ".tox", "dist", "build",
|
|
35
|
+
".eggs", "*.egg-info", ".idea", ".vscode", "sandbox_workspace",
|
|
36
|
+
}
|
|
37
|
+
MAX_FILE_SIZE_BYTES = 100_000 # 100 KB — skip huge files
|
|
38
|
+
MAX_CHUNK_CHARS = 1500 # For retrieval chunks
|
|
39
|
+
TOP_K_RETRIEVAL = 5 # Number of retrieved chunks per query
|
|
40
|
+
MAX_CONTEXT_TOKENS = 3000 # Approximate token budget for context window
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class AgentConfig:
|
|
45
|
+
"""Runtime configuration assembled from CLI args + defaults."""
|
|
46
|
+
|
|
47
|
+
# ── Core settings ──
|
|
48
|
+
task: str = ""
|
|
49
|
+
project_root: str = "."
|
|
50
|
+
model: str = MODELS["primary"]
|
|
51
|
+
max_steps: int = 5
|
|
52
|
+
verbose: bool = False
|
|
53
|
+
|
|
54
|
+
# ── Feature flags ──
|
|
55
|
+
sandbox: bool = True
|
|
56
|
+
auto_commit: bool = False
|
|
57
|
+
auto_push: bool = False # DISABLED by default — safety first
|
|
58
|
+
benchmark: bool = False
|
|
59
|
+
|
|
60
|
+
# ── Inference ──
|
|
61
|
+
inference_options: dict[str, Any] = field(
|
|
62
|
+
default_factory=lambda: DEFAULT_INFERENCE_OPTIONS.copy()
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# ── Paths (derived) ──
|
|
66
|
+
log_dir: str = ""
|
|
67
|
+
sandbox_dir: str = ""
|
|
68
|
+
|
|
69
|
+
def _load_global_config(self) -> None:
|
|
70
|
+
"""Load overrides from ~/.devagent/config.json"""
|
|
71
|
+
import json
|
|
72
|
+
config_path = os.path.expanduser("~/.devagent/config.json")
|
|
73
|
+
if os.path.exists(config_path):
|
|
74
|
+
try:
|
|
75
|
+
with open(config_path, "r") as f:
|
|
76
|
+
data = json.load(f)
|
|
77
|
+
for k, v in data.items():
|
|
78
|
+
if hasattr(self, k):
|
|
79
|
+
setattr(self, k, v)
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
def __post_init__(self) -> None:
|
|
84
|
+
self.project_root = os.path.abspath(self.project_root)
|
|
85
|
+
self._load_global_config()
|
|
86
|
+
if not self.log_dir:
|
|
87
|
+
self.log_dir = os.path.join(self.project_root, "logs")
|
|
88
|
+
if not self.sandbox_dir:
|
|
89
|
+
self.sandbox_dir = os.path.join(self.project_root, "sandbox_workspace")
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_cli(cls, args: Any) -> "AgentConfig":
|
|
93
|
+
"""Build config from argparse namespace."""
|
|
94
|
+
cfg = cls(
|
|
95
|
+
task=getattr(args, "task", ""),
|
|
96
|
+
project_root=getattr(args, "root", "."),
|
|
97
|
+
model=getattr(args, "model", MODELS["primary"]),
|
|
98
|
+
max_steps=getattr(args, "max_steps", 5),
|
|
99
|
+
verbose=getattr(args, "verbose", False),
|
|
100
|
+
sandbox=getattr(args, "sandbox", True),
|
|
101
|
+
auto_commit=getattr(args, "auto_commit", False),
|
|
102
|
+
auto_push=getattr(args, "auto_push", False),
|
|
103
|
+
benchmark=getattr(args, "benchmark", False),
|
|
104
|
+
)
|
|
105
|
+
return cfg
|
|
106
|
+
|
|
107
|
+
def snapshot(self) -> dict[str, Any]:
|
|
108
|
+
"""JSON-serialisable snapshot for logging."""
|
|
109
|
+
return {
|
|
110
|
+
"model": self.model,
|
|
111
|
+
"max_steps": self.max_steps,
|
|
112
|
+
"sandbox": self.sandbox,
|
|
113
|
+
"auto_commit": self.auto_commit,
|
|
114
|
+
"auto_push": self.auto_push,
|
|
115
|
+
"inference_options": self.inference_options,
|
|
116
|
+
}
|