alpiecode 9.0.2__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.
Files changed (48) hide show
  1. {alpiecode-9.0.2 → alpiecode-9.0.4}/PKG-INFO +1 -1
  2. {alpiecode-9.0.2 → alpiecode-9.0.4}/pyproject.toml +1 -1
  3. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/alpiecode.egg-info/PKG-INFO +1 -1
  4. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/alpiecode.egg-info/SOURCES.txt +1 -0
  5. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/__init__.py +1 -1
  6. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/backends/openai_backend.py +1 -0
  7. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/config.py +16 -0
  8. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/discovery.py +84 -2
  9. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/executor.py +57 -5
  10. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/local_model.py +15 -2
  11. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/orchestrator.py +64 -17
  12. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/progress.py +45 -0
  13. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/prompt.py +9 -3
  14. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/rephraser.py +11 -2
  15. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/tools.py +90 -26
  16. alpiecode-9.0.4/test/test_claude_codex_capabilities.py +111 -0
  17. {alpiecode-9.0.2 → alpiecode-9.0.4}/README.md +0 -0
  18. {alpiecode-9.0.2 → alpiecode-9.0.4}/setup.cfg +0 -0
  19. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/alpiecode/__init__.py +0 -0
  20. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/alpiecode.egg-info/dependency_links.txt +0 -0
  21. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/alpiecode.egg-info/entry_points.txt +0 -0
  22. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/alpiecode.egg-info/requires.txt +0 -0
  23. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/alpiecode.egg-info/top_level.txt +0 -0
  24. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/agent.py +0 -0
  25. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/backends/__init__.py +0 -0
  26. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/backends/base.py +0 -0
  27. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/backends/local_backend.py +0 -0
  28. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/cache.py +0 -0
  29. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/cli.py +0 -0
  30. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/client.py +0 -0
  31. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/compaction.py +0 -0
  32. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/context.py +0 -0
  33. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/doctor.py +0 -0
  34. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/extension/alpiecode.vsix +0 -0
  35. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/git_ops.py +0 -0
  36. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/github.py +0 -0
  37. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/guardian.py +0 -0
  38. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/guardrails.py +0 -0
  39. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/ipython_ext.py +0 -0
  40. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/media.py +0 -0
  41. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/memory.py +0 -0
  42. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/server.py +0 -0
  43. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/session.py +0 -0
  44. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/updater.py +0 -0
  45. {alpiecode-9.0.2 → alpiecode-9.0.4}/src/codeagent/vscode_installer.py +0 -0
  46. {alpiecode-9.0.2 → alpiecode-9.0.4}/test/test_phase1.py +0 -0
  47. {alpiecode-9.0.2 → alpiecode-9.0.4}/test/test_phase2.py +0 -0
  48. {alpiecode-9.0.2 → alpiecode-9.0.4}/test/test_phase3.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpiecode
3
- Version: 9.0.2
3
+ Version: 9.0.4
4
4
  Summary: AlpieCode — Autonomous AI Coding Agent CLI powered by local 169Pi GGUF VLM
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: huggingface_hub>=0.20.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "alpiecode"
7
- version = "9.0.2"
7
+ version = "9.0.4"
8
8
  description = "AlpieCode — Autonomous AI Coding Agent CLI powered by local 169Pi GGUF VLM"
9
9
  requires-python = ">=3.9"
