alpiecode 9.0.3__tar.gz → 9.0.4__tar.gz
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.
- {alpiecode-9.0.3 → alpiecode-9.0.4}/PKG-INFO +1 -1
- {alpiecode-9.0.3 → alpiecode-9.0.4}/pyproject.toml +1 -1
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/alpiecode.egg-info/PKG-INFO +1 -1
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/alpiecode.egg-info/SOURCES.txt +1 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/__init__.py +1 -1
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/config.py +16 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/discovery.py +68 -1
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/executor.py +57 -5
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/local_model.py +15 -2
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/orchestrator.py +64 -17
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/progress.py +45 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/prompt.py +9 -3
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/rephraser.py +4 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/tools.py +25 -9
- alpiecode-9.0.4/test/test_claude_codex_capabilities.py +111 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/README.md +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/setup.cfg +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/alpiecode/__init__.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/alpiecode.egg-info/dependency_links.txt +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/alpiecode.egg-info/entry_points.txt +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/alpiecode.egg-info/requires.txt +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/alpiecode.egg-info/top_level.txt +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/agent.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/backends/__init__.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/backends/base.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/backends/local_backend.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/backends/openai_backend.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/cache.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/cli.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/client.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/compaction.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/context.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/doctor.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/extension/alpiecode.vsix +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/git_ops.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/github.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/guardian.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/guardrails.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/ipython_ext.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/media.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/memory.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/server.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/session.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/updater.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/src/codeagent/vscode_installer.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/test/test_phase1.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/test/test_phase2.py +0 -0
- {alpiecode-9.0.3 → alpiecode-9.0.4}/test/test_phase3.py +0 -0
|
@@ -40,6 +40,7 @@ src/codeagent/backends/base.py
|
|
|
40
40
|
src/codeagent/backends/local_backend.py
|
|
41
41
|
src/codeagent/backends/openai_backend.py
|
|
42
42
|
src/codeagent/extension/alpiecode.vsix
|
|
43
|
+
test/test_claude_codex_capabilities.py
|
|
43
44
|
test/test_phase1.py
|
|
44
45
|
test/test_phase2.py
|
|
45
46
|
test/test_phase3.py
|
|
@@ -16,7 +16,7 @@ Supports:
|
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
from typing import Optional, Any, Dict
|
|
18
18
|
|
|
19
|
-
__version__ = "9.0.
|
|
19
|
+
__version__ = "9.0.4"
|
|
20
20
|
|
|
21
21
|
def run(task: str, workdir: str = ".", reasoning_level: str = "thinking", **kwargs):
|
|
22
22
|
"""Run an autonomous coding task programmatically."""
|
|
@@ -133,6 +133,16 @@ def load_config() -> Config:
|
|
|
133
133
|
data["api_key"] = os.environ[f"{prefix}API_KEY"]
|
|
134
134
|
if os.environ.get("HF_TOKEN"):
|
|
135
135
|
data["hf_token"] = os.environ["HF_TOKEN"]
|
|
136
|
+
|
|
137
|
+
# Context size overrides from environment
|
|
138
|
+
for ctx_var in ("ALPIECODE_N_CTX", "ALPIECODE_CTX_SIZE", "ALPIECODE_CONTEXT_SIZE", "CODEAGENT_N_CTX"):
|
|
139
|
+
if os.environ.get(ctx_var):
|
|
140
|
+
try:
|
|
141
|
+
data["n_ctx"] = int(os.environ[ctx_var])
|
|
142
|
+
break
|
|
143
|
+
except ValueError:
|
|
144
|
+
pass
|
|
145
|
+
|
|
136
146
|
if os.environ.get("ALPIECODE_CPU") == "1":
|
|
137
147
|
data["n_gpu_layers"] = 0
|
|
138
148
|
elif os.environ.get("ALPIECODE_GPU_LAYERS"):
|
|
@@ -141,6 +151,12 @@ def load_config() -> Config:
|
|
|
141
151
|
except ValueError:
|
|
142
152
|
pass
|
|
143
153
|
|
|
154
|
+
# For offline mode, optimize default context size to 4096 to prevent VRAM/RAM bottleneck
|
|
155
|
+
# unless explicitly configured by the user
|
|
156
|
+
if data.get("base_url") == "offline" and not any(os.environ.get(v) for v in ("ALPIECODE_N_CTX", "ALPIECODE_CTX_SIZE")):
|
|
157
|
+
if data.get("n_ctx") == 32768:
|
|
158
|
+
data["n_ctx"] = 4096
|
|
159
|
+
|
|
144
160
|
# Filter data to only keys present in Config fields
|
|
145
161
|
valid_keys = {f.name for f in fields(Config)}
|
|
146
162
|
filtered_data = {k: v for k, v in data.items() if k in valid_keys}
|
|
@@ -19,7 +19,7 @@ import subprocess
|
|
|
19
19
|
import sys
|
|
20
20
|
from dataclasses import dataclass, field
|
|
21
21
|
from pathlib import Path
|
|
22
|
-
from typing import List, Optional, Tuple
|
|
22
|
+
from typing import Dict, List, Optional, Tuple
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
# -- Task Context ----------------------------------------------------------
|
|
@@ -47,6 +47,7 @@ class TaskContext:
|
|
|
47
47
|
has_tests: bool = False
|
|
48
48
|
entry_points: List[str] = field(default_factory=list)
|
|
49
49
|
dependencies: List[str] = field(default_factory=list)
|
|
50
|
+
symbol_index: Dict[str, List[str]] = field(default_factory=dict)
|
|
50
51
|
|
|
51
52
|
# Budget (computed from complexity)
|
|
52
53
|
max_tokens: int = 8192
|
|
@@ -599,6 +600,22 @@ def gather_relevant_context(
|
|
|
599
600
|
for ep in task_context.entry_points[:3]:
|
|
600
601
|
_add_file(ep, f"{ep} (entry point)")
|
|
601
602
|
|
|
603
|
+
|
|
604
|
+
# -- Priority 2.5: Matching Codebase Symbols (quick navigation) --
|
|
605
|
+
if getattr(task_context, "symbol_index", None):
|
|
606
|
+
matched_syms = []
|
|
607
|
+
task_terms = [t for t in task_lower.split() if len(t) > 3]
|
|
608
|
+
for fpath, syms in task_context.symbol_index.items():
|
|
609
|
+
rel_lower = fpath.lower()
|
|
610
|
+
syms_str = " ".join(syms).lower()
|
|
611
|
+
if any(term in rel_lower or term in syms_str for term in task_terms) or len(matched_syms) < 3:
|
|
612
|
+
matched_syms.append(f"- `{fpath}`: {', '.join(syms[:4])}")
|
|
613
|
+
if len(matched_syms) >= 6:
|
|
614
|
+
break
|
|
615
|
+
if matched_syms:
|
|
616
|
+
sym_section = "### Codebase Symbol Map (Quick Navigation)\n" + "\n".join(matched_syms)
|
|
617
|
+
sections.append(sym_section)
|
|
618
|
+
|
|
602
619
|
# -- Priority 3: Files mentioned in the task (fuzzy match) --
|
|
603
620
|
task_lower = task.lower()
|
|
604
621
|
try:
|
|
@@ -629,6 +646,48 @@ def gather_relevant_context(
|
|
|
629
646
|
|
|
630
647
|
# -- Main Entry Point -------------------------------------------------------
|
|
631
648
|
|
|
649
|
+
|
|
650
|
+
def build_symbol_index(workdir: Path, max_files: int = 40) -> Dict[str, List[str]]:
|
|
651
|
+
"""Fast AST-based code symbol indexer (<50ms).
|
|
652
|
+
|
|
653
|
+
Extracts classes, methods, and functions across Python files.
|
|
654
|
+
"""
|
|
655
|
+
symbols: Dict[str, List[str]] = {}
|
|
656
|
+
count = 0
|
|
657
|
+
import ast
|
|
658
|
+
|
|
659
|
+
skip_dirs = {".git", "__pycache__", "node_modules", ".venv", "venv", "dist", "build", ".egg-info"}
|
|
660
|
+
|
|
661
|
+
try:
|
|
662
|
+
for path in workdir.rglob("*.py"):
|
|
663
|
+
if any(part in skip_dirs or part.endswith(".egg-info") for part in path.parts):
|
|
664
|
+
continue
|
|
665
|
+
try:
|
|
666
|
+
rel_str = str(path.relative_to(workdir))
|
|
667
|
+
tree = ast.parse(path.read_text(encoding="utf-8", errors="replace"))
|
|
668
|
+
file_syms = []
|
|
669
|
+
for node in tree.body:
|
|
670
|
+
if isinstance(node, ast.FunctionDef):
|
|
671
|
+
args = [a.arg for a in node.args.args if a.arg != "self"]
|
|
672
|
+
sig = f"def {node.name}({', '.join(args[:3])})"
|
|
673
|
+
file_syms.append(sig)
|
|
674
|
+
elif isinstance(node, ast.ClassDef):
|
|
675
|
+
methods = [n.name for n in node.body if isinstance(n, ast.FunctionDef) and not n.name.startswith("__")]
|
|
676
|
+
method_str = f" [{', '.join(methods[:3])}]" if methods else ""
|
|
677
|
+
file_syms.append(f"class {node.name}{method_str}")
|
|
678
|
+
if file_syms:
|
|
679
|
+
symbols[rel_str] = file_syms[:8]
|
|
680
|
+
count += 1
|
|
681
|
+
if count >= max_files:
|
|
682
|
+
break
|
|
683
|
+
except Exception:
|
|
684
|
+
continue
|
|
685
|
+
except Exception:
|
|
686
|
+
pass
|
|
687
|
+
|
|
688
|
+
return symbols
|
|
689
|
+
|
|
690
|
+
|
|
632
691
|
def build_task_context(task: str, workdir: Path) -> TaskContext:
|
|
633
692
|
"""Build complete TaskContext by running all discovery phases.
|
|
634
693
|
|
|
@@ -650,7 +709,15 @@ def build_task_context(task: str, workdir: Path) -> TaskContext:
|
|
|
650
709
|
# Phase 5: Budget from complexity
|
|
651
710
|
budget = COMPLEXITY_CONFIG[complexity]
|
|
652
711
|
|
|
712
|
+
symbol_index = {}
|
|
713
|
+
if repo.get("file_count", 0) > 0 and repo.get("project_type") != "empty":
|
|
714
|
+
try:
|
|
715
|
+
symbol_index = build_symbol_index(workdir)
|
|
716
|
+
except Exception:
|
|
717
|
+
pass
|
|
718
|
+
|
|
653
719
|
return TaskContext(
|
|
720
|
+
symbol_index=symbol_index,
|
|
654
721
|
# Intent
|
|
655
722
|
intent=intent,
|
|
656
723
|
complexity=complexity,
|
|
@@ -39,11 +39,15 @@ def parse_text_tool_calls(text: str) -> List[dict]:
|
|
|
39
39
|
|
|
40
40
|
tool_calls = []
|
|
41
41
|
|
|
42
|
-
# 1. Try parsing
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
# 1. Try parsing blocks inside <tool_call> or ```json using robust JSON repair
|
|
43
|
+
from .backends.openai_backend import _repair_and_parse_json_arguments
|
|
44
|
+
blocks = re.findall(r"(?:<tool_call>|```json)\s*(.*?)\s*(?:</tool_call>|```|$)", text, re.DOTALL)
|
|
45
|
+
for block in blocks:
|
|
46
|
+
raw = block.strip()
|
|
47
|
+
if not raw:
|
|
48
|
+
continue
|
|
45
49
|
try:
|
|
46
|
-
data =
|
|
50
|
+
data = _repair_and_parse_json_arguments(raw)
|
|
47
51
|
if isinstance(data, dict) and "name" in data:
|
|
48
52
|
tool_calls.append({
|
|
49
53
|
"name": data["name"],
|
|
@@ -146,6 +150,42 @@ def _parse_error_hint(stderr: str, stdout: str) -> str:
|
|
|
146
150
|
return "\n".join(hints)
|
|
147
151
|
|
|
148
152
|
|
|
153
|
+
|
|
154
|
+
def suggest_similar_file(workdir: Path, target_path: str) -> Optional[str]:
|
|
155
|
+
"""Find the closest existing file path in the workspace for typos/misspellings."""
|
|
156
|
+
import difflib
|
|
157
|
+
target_name = Path(target_path).name.lower()
|
|
158
|
+
candidates = []
|
|
159
|
+
skip_dirs = {".git", "__pycache__", "node_modules", ".venv", "venv"}
|
|
160
|
+
try:
|
|
161
|
+
for p in workdir.rglob("*"):
|
|
162
|
+
if p.is_file() and not any(part in skip_dirs for part in p.parts):
|
|
163
|
+
candidates.append(str(p.relative_to(workdir)))
|
|
164
|
+
except Exception:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
if not candidates:
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
# 1. Exact filename match in another directory
|
|
171
|
+
for c in candidates:
|
|
172
|
+
if Path(c).name.lower() == target_name:
|
|
173
|
+
return c
|
|
174
|
+
|
|
175
|
+
# 2. Fuzzy match on relative path
|
|
176
|
+
matches = difflib.get_close_matches(target_path, candidates, n=1, cutoff=0.55)
|
|
177
|
+
if matches:
|
|
178
|
+
return matches[0]
|
|
179
|
+
|
|
180
|
+
# 3. Fuzzy match on filename
|
|
181
|
+
file_matches = difflib.get_close_matches(target_name, [Path(c).name.lower() for c in candidates], n=1, cutoff=0.55)
|
|
182
|
+
if file_matches:
|
|
183
|
+
for c in candidates:
|
|
184
|
+
if Path(c).name.lower() == file_matches[0]:
|
|
185
|
+
return c
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
|
|
149
189
|
class ToolExecutor:
|
|
150
190
|
"""Executes tool calls for a workspace."""
|
|
151
191
|
|
|
@@ -160,7 +200,7 @@ class ToolExecutor:
|
|
|
160
200
|
if response.tool_calls:
|
|
161
201
|
return response.tool_calls
|
|
162
202
|
|
|
163
|
-
if response.content
|
|
203
|
+
if response.content:
|
|
164
204
|
parsed = parse_text_tool_calls(response.content)
|
|
165
205
|
return [
|
|
166
206
|
ToolCall(
|
|
@@ -215,6 +255,12 @@ class ToolExecutor:
|
|
|
215
255
|
res_str = f"error: {e}"
|
|
216
256
|
if on_tool_end:
|
|
217
257
|
on_tool_end(tc.name, res_str)
|
|
258
|
+
if "file not found" in res_str.lower() and isinstance(tc.arguments, dict):
|
|
259
|
+
req_path = tc.arguments.get("path") or tc.arguments.get("file", "")
|
|
260
|
+
if req_path:
|
|
261
|
+
sugg = suggest_similar_file(self.workdir, req_path)
|
|
262
|
+
if sugg and sugg != req_path:
|
|
263
|
+
res_str += f"\n💡 HINT: Did you mean '{sugg}'? Use that path instead."
|
|
218
264
|
results_map[tc.id] = ToolResult(
|
|
219
265
|
tool_call_id=tc.id, name=tc.name, content=res_str, duration_ms=elapsed
|
|
220
266
|
)
|
|
@@ -270,6 +316,12 @@ class ToolExecutor:
|
|
|
270
316
|
res_str = f"error: {e}"
|
|
271
317
|
if on_tool_end:
|
|
272
318
|
on_tool_end(tc.name, res_str)
|
|
319
|
+
if "file not found" in res_str.lower() and isinstance(tc.arguments, dict):
|
|
320
|
+
req_path = tc.arguments.get("path") or tc.arguments.get("file", "")
|
|
321
|
+
if req_path:
|
|
322
|
+
sugg = suggest_similar_file(self.workdir, req_path)
|
|
323
|
+
if sugg and sugg != req_path:
|
|
324
|
+
res_str += f"\n💡 HINT: Did you mean '{sugg}'? Use that path instead."
|
|
273
325
|
results_map[tc.id] = ToolResult(
|
|
274
326
|
tool_call_id=tc.id, name=tc.name, content=res_str, duration_ms=elapsed
|
|
275
327
|
)
|
|
@@ -68,7 +68,8 @@ def detect_gpu() -> int:
|
|
|
68
68
|
if res.returncode == 0 and res.stdout.strip():
|
|
69
69
|
vram_mb = int(res.stdout.strip().split("\n")[0].strip())
|
|
70
70
|
if vram_mb <= 6500: # 6GB VRAM (e.g. RTX 3050 Laptop)
|
|
71
|
-
|
|
71
|
+
# Full offload: 4.4GB model + 4096 context fits in 6GB VRAM and runs 5x faster than partial offload
|
|
72
|
+
return 33
|
|
72
73
|
return -1 # 8GB+ VRAM — offload all layers
|
|
73
74
|
except Exception:
|
|
74
75
|
pass
|
|
@@ -303,7 +304,19 @@ class LocalModel:
|
|
|
303
304
|
model_path = self.ensure_model()
|
|
304
305
|
Llama = _ensure_llama_cpp()
|
|
305
306
|
|
|
306
|
-
|
|
307
|
+
has_gpu_offload = False
|
|
308
|
+
try:
|
|
309
|
+
import llama_cpp
|
|
310
|
+
has_gpu_offload = getattr(llama_cpp, "llama_supports_gpu_offload", lambda: False)()
|
|
311
|
+
except Exception:
|
|
312
|
+
pass
|
|
313
|
+
|
|
314
|
+
if self.n_gpu_layers != 0 and not has_gpu_offload:
|
|
315
|
+
print(" ⚠️ Warning: Active Python environment has no CUDA llama-cpp support. Running on CPU.")
|
|
316
|
+
accel = "CPU (No CUDA in Python env)"
|
|
317
|
+
self.n_gpu_layers = 0
|
|
318
|
+
else:
|
|
319
|
+
accel = "GPU" if self.n_gpu_layers != 0 else "CPU"
|
|
307
320
|
|
|
308
321
|
# ── Smart P-Core Topology Auto-Tuning ─────────────────────────
|
|
309
322
|
# On hybrid CPUs (Intel 12th+), over-subscribing E-cores causes
|
|
@@ -413,27 +413,74 @@ class AgentOrchestrator:
|
|
|
413
413
|
"commands": session_executed_commands,
|
|
414
414
|
})
|
|
415
415
|
|
|
416
|
-
|
|
417
|
-
# If files were written/modified but no verification was run, nudge the agent
|
|
416
|
+
# ── Component 1: Autonomous Verify-Before-Done Gate ──
|
|
418
417
|
has_code_files = any(
|
|
419
418
|
f.endswith((".py", ".js", ".ts", ".jsx", ".tsx", ".rs", ".go", ".java", ".c", ".cpp"))
|
|
420
419
|
for f in session_touched_files
|
|
421
420
|
)
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if has_code_files and not
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
421
|
+
auto_verify_passed = getattr(session, "_auto_verify_passed", False)
|
|
422
|
+
verify_attempts = getattr(session, "_auto_verify_attempts", 0)
|
|
423
|
+
|
|
424
|
+
if has_code_files and not auto_verify_passed and verify_attempts < 2:
|
|
425
|
+
# 1. In-process Python syntax verification for all modified Python files
|
|
426
|
+
syntax_errs = []
|
|
427
|
+
for tf in session_touched_files:
|
|
428
|
+
if tf.endswith(".py"):
|
|
429
|
+
full_tf = session.workdir / tf
|
|
430
|
+
if full_tf.exists():
|
|
431
|
+
try:
|
|
432
|
+
import ast as _ast
|
|
433
|
+
_ast.parse(full_tf.read_text(encoding="utf-8", errors="replace"))
|
|
434
|
+
except SyntaxError as se:
|
|
435
|
+
syntax_errs.append(f"- '{tf}' line {se.lineno}: {se.msg}")
|
|
436
|
+
|
|
437
|
+
if syntax_errs:
|
|
438
|
+
session._auto_verify_attempts = verify_attempts + 1
|
|
439
|
+
err_msg = "\n".join(syntax_errs)
|
|
440
|
+
session.context.add_user_message(
|
|
441
|
+
f"[AUTO-VERIFICATION FAILED] Syntax error(s) detected in your code:\n{err_msg}\n"
|
|
442
|
+
"Please use edit_file to fix these syntax errors before completing the task."
|
|
443
|
+
)
|
|
444
|
+
yield AgentEvent("verification_failed", {
|
|
445
|
+
"type": "syntax",
|
|
446
|
+
"errors": syntax_errs,
|
|
447
|
+
})
|
|
448
|
+
continue
|
|
449
|
+
|
|
450
|
+
# 2. Automated test verification if project has tests
|
|
451
|
+
if task_context.has_tests and not getattr(session, "_has_run_tests_in_session", False):
|
|
452
|
+
session._auto_verify_attempts = verify_attempts + 1
|
|
453
|
+
session._has_run_tests_in_session = True
|
|
454
|
+
test_cmd = "python3 -m pytest -q --tb=short" if task_context.project_type == "python" else "npm test" if task_context.project_type == "node" else None
|
|
455
|
+
if test_cmd:
|
|
456
|
+
yield AgentEvent("status", {
|
|
457
|
+
"phase": "verifying",
|
|
458
|
+
"message": f"Auto-running verification test suite ({test_cmd})...",
|
|
459
|
+
})
|
|
460
|
+
t_res = session.executor.dispatch.get("bash", lambda a: "")({"command": test_cmd})
|
|
461
|
+
t_exit = 0
|
|
462
|
+
try:
|
|
463
|
+
import json as _json
|
|
464
|
+
t_data = _json.loads(t_res)
|
|
465
|
+
t_exit = t_data.get("exit_code", 0)
|
|
466
|
+
t_out = t_data.get("stdout", "")
|
|
467
|
+
except Exception:
|
|
468
|
+
t_out = str(t_res)
|
|
469
|
+
|
|
470
|
+
if t_exit != 0:
|
|
471
|
+
session.context.add_user_message(
|
|
472
|
+
f"[AUTO-VERIFICATION FAILED] Test suite failed (exit_code={t_exit}) after your changes:\n"
|
|
473
|
+
f"{t_out[:1200]}\n"
|
|
474
|
+
"Please analyze the failure, fix the issue, and verify it passes before finishing."
|
|
475
|
+
)
|
|
476
|
+
yield AgentEvent("verification_failed", {
|
|
477
|
+
"type": "tests",
|
|
478
|
+
"command": test_cmd,
|
|
479
|
+
"exit_code": t_exit,
|
|
480
|
+
})
|
|
481
|
+
continue
|
|
482
|
+
|
|
483
|
+
session._auto_verify_passed = True
|
|
437
484
|
|
|
438
485
|
# Cache if single-turn
|
|
439
486
|
if is_cacheable and turn == 0:
|
|
@@ -24,6 +24,50 @@ class TurnSnapshot:
|
|
|
24
24
|
had_progress: bool = False
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Milestone:
|
|
30
|
+
text: str
|
|
31
|
+
done: bool = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MilestoneTracker:
|
|
35
|
+
"""Tracks progress against markdown task lists (- [ ] or numbered steps)."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, task_text: str = ""):
|
|
38
|
+
self.milestones: List[Milestone] = []
|
|
39
|
+
if task_text:
|
|
40
|
+
self.init_from_text(task_text)
|
|
41
|
+
|
|
42
|
+
def init_from_text(self, text: str) -> None:
|
|
43
|
+
import re
|
|
44
|
+
lines = text.splitlines()
|
|
45
|
+
for line in lines:
|
|
46
|
+
match = re.search(r"^\s*(?:-\s*\[([ xX])\]|\d+\.)\s+(.+)$", line)
|
|
47
|
+
if match:
|
|
48
|
+
is_done = match.group(1) in ("x", "X") if match.group(1) else False
|
|
49
|
+
item_text = match.group(2).strip()
|
|
50
|
+
if len(item_text) > 3:
|
|
51
|
+
self.milestones.append(Milestone(text=item_text, done=is_done))
|
|
52
|
+
|
|
53
|
+
def mark_milestone(self, text_fragment: str, done: bool = True) -> None:
|
|
54
|
+
for m in self.milestones:
|
|
55
|
+
if text_fragment.lower() in m.text.lower():
|
|
56
|
+
m.done = done
|
|
57
|
+
|
|
58
|
+
def get_pending(self) -> List[str]:
|
|
59
|
+
return [m.text for m in self.milestones if not m.done]
|
|
60
|
+
|
|
61
|
+
def format_checklist(self) -> str:
|
|
62
|
+
if not self.milestones:
|
|
63
|
+
return ""
|
|
64
|
+
lines = ["### Active Task Checklist:"]
|
|
65
|
+
for m in self.milestones:
|
|
66
|
+
box = "[x]" if m.done else "[ ]"
|
|
67
|
+
lines.append(f"- {box} {m.text}")
|
|
68
|
+
return "\n".join(lines)
|
|
69
|
+
|
|
70
|
+
|
|
27
71
|
class ProgressMonitor:
|
|
28
72
|
"""Tracks agent progress across turns.
|
|
29
73
|
|
|
@@ -39,6 +83,7 @@ class ProgressMonitor:
|
|
|
39
83
|
self.all_files_created: Set[str] = set()
|
|
40
84
|
self.consecutive_stalls: int = 0
|
|
41
85
|
self.stall_interventions: int = 0
|
|
86
|
+
self.milestones = MilestoneTracker()
|
|
42
87
|
|
|
43
88
|
def record_turn(self, turn: int, tool_calls: list, tool_results: list) -> TurnSnapshot:
|
|
44
89
|
"""Record what happened in a turn and determine if progress was made."""
|
|
@@ -267,9 +267,15 @@ Write the complete code on Turn 1. Verify by running it. Fix if needed.
|
|
|
267
267
|
Rules:
|
|
268
268
|
1. Write clean code using ONLY Python standard library modules.
|
|
269
269
|
2. NEVER run pip, uv pip, or any package install commands — they will fail offline.
|
|
270
|
-
3. For testing, use `
|
|
271
|
-
4.
|
|
272
|
-
|
|
270
|
+
3. For testing, use `python3 -m unittest` (stdlib).
|
|
271
|
+
4. To execute a tool, output a <tool_call> block:
|
|
272
|
+
<tool_call>
|
|
273
|
+
{"name": "write_file", "arguments": {"path": "<file>", "content": "<full_code>"}}
|
|
274
|
+
</tool_call>
|
|
275
|
+
<tool_call>
|
|
276
|
+
{"name": "bash", "arguments": {"command": "<command>"}}
|
|
277
|
+
</tool_call>
|
|
278
|
+
5. Always create the files and run them to verify.
|
|
273
279
|
6. When done and verified, output: DONE: <summary>.
|
|
274
280
|
"""
|
|
275
281
|
|
|
@@ -106,6 +106,10 @@ class PromptRephraser:
|
|
|
106
106
|
if backend is None or not getattr(backend, "is_available", True):
|
|
107
107
|
return task
|
|
108
108
|
|
|
109
|
+
# In offline/local GGUF mode, skip redundant rephraser LLM pass to ensure instant turn-0 start
|
|
110
|
+
if type(backend).__name__ == "LocalBackend" or getattr(backend, "is_offline", False):
|
|
111
|
+
return task
|
|
112
|
+
|
|
109
113
|
try:
|
|
110
114
|
# Inject project context if available so rephrased spec is project-aware
|
|
111
115
|
project_hint = ""
|
|
@@ -348,16 +348,33 @@ def _build_venv_env(workdir: Path) -> dict:
|
|
|
348
348
|
|
|
349
349
|
|
|
350
350
|
def _smart_truncate(text: str, max_chars: int = 6000) -> str:
|
|
351
|
-
"""Truncate long output keeping
|
|
352
|
-
|
|
353
|
-
Compiler errors appear at the top (file:line: error: ...) while the
|
|
354
|
-
bottom often has summary counts. Keeping both ends gives the model
|
|
355
|
-
far better error visibility than tail-only truncation.
|
|
356
|
-
"""
|
|
351
|
+
"""Truncate long output keeping head/tail, and distilling test failures / compiler traces."""
|
|
357
352
|
if len(text) <= max_chars:
|
|
358
353
|
return text
|
|
359
|
-
|
|
360
|
-
|
|
354
|
+
|
|
355
|
+
# High-signal error/failure extraction for pytest, jest, compiler outputs
|
|
356
|
+
failure_markers = ["FAILED", "FAILURES", "ERROR:", "FAIL:", "AssertionError:", "Traceback (most recent call last):"]
|
|
357
|
+
if any(m in text for m in failure_markers):
|
|
358
|
+
lines_list = text.splitlines()
|
|
359
|
+
error_lines = []
|
|
360
|
+
in_error = False
|
|
361
|
+
for line in lines_list:
|
|
362
|
+
if any(m in line for m in failure_markers):
|
|
363
|
+
in_error = True
|
|
364
|
+
if in_error:
|
|
365
|
+
error_lines.append(line)
|
|
366
|
+
if len(error_lines) >= 70:
|
|
367
|
+
break
|
|
368
|
+
if error_lines:
|
|
369
|
+
header = "\n".join(lines_list[:15])
|
|
370
|
+
error_block = "\n".join(error_lines)
|
|
371
|
+
footer = "\n".join(lines_list[-15:])
|
|
372
|
+
distilled = f"{header}\n\n... [build logs distilled] ...\n\n### CRITICAL FAILURE DETAILS:\n{error_block}\n\n### SUMMARY:\n{footer}"
|
|
373
|
+
if len(distilled) <= max_chars:
|
|
374
|
+
return distilled
|
|
375
|
+
|
|
376
|
+
head_size = int(max_chars * 0.6)
|
|
377
|
+
tail_size = max_chars - head_size
|
|
361
378
|
omitted = len(text) - head_size - tail_size
|
|
362
379
|
return (
|
|
363
380
|
text[:head_size]
|
|
@@ -366,7 +383,6 @@ def _smart_truncate(text: str, max_chars: int = 6000) -> str:
|
|
|
366
383
|
)
|
|
367
384
|
|
|
368
385
|
|
|
369
|
-
|
|
370
386
|
def _extract_script_path(command: str) -> str:
|
|
371
387
|
"""Extract the script file path from a bash command, if any."""
|
|
372
388
|
import re as _re
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for Claude Code and Codex caliber capabilities added to AlpieCode:
|
|
3
|
+
1. Fast AST Codebase Map (Symbol indexing)
|
|
4
|
+
2. Self-Healing File Suggestions
|
|
5
|
+
3. Smart Failure Distillation in Output Truncation
|
|
6
|
+
4. Milestone & Todo Checklist Tracking
|
|
7
|
+
5. Autonomous Verify-Before-Done Gate
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from codeagent.discovery import build_symbol_index, TaskContext
|
|
14
|
+
from codeagent.executor import suggest_similar_file
|
|
15
|
+
from codeagent.tools import _smart_truncate
|
|
16
|
+
from codeagent.progress import MilestoneTracker, ProgressMonitor
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_ast_symbol_index_extraction(tmp_path: Path):
|
|
20
|
+
"""Verify that build_symbol_index accurately extracts classes, methods, and functions."""
|
|
21
|
+
sample_file = tmp_path / "sample.py"
|
|
22
|
+
sample_file.write_text(
|
|
23
|
+
"class OrderService:\n"
|
|
24
|
+
" def create_order(self, item_id, qty):\n"
|
|
25
|
+
" pass\n"
|
|
26
|
+
" def cancel_order(self, order_id):\n"
|
|
27
|
+
" pass\n\n"
|
|
28
|
+
"def calculate_tax(subtotal, rate=0.05):\n"
|
|
29
|
+
" return subtotal * rate\n",
|
|
30
|
+
encoding="utf-8"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
symbols = build_symbol_index(tmp_path)
|
|
34
|
+
assert "sample.py" in symbols
|
|
35
|
+
syms = symbols["sample.py"]
|
|
36
|
+
assert any("class OrderService" in s for s in syms)
|
|
37
|
+
assert any("def calculate_tax" in s for s in syms)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_suggest_similar_file(tmp_path: Path):
|
|
41
|
+
"""Verify fuzzy file-not-found suggestion matches close typos."""
|
|
42
|
+
src_dir = tmp_path / "src"
|
|
43
|
+
src_dir.mkdir(parents=True)
|
|
44
|
+
real_file = src_dir / "user_repository.py"
|
|
45
|
+
real_file.write_text("# user repo", encoding="utf-8")
|
|
46
|
+
|
|
47
|
+
# Typo: user_repo.py instead of user_repository.py
|
|
48
|
+
suggestion = suggest_similar_file(tmp_path, "src/user_repo.py")
|
|
49
|
+
assert suggestion == "src/user_repository.py"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_smart_truncate_preserves_failures():
|
|
53
|
+
"""Verify _smart_truncate preserves the exact failure traceback when truncating large logs."""
|
|
54
|
+
noisy_prefix = "PASS test_001.py\n" * 300
|
|
55
|
+
critical_failure = (
|
|
56
|
+
"FAILED test_auth.py::test_login - AssertionError: Status code 401 != 200\n"
|
|
57
|
+
"Traceback (most recent call last):\n"
|
|
58
|
+
" File 'test_auth.py', line 45, in test_login\n"
|
|
59
|
+
" assert response.status_code == 200\n"
|
|
60
|
+
)
|
|
61
|
+
noisy_suffix = "PASS test_100.py\n" * 300
|
|
62
|
+
summary = "=== 1 failed, 600 passed in 4.25s ==="
|
|
63
|
+
|
|
64
|
+
full_log = f"{noisy_prefix}\n{critical_failure}\n{noisy_suffix}\n{summary}"
|
|
65
|
+
assert len(full_log) > 6000
|
|
66
|
+
|
|
67
|
+
truncated = _smart_truncate(full_log, max_chars=3000)
|
|
68
|
+
assert len(truncated) <= 3000
|
|
69
|
+
assert "CRITICAL FAILURE DETAILS" in truncated
|
|
70
|
+
assert "AssertionError: Status code 401 != 200" in truncated
|
|
71
|
+
assert "test_auth.py" in truncated
|
|
72
|
+
assert summary in truncated
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_milestone_tracker():
|
|
76
|
+
"""Verify milestone checklist tracking for planned tasks."""
|
|
77
|
+
plan_markdown = (
|
|
78
|
+
"Here is the plan:\n"
|
|
79
|
+
"- [ ] Step 1: Create database schema\n"
|
|
80
|
+
"- [ ] Step 2: Implement REST endpoints\n"
|
|
81
|
+
"- [x] Step 3: Set up virtualenv\n"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
tracker = MilestoneTracker(plan_markdown)
|
|
85
|
+
assert len(tracker.milestones) == 3
|
|
86
|
+
assert tracker.milestones[0].done is False
|
|
87
|
+
assert tracker.milestones[2].done is True
|
|
88
|
+
|
|
89
|
+
# Mark step 1 done
|
|
90
|
+
tracker.mark_milestone("database schema", done=True)
|
|
91
|
+
assert tracker.milestones[0].done is True
|
|
92
|
+
|
|
93
|
+
pending = tracker.get_pending()
|
|
94
|
+
assert len(pending) == 1
|
|
95
|
+
assert "REST endpoints" in pending[0]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_auto_verification_syntax_detection(tmp_path: Path):
|
|
99
|
+
"""Verify that auto-verification catches syntax errors in modified Python files."""
|
|
100
|
+
import ast
|
|
101
|
+
bad_file = tmp_path / "broken.py"
|
|
102
|
+
bad_file.write_text("def broken(\n return 42", encoding="utf-8")
|
|
103
|
+
|
|
104
|
+
syntax_errs = []
|
|
105
|
+
try:
|
|
106
|
+
ast.parse(bad_file.read_text(encoding="utf-8"))
|
|
107
|
+
except SyntaxError as se:
|
|
108
|
+
syntax_errs.append(f"- '{bad_file.name}' line {se.lineno}: {se.msg}")
|
|
109
|
+
|
|
110
|
+
assert len(syntax_errs) == 1
|
|
111
|
+
assert "broken.py" in syntax_errs[0]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|