10
10
  dependencies = [
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpiecode
3
- Version: 9.0.2
3
+ Version: 9.0.4
4
4
  Summary: AlpieCode — Autonomous AI Coding Agent CLI powered by local 169Pi GGUF VLM
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: huggingface_hub>=0.20.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.2"
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."""
@@ -196,6 +196,7 @@ class OpenAIBackend:
196
196
  "max_tokens": m_tokens,
197
197
  "extra_body": {"chat_template_kwargs": {"enable_thinking": enable_thinking}},
198
198
  "stream": True,
199
+ "timeout": 45.0,
199
200
  }
200
201
  if tools:
201
202
  params["tools"] = tools
@@ -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
@@ -442,11 +443,26 @@ def compute_complexity(intent: str, repo_info: dict, task: str) -> str:
442
443
  if intent == "qa":
443
444
  return "qa"
444
445
 
445
- task_lower = task.lower()
446
+ task_lower = task.lower().strip()
446
447
  file_count = repo_info.get("file_count", 0)
447
448
  frameworks = repo_info.get("frameworks", [])
448
449
  project_type = repo_info.get("project_type", "empty")
449
450
 
451
+ # Standalone algorithms / simple scripts: keep 'low' complexity so they
452
+ # complete in 1-2 turns without creating multi-file architectures or plans.
453
+ standalone_keywords = [
454
+ "palindrome", "fibonacci", "quicksort", "bubblesort",
455
+ "binary search", "factorial", "hello world", "calculator",
456
+ "is_palindrome", "check palindrome", "reverse string",
457
+ "prime number", "anagram", "todo script", "simple script",
458
+ ]
459
+ project_terms = [
460
+ "this project", "this repo", "our repo", "our project", "existing",
461
+ "endpoint", "route", "database", "migration", "service", "component",
462
+ ]
463
+ if any(kw in task_lower for kw in standalone_keywords) and not any(pt in task_lower for pt in project_terms):
464
+ return "low"
465
+
450
466
  # -- HIGH: explicitly complex multi-component tasks --
451
467
  high_keywords = [
452
468
  "full stack", "fullstack", "full-stack",
@@ -584,6 +600,22 @@ def gather_relevant_context(
584
600
  for ep in task_context.entry_points[:3]:
585
601
  _add_file(ep, f"{ep} (entry point)")
586
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
+
587
619
  # -- Priority 3: Files mentioned in the task (fuzzy match) --
588
620
  task_lower = task.lower()
589
621
  try:
@@ -614,6 +646,48 @@ def gather_relevant_context(
614
646
 
615
647
  # -- Main Entry Point -------------------------------------------------------
616
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
+
617
691
  def build_task_context(task: str, workdir: Path) -> TaskContext:
618
692
  """Build complete TaskContext by running all discovery phases.
619
693
 
@@ -635,7 +709,15 @@ def build_task_context(task: str, workdir: Path) -> TaskContext:
635
709
  # Phase 5: Budget from complexity
636
710
  budget = COMPLEXITY_CONFIG[complexity]
637
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
+
638
719
  return TaskContext(
720
+ symbol_index=symbol_index,
639
721
  # Intent
640
722
  intent=intent,
641
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 JSON blocks inside <tool_call> or ```json
43
- json_matches = re.findall(r"(?:<tool_call>|```json)\s*(\{.*?\})\s*(?:</tool_call>|```|$)", text, re.DOTALL)
44
- for jm in json_matches:
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 = json.loads(jm.strip())
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 and "<tool_call>" in 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
- return 26 # Partial offload — keep 2GB VRAM free
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
- accel = "GPU" if self.n_gpu_layers != 0 else "CPU"
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
- # ── Soft Verification Gate ──
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
- has_successful_verify = any(
423
- c.get("exit_code", -1) == 0 for c in session_executed_commands
424
- )
425
- if has_code_files and not has_successful_verify and not verification_nudged:
426
- verification_nudged = True
427
- session.context.add_user_message(
428
- "[SYSTEM - VERIFICATION] You created/modified code files but did not run "
429
- "any verification command. Please run a quick syntax check or test before finishing. "
430
- "Then output DONE: <summary>."
431
- )
432
- yield AgentEvent("verification_nudge", {
433
- "files": sorted(list(session_touched_files)),
434
- "message": "Nudging agent to verify before completing",
435
- })
436
- continue
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 `python -m unittest` (stdlib). NEVER use pytest.
271
- 4. Create files with write_file, edit with edit_file, run with bash.
272
- 5. After writing code, always run to verify.
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
 
@@ -18,7 +18,10 @@ Your job is to transform the user's raw prompt into a clear, solid, and unambigu
18
18
  Guidelines:
19
19
  1. If the prompt is a simple Q&A, greeting, or direct conceptual question (e.g. "what is X?", "explain Y", "hi", "how does Z work"):
20
20
  - Keep it clean, direct, and concise. Do NOT add unnecessary software development boilerplate.
21
- 2. If the prompt is a coding, debugging, refactoring, testing, or feature creation task:
21
+ 2. If the prompt is a standalone algorithm, utility, or single-file script (e.g. "check palindrome", "fibonacci", "calculator", "sort"):
22
+ - Keep the specification strictly focused on a SINGLE runnable executable script (e.g. `palindrome.py`).
23
+ - Do NOT turn it into a multi-file architecture, microservice, or web framework (do NOT add FastAPI, Flask, Pydantic, Docker, or separate schemas/models/utils unless the user explicitly requested a web service).
24
+ 3. If the prompt is a coding, debugging, refactoring, testing, or feature creation task:
22
25
  - Expand it into a solid, structured engineering prompt with:
23
26
  * Objective: Explicit statement of what needs to be created, modified, or fixed
24
27
  * Scope & Architecture: Target files, patterns, and module structure
@@ -103,10 +106,16 @@ class PromptRephraser:
103
106
  if backend is None or not getattr(backend, "is_available", True):
104
107
  return task
105
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
+
106
113
  try:
107
114
  # Inject project context if available so rephrased spec is project-aware
108
115
  project_hint = ""
109
- if task_context:
116
+ standalone_kws = ["palindrome", "fibonacci", "quicksort", "bubblesort", "factorial", "hello world", "calculator", "reverse string"]
117
+ is_standalone = any(kw in task.lower() for kw in standalone_kws)
118
+ if task_context and not is_standalone:
110
119
  _fw = ", ".join(getattr(task_context, "frameworks", []) or []) or "none"
111
120
  _pt = getattr(task_context, "project_type", "unknown")
112
121
  _fc = getattr(task_context, "file_count", 0)
@@ -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 both head and tail (where errors usually are).
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
- head_size = int(max_chars * 0.6) # 60% head — where real errors live
360
- tail_size = max_chars - head_size # 40% tail — summaries and counts
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
@@ -853,34 +869,82 @@ def _clone_repo(workdir: Path, repo_url: str, branch: str = None) -> str:
853
869
  # ── Dispatch factory ──────────────────────────────────────────────────
854
870
 
855
871
  def make_dispatch(workdir: Path):
856
- """Bind tool implementations to a specific working directory."""
872
+ """Bind tool implementations to a specific working directory with resilient parameter extraction."""
873
+
874
+ def _safe_bash(a: dict) -> str:
875
+ cmd = a.get("command") or a.get("cmd") or a.get("script") or a.get("shell_command") or ""
876
+ if not str(cmd).strip():
877
+ return "error: 'command' parameter is required and cannot be empty"
878
+ return _bash(workdir, str(cmd))
879
+
880
+ def _safe_read_file(a: dict) -> str:
881
+ path = a.get("path") or a.get("file") or a.get("filepath") or a.get("filename") or ""
882
+ if not str(path).strip():
883
+ return "error: 'path' parameter is required for read_file"
884
+ return _read_file(workdir, str(path), a.get("start_line"), a.get("end_line"))
885
+
886
+ def _safe_write_file(a: dict) -> str:
887
+ path = a.get("path") or a.get("file") or a.get("filepath") or a.get("filename") or ""
888
+ if not str(path).strip():
889
+ return "error: 'path' parameter is required for write_file"
890
+ content = None
891
+ for k in ("content", "code", "text", "body", "file_content", "data", "contents"):
892
+ if k in a and a[k] is not None:
893
+ content = str(a[k])
894
+ break
895
+ if content is None:
896
+ return f"error: 'content' parameter is required for write_file to '{path}'. Please provide the file content."
897
+ return _write_file(workdir, str(path), content)
898
+
899
+ def _safe_edit_file(a: dict) -> str:
900
+ path = a.get("path") or a.get("file") or a.get("filepath") or a.get("filename") or ""
901
+ if not str(path).strip():
902
+ return "error: 'path' parameter is required for edit_file"
903
+ old_str = a.get("old_str") if a.get("old_str") is not None else a.get("old_string") if a.get("old_string") is not None else a.get("target")
904
+ if old_str is None:
905
+ return "error: 'old_str' parameter is required for edit_file (the exact string to replace)"
906
+ new_str = a.get("new_str") if a.get("new_str") is not None else a.get("new_string") if a.get("new_string") is not None else a.get("replacement") if a.get("replacement") is not None else a.get("code") if a.get("code") is not None else ""
907
+ return _edit_file(workdir, str(path), str(old_str), str(new_str))
908
+
909
+ def _safe_file_search(a: dict) -> str:
910
+ pattern = a.get("pattern") or a.get("query") or a.get("regex") or ""
911
+ if not str(pattern).strip():
912
+ return "error: 'pattern' parameter is required for file_search"
913
+ return _file_search(workdir, str(pattern), a.get("path", "."),
914
+ a.get("include"), a.get("case_insensitive", False))
915
+
916
+ def _safe_update_plan(a: dict) -> str:
917
+ plan = a.get("plan") or a.get("content") or a.get("steps") or a.get("summary") or ""
918
+ if not str(plan).strip():
919
+ return "error: 'plan' parameter is required for update_plan"
920
+ return _update_plan(workdir, str(plan))
921
+
857
922
  return {
858
- "bash": lambda a: _bash(workdir, a["command"]),
859
- "read_file": lambda a: _read_file(workdir, a["path"], a.get("start_line"), a.get("end_line")),
860
- "write_file": lambda a: _write_file(workdir, a["path"], a["content"]),
861
- "edit_file": lambda a: _edit_file(workdir, a["path"], a["old_str"], a["new_str"]),
862
- "list_files": lambda a: _list_files(workdir, a.get("path", "."), a.get("max_depth", 4)),
863
- "file_search": lambda a: _file_search(workdir, a["pattern"], a.get("path", "."),
864
- a.get("include"), a.get("case_insensitive", False)),
865
- "apply_patch": lambda a: _apply_patch(workdir, a["path"], a["patch"]),
866
- "web_search": lambda a: _web_search(a["query"], a.get("num_results", 5)),
867
- "fetch_url": lambda a: _fetch_url(a["url"]),
868
- "request_user_input": lambda a: _request_user_input(a["question"]),
869
- "update_plan": lambda a: _update_plan(workdir, a["plan"]),
870
- "view_image": lambda a: _view_image(workdir, a["path"]),
923
+ "bash": _safe_bash,
924
+ "read_file": _safe_read_file,
925
+ "write_file": _safe_write_file,
926
+ "edit_file": _safe_edit_file,
927
+ "list_files": lambda a: _list_files(workdir, a.get("path") or a.get("dir") or ".", a.get("max_depth", 4)),
928
+ "file_search": _safe_file_search,
929
+ "apply_patch": lambda a: _apply_patch(workdir, a.get("path") or a.get("file", ""), a.get("patch") or a.get("diff", "")),
930
+ "web_search": lambda a: _web_search(a.get("query") or a.get("q", ""), a.get("num_results", 5)),
931
+ "fetch_url": lambda a: _fetch_url(a.get("url") or a.get("uri", "")),
932
+ "request_user_input": lambda a: _request_user_input(a.get("question") or a.get("prompt", "")),
933
+ "update_plan": _safe_update_plan,
934
+ "view_image": lambda a: _view_image(workdir, a.get("path") or a.get("file", "")),
871
935
  "github_issues": lambda a: _github_issues(
872
- a["owner"], a["repo"],
936
+ a.get("owner", ""), a.get("repo", ""),
873
937
  issue_number=a.get("issue_number"),
874
938
  state=a.get("state", "open"),
875
939
  max_results=a.get("max_results", 10),
876
940
  ),
877
941
  "github_browse": lambda a: _github_browse(
878
- a["owner"], a["repo"],
942
+ a.get("owner", ""), a.get("repo", ""),
879
943
  path=a.get("path", ""),
880
944
  info_only=a.get("info_only", False),
881
945
  ),
882
946
  "clone_repo": lambda a: _clone_repo(
883
- workdir, a["repo_url"],
947
+ workdir, a.get("repo_url") or a.get("url", ""),
884
948
  branch=a.get("branch"),
885
949
  ),
886
950
  }
@@ -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