alpiecode 8.0.0__tar.gz → 8.0.2__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 (44) hide show
  1. {alpiecode-8.0.0 → alpiecode-8.0.2}/PKG-INFO +1 -1
  2. {alpiecode-8.0.0 → alpiecode-8.0.2}/pyproject.toml +1 -1
  3. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/alpiecode.egg-info/PKG-INFO +1 -1
  4. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/alpiecode.egg-info/SOURCES.txt +1 -0
  5. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/__init__.py +1 -1
  6. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/backends/openai_backend.py +35 -2
  7. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/context.py +27 -0
  8. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/executor.py +94 -53
  9. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/extension/alpiecode.vsix +0 -0
  10. alpiecode-8.0.2/src/codeagent/guardrails.py +135 -0
  11. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/orchestrator.py +86 -1
  12. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/prompt.py +4 -0
  13. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/tools.py +74 -28
  14. {alpiecode-8.0.0 → alpiecode-8.0.2}/README.md +0 -0
  15. {alpiecode-8.0.0 → alpiecode-8.0.2}/setup.cfg +0 -0
  16. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/alpiecode/__init__.py +0 -0
  17. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/alpiecode.egg-info/dependency_links.txt +0 -0
  18. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/alpiecode.egg-info/entry_points.txt +0 -0
  19. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/alpiecode.egg-info/requires.txt +0 -0
  20. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/alpiecode.egg-info/top_level.txt +0 -0
  21. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/agent.py +0 -0
  22. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/backends/__init__.py +0 -0
  23. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/backends/base.py +0 -0
  24. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/backends/local_backend.py +0 -0
  25. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/cache.py +0 -0
  26. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/cli.py +0 -0
  27. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/client.py +0 -0
  28. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/compaction.py +0 -0
  29. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/config.py +0 -0
  30. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/discovery.py +0 -0
  31. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/doctor.py +0 -0
  32. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/git_ops.py +0 -0
  33. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/github.py +0 -0
  34. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/guardian.py +0 -0
  35. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/ipython_ext.py +0 -0
  36. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/local_model.py +0 -0
  37. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/media.py +0 -0
  38. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/memory.py +0 -0
  39. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/progress.py +0 -0
  40. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/rephraser.py +0 -0
  41. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/server.py +0 -0
  42. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/session.py +0 -0
  43. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/updater.py +0 -0
  44. {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/vscode_installer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpiecode
3
- Version: 8.0.0
3
+ Version: 8.0.2
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 = "8.0.0"
7
+ version = "8.0.2"
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: 8.0.0
3
+ Version: 8.0.2
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
@@ -21,6 +21,7 @@ src/codeagent/executor.py
21
21
  src/codeagent/git_ops.py
22
22
  src/codeagent/github.py
23
23
  src/codeagent/guardian.py
24
+ src/codeagent/guardrails.py
24
25
  src/codeagent/ipython_ext.py
25
26
  src/codeagent/local_model.py
26
27
  src/codeagent/media.py
@@ -16,7 +16,7 @@ Supports:
16
16
  from pathlib import Path
17
17
  from typing import Optional, Any, Dict
18
18
 
19
- __version__ = "7.0.7"
19
+ __version__ = "8.0.1"
20
20
 
21
21
 
22
22
  def run(task: str, workdir: str = ".", reasoning_level: str = "thinking", **kwargs):
@@ -155,6 +155,18 @@ class OpenAIBackend:
155
155
  if after.strip():
156
156
  full_content.append(after)
157
157
  yield ("token", {"delta": after})
158
+ elif "DONE:" in text:
159
+ # Model output DONE: directly inside thinking without </think> tag
160
+ before, after = text.split("DONE:", 1)
161
+ if before:
162
+ full_reasoning.append(before)
163
+ yield ("thinking_delta", {"delta": before})
164
+ in_think = False
165
+ elapsed = time.time() - start_time
166
+ yield ("thinking_end", {"duration": round(elapsed, 1), "content": "".join(full_reasoning).strip()})
167
+ done_chunk = "DONE:" + after
168
+ full_content.append(done_chunk)
169
+ yield ("token", {"delta": done_chunk})
158
170
  else:
159
171
  clean_text = text.replace("<think>", "")
160
172
  full_reasoning.append(clean_text)
@@ -179,8 +191,29 @@ class OpenAIBackend:
179
191
  args = {}
180
192
  tool_calls.append(ToolCall(id=item["id"], name=item["name"], arguments=args))
181
193
 
182
- final_content = "".join(full_content).strip()
183
- final_reasoning = "".join(full_reasoning).strip() if full_reasoning else None
194
+ final_content_str = "".join(full_content).strip()
195
+ final_reasoning_str = "".join(full_reasoning).strip()
196
+
197
+ # CRITICAL RECOVERY: If content is empty but reasoning has text,
198
+ # extract any DONE: or answer that was mistakenly trapped in reasoning!
199
+ if not final_content_str and final_reasoning_str:
200
+ if "DONE:" in final_reasoning_str:
201
+ idx = final_reasoning_str.find("DONE:")
202
+ final_content_str = final_reasoning_str[idx:].strip()
203
+ final_reasoning_str = final_reasoning_str[:idx].strip()
204
+ yield ("token", {"delta": final_content_str})
205
+ elif "</think>" in final_reasoning_str:
206
+ parts = final_reasoning_str.split("</think>", 1)
207
+ final_reasoning_str = parts[0].strip()
208
+ final_content_str = parts[1].strip()
209
+ yield ("token", {"delta": final_content_str})
210
+ elif any(m in final_reasoning_str for m in ["The codebase is complete", "All JavaScript files", "I have implemented", "Verified the", "All files"]):
211
+ final_content_str = final_reasoning_str
212
+ final_reasoning_str = ""
213
+ yield ("token", {"delta": final_content_str})
214
+
215
+ final_content = final_content_str
216
+ final_reasoning = final_reasoning_str if final_reasoning_str else None
184
217
 
185
218
  yield ("done", ChatResponse(
186
219
  content=final_content,
@@ -39,10 +39,37 @@ class ContextManager:
39
39
  self.max_tokens = max_tokens
40
40
  self._messages: List[dict] = []
41
41
 
42
+ def rolling_compact_old_tools(self, keep_last_turns: int = 3) -> None:
43
+ """Truncate large historical tool outputs from turns older than keep_last_turns."""
44
+ assistant_indices = [
45
+ i for i, m in enumerate(self._messages)
46
+ if isinstance(m, dict) and m.get("role") == "assistant"
47
+ ]
48
+ if len(assistant_indices) <= keep_last_turns:
49
+ return
50
+
51
+ cutoff_idx = assistant_indices[-keep_last_turns]
52
+ for i in range(cutoff_idx):
53
+ msg = self._messages[i]
54
+ if isinstance(msg, dict) and msg.get("role") == "tool":
55
+ content = str(msg.get("content", ""))
56
+ # Never truncate update_plan output
57
+ if "[Plan updated]" in content or len(content) <= 300:
58
+ continue
59
+ lines = content.splitlines()
60
+ if len(lines) > 8:
61
+ preview_start = "\n".join(lines[:3])
62
+ preview_end = "\n".join(lines[-2:])
63
+ msg["content"] = f"[Output: {len(lines)} lines truncated for brevity]\n{preview_start}\n...\n{preview_end}"
64
+ else:
65
+ msg["content"] = content[:150] + f"... [truncated {len(content)} chars]"
66
+
42
67
  @property
43
68
  def messages(self) -> List[dict]:
69
+ self.rolling_compact_old_tools(keep_last_turns=3)
44
70
  return self._messages
45
71
 
72
+
46
73
  @messages.setter
47
74
  def messages(self, msgs: List[dict]) -> None:
48
75
  self._messages = msgs
@@ -171,62 +171,103 @@ class ToolExecutor:
171
171
  tool_call_id=tc.id, name=tc.name, content=res_str, duration_ms=elapsed
172
172
  )
173
173
 
174
- # Stage 2: Sequential mutating tools (write_file, edit_file, bash)
175
- for tc in mut_calls:
176
- if on_tool_start:
177
- on_tool_start(tc.name, tc.arguments)
178
- t0 = time.monotonic()
179
- safe_args = tc.arguments if isinstance(tc.arguments, dict) else {}
180
- fn = self.dispatch.get(tc.name)
181
- if fn:
182
- try:
183
- res_str = str(fn(safe_args))
184
- except Exception as e:
185
- res_str = f"error: {e}"
186
- else:
187
- res_str = f"error: Unknown tool '{tc.name}'"
188
- elapsed = (time.monotonic() - t0) * 1000
189
-
190
- # Tool loop detection guard
191
- call_sig = (tc.name, json.dumps(tc.arguments, sort_keys=True))
192
- self.tool_call_history.append(call_sig)
193
- repeat_count = sum(1 for item in self.tool_call_history[-5:] if item == call_sig)
194
-
195
- if repeat_count >= 3:
196
- res_str += (
197
- f"\n\n🛑 REPEATED TOOL CALL LOOP DETECTED (attempt #{repeat_count}). "
198
- f"You have already executed '{tc.name}' with these exact parameters {repeat_count} times in a row. "
199
- "All checks have passed. Do NOT run this tool again. Output your final summary starting with: DONE: <summary>."
200
- )
174
+ # Stage 2: Mutating tools (parallel write_file if distinct paths, otherwise sequential)
175
+ all_writes = all(tc.name == "write_file" for tc in mut_calls)
176
+ paths = [
177
+ tc.arguments.get("path") if isinstance(tc.arguments, dict) else None
178
+ for tc in mut_calls
179
+ ]
180
+ can_parallel_write = (
181
+ all_writes
182
+ and len(mut_calls) > 1
183
+ and len(paths) == len(set(paths))
184
+ and None not in paths
185
+ )
186
+
187
+ if can_parallel_write:
188
+ with ThreadPoolExecutor(max_workers=min(4, len(mut_calls))) as pool:
189
+ future_to_tc = {}
190
+ for tc in mut_calls:
191
+ if on_tool_start:
192
+ on_tool_start(tc.name, tc.arguments)
193
+ t0 = time.monotonic()
194
+ safe_args = tc.arguments if isinstance(tc.arguments, dict) else {}
195
+ fn = self.dispatch.get(tc.name)
196
+ if fn:
197
+ future = pool.submit(fn, safe_args)
198
+ else:
199
+ future = pool.submit(lambda: f"error: Unknown tool '{tc.name}'")
200
+ future_to_tc[future] = (tc, t0)
201
201
 
202
- # Compilation failure recovery hint
203
- if tc.name == "bash":
204
- cmd = tc.arguments.get("command", "") if isinstance(tc.arguments, dict) else ""
205
- is_compile = any(kw in cmd for kw in ["g++", "gcc", "clang", "make", "cmake", "cargo build", "rustc"])
206
- if is_compile and "exit_code" in str(res_str):
202
+ for future in as_completed(future_to_tc):
203
+ tc, t0 = future_to_tc[future]
204
+ elapsed = (time.monotonic() - t0) * 1000
207
205
  try:
208
- result_data = json.loads(res_str.split("\n", 1)[-1] if res_str.startswith("⚠️") else res_str)
209
- if result_data.get("exit_code", 0) != 0:
210
- compile_key = cmd.strip()
211
- self.compile_fail_counts[compile_key] = self.compile_fail_counts.get(compile_key, 0) + 1
212
- if self.compile_fail_counts[compile_key] >= 3:
213
- res_str += (
214
- "\n\n🛑 REPEATED COMPILATION FAILURE (attempt "
215
- f"#{self.compile_fail_counts[compile_key]}). "
216
- "STOP making blind edits. Re-read the ENTIRE source file with "
217
- "read_file to understand its full structure, then fix ALL errors "
218
- "comprehensively in one edit."
219
- )
220
- else:
221
- self.compile_fail_counts.pop(cmd.strip(), None)
222
- except (json.JSONDecodeError, ValueError):
223
- pass
206
+ res_str = str(future.result())
207
+ except Exception as e:
208
+ res_str = f"error: {e}"
209
+ if on_tool_end:
210
+ on_tool_end(tc.name, res_str)
211
+ results_map[tc.id] = ToolResult(
212
+ tool_call_id=tc.id, name=tc.name, content=res_str, duration_ms=elapsed
213
+ )
214
+ else:
215
+ # Stage 2: Sequential mutating tools (write_file, edit_file, bash)
216
+ for tc in mut_calls:
217
+ if on_tool_start:
218
+ on_tool_start(tc.name, tc.arguments)
219
+ t0 = time.monotonic()
220
+ safe_args = tc.arguments if isinstance(tc.arguments, dict) else {}
221
+ fn = self.dispatch.get(tc.name)
222
+ if fn:
223
+ try:
224
+ res_str = str(fn(safe_args))
225
+ except Exception as e:
226
+ res_str = f"error: {e}"
227
+ else:
228
+ res_str = f"error: Unknown tool '{tc.name}'"
229
+ elapsed = (time.monotonic() - t0) * 1000
230
+
231
+ # Tool loop detection guard
232
+ call_sig = (tc.name, json.dumps(tc.arguments, sort_keys=True))
233
+ self.tool_call_history.append(call_sig)
234
+ repeat_count = sum(1 for item in self.tool_call_history[-5:] if item == call_sig)
235
+
236
+ if repeat_count >= 3:
237
+ res_str += (
238
+ f"\n\n🛑 REPEATED TOOL CALL LOOP DETECTED (attempt #{repeat_count}). "
239
+ f"You have already executed '{tc.name}' with these exact parameters {repeat_count} times in a row. "
240
+ "All checks have passed. Do NOT run this tool again. Output your final summary starting with: DONE: <summary>."
241
+ )
224
242
 
225
- if on_tool_end:
226
- on_tool_end(tc.name, res_str)
227
- results_map[tc.id] = ToolResult(
228
- tool_call_id=tc.id, name=tc.name, content=res_str, duration_ms=elapsed
229
- )
243
+ # Compilation failure recovery hint
244
+ if tc.name == "bash":
245
+ cmd = tc.arguments.get("command", "") if isinstance(tc.arguments, dict) else ""
246
+ is_compile = any(kw in cmd for kw in ["g++", "gcc", "clang", "make", "cmake", "cargo build", "rustc"])
247
+ if is_compile and "exit_code" in str(res_str):
248
+ try:
249
+ result_data = json.loads(res_str.split("\n", 1)[-1] if res_str.startswith("⚠️") else res_str)
250
+ if result_data.get("exit_code", 0) != 0:
251
+ compile_key = cmd.strip()
252
+ self.compile_fail_counts[compile_key] = self.compile_fail_counts.get(compile_key, 0) + 1
253
+ if self.compile_fail_counts[compile_key] >= 3:
254
+ res_str += (
255
+ "\n\n🛑 REPEATED COMPILATION FAILURE (attempt "
256
+ f"#{self.compile_fail_counts[compile_key]}). "
257
+ "STOP making blind edits. Re-read the ENTIRE source file with "
258
+ "read_file to understand its full structure, then fix ALL errors "
259
+ "comprehensively in one edit."
260
+ )
261
+ else:
262
+ self.compile_fail_counts.pop(cmd.strip(), None)
263
+ except (json.JSONDecodeError, ValueError):
264
+ pass
265
+
266
+ if on_tool_end:
267
+ on_tool_end(tc.name, res_str)
268
+ results_map[tc.id] = ToolResult(
269
+ tool_call_id=tc.id, name=tc.name, content=res_str, duration_ms=elapsed
270
+ )
230
271
 
231
272
  return [results_map[tc.id] for tc in tool_calls if tc.id in results_map]
232
273
 
@@ -0,0 +1,135 @@
1
+ """
2
+ Pre-Commit AST & Syntax Guardrail for AlpieCode.
3
+
4
+ Validates code syntax before writing to disk, ensuring that corrupt code,
5
+ unclosed brackets, indentation errors, or syntax mistakes are caught and
6
+ can be self-healed in-place without breaking the user workspace.
7
+ """
8
+
9
+ import ast
10
+ import json
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ from pathlib import Path
15
+ from typing import Optional, Tuple
16
+
17
+ try:
18
+ import tomllib
19
+ except ImportError:
20
+ try:
21
+ import tomli as tomllib
22
+ except ImportError:
23
+ tomllib = None
24
+
25
+
26
+ def validate_code_syntax(file_path: str, content: str) -> Tuple[bool, Optional[str]]:
27
+ """
28
+ Validate the syntax of code content for a given file path.
29
+ Returns (is_valid, error_message_if_any).
30
+ """
31
+ if not content or not content.strip():
32
+ return True, None
33
+
34
+ ext = Path(file_path).suffix.lower()
35
+
36
+ # 1. Python AST Validation
37
+ if ext == ".py":
38
+ try:
39
+ ast.parse(content, filename=file_path)
40
+ return True, None
41
+ except SyntaxError as e:
42
+ line = e.lineno or 0
43
+ col = e.offset or 0
44
+ text_line = e.text.strip() if e.text else ""
45
+ err_msg = (
46
+ f"SyntaxError in '{file_path}' at line {line}, col {col}: {e.msg}.\n"
47
+ f"Offending line: {text_line}\n"
48
+ "Please fix this syntax error."
49
+ )
50
+ return False, err_msg
51
+ except Exception as e:
52
+ return False, f"Syntax parsing error in '{file_path}': {e}"
53
+
54
+ # 2. JSON Validation
55
+ if ext == ".json":
56
+ try:
57
+ json.loads(content)
58
+ return True, None
59
+ except Exception as e:
60
+ return False, f"Invalid JSON in '{file_path}': {e}"
61
+
62
+ # 3. TOML Validation
63
+ if ext == ".toml" and tomllib:
64
+ try:
65
+ tomllib.loads(content)
66
+ return True, None
67
+ except Exception as e:
68
+ return False, f"Invalid TOML in '{file_path}': {e}"
69
+
70
+ # 4. JavaScript / TypeScript Validation
71
+ if ext in (".js", ".mjs", ".cjs"):
72
+ node_bin = shutil.which("node")
73
+ if node_bin:
74
+ try:
75
+ res = subprocess.run(
76
+ [node_bin, "--check", "-"],
77
+ input=content,
78
+ text=True,
79
+ capture_output=True,
80
+ timeout=3
81
+ )
82
+ if res.returncode != 0:
83
+ err = res.stderr.strip() or "Syntax error in JavaScript code"
84
+ return False, f"JavaScript SyntaxError in '{file_path}': {err}"
85
+ except Exception:
86
+ pass
87
+
88
+ # Fallback lexical check for bracket balance
89
+ is_balanced, balance_err = check_bracket_balance(content)
90
+ if not is_balanced:
91
+ return False, f"Syntax issue in '{file_path}': {balance_err}"
92
+ return True, None
93
+
94
+ return True, None
95
+
96
+
97
+ def check_bracket_balance(code: str) -> Tuple[bool, Optional[str]]:
98
+ """Fast lexical check for unclosed brackets or strings in code."""
99
+ stack = []
100
+ pairs = {")": "(", "}": "{", "]": "["}
101
+ in_string = False
102
+ quote_char = ""
103
+ escaped = False
104
+
105
+ lines = code.split("\n")
106
+ for line_idx, line in enumerate(lines, 1):
107
+ for char_idx, ch in enumerate(line, 1):
108
+ if in_string:
109
+ if escaped:
110
+ escaped = False
111
+ elif ch == "\\":
112
+ escaped = True
113
+ elif ch == quote_char:
114
+ in_string = False
115
+ continue
116
+
117
+ if ch in ("'", '"', '`'):
118
+ in_string = True
119
+ quote_char = ch
120
+ continue
121
+
122
+ if ch in ("(", "{", "["):
123
+ stack.append((ch, line_idx, char_idx))
124
+ elif ch in (")", "}", "]"):
125
+ if not stack:
126
+ return False, f"Unexpected closing bracket '{ch}' at line {line_idx}:{char_idx}"
127
+ top, t_line, t_col = stack.pop()
128
+ if pairs[ch] != top:
129
+ return False, f"Mismatched bracket: opened '{top}' at line {t_line}:{t_col} but closed with '{ch}' at line {line_idx}:{char_idx}"
130
+
131
+ if stack:
132
+ top, t_line, t_col = stack[-1]
133
+ return False, f"Unclosed bracket '{top}' opened at line {t_line}:{t_col}"
134
+
135
+ return True, None
@@ -1,3 +1,37 @@
1
+ def generate_walkthrough_markdown(task: str, summary: str, files: list, commands: list) -> str:
2
+ """Generate Antigravity-style Walkthrough markdown document."""
3
+ lines = [
4
+ f"# Walkthrough: {task}",
5
+ "",
6
+ "## Overview",
7
+ summary.strip() if summary else "All requested changes and verification steps have been completed.",
8
+ "",
9
+ "## Changes Made",
10
+ ]
11
+ if files:
12
+ lines.append("### Modified / Created Files")
13
+ for f in files:
14
+ lines.append(f"- `{f}`")
15
+ lines.append("")
16
+ else:
17
+ lines.append("No files were modified during this session.")
18
+ lines.append("")
19
+
20
+ if commands:
21
+ lines.append("## Verification Results")
22
+ for c in commands:
23
+ cmd = c.get("command") or c.get("cmd") or ""
24
+ code = c.get("exit_code", 0)
25
+ status = "✓ Passed" if code == 0 else f"✗ Failed (exit code {code})"
26
+ lines.append(f"- `{cmd}` — **{status}**")
27
+ lines.append("")
28
+
29
+ lines.append("## How to Run / Verify")
30
+ lines.append("Review the files above or run your test/build commands to verify project execution.")
31
+ lines.append("")
32
+ return "\n".join(lines)
33
+
34
+
1
35
  """
2
36
  Agent orchestrator for AlpieCode.
3
37
 
@@ -81,6 +115,10 @@ class AgentOrchestrator:
81
115
  # Safety ceiling: hard emergency brake (should never be hit naturally)
82
116
  safety_ceiling = cfg.max_turns if cfg.max_turns != 200 else 200
83
117
 
118
+ # Track touched files and executed commands for Antigravity Walkthrough
119
+ session_touched_files = set()
120
+ session_executed_commands = []
121
+
84
122
  # ── Response cache check ──
85
123
  is_cacheable = not any([image_path, video_path, url, github_repo])
86
124
  if is_cacheable:
@@ -112,7 +150,7 @@ class AgentOrchestrator:
112
150
  session.is_offline = is_offline
113
151
 
114
152
  # ── Configure tools & system prompt based on complexity ──
115
- active_tools = self.prompt_builder.get_tools(is_offline=is_offline, complexity=complexity)
153
+ active_tools = self.prompt_builder.get_tools(is_offline=is_offline, complexity=complexity, task_context=task_context)
116
154
  system_prompt = self.prompt_builder.build_system_prompt(
117
155
  session.workdir, is_offline=is_offline, complexity=complexity,
118
156
  task_context=task_context,
@@ -260,6 +298,20 @@ class AgentOrchestrator:
260
298
 
261
299
  session.context.add_assistant_response(resp)
262
300
 
301
+ # Normalize content and reasoning: rescue any DONE: or answer trapped in reasoning
302
+ if (not resp.content or not resp.content.strip()) and resp.reasoning:
303
+ if "DONE:" in resp.reasoning.upper():
304
+ idx = resp.reasoning.upper().find("DONE:")
305
+ resp.content = resp.reasoning[idx:].strip()
306
+ resp.reasoning = resp.reasoning[:idx].strip() or None
307
+ elif "</think>" in resp.reasoning:
308
+ parts = resp.reasoning.split("</think>", 1)
309
+ resp.reasoning = parts[0].strip() or None
310
+ resp.content = parts[1].strip()
311
+ elif any(m in resp.reasoning for m in ["The codebase is complete", "All JavaScript files", "I have implemented", "Verified the"]):
312
+ resp.content = resp.reasoning.strip()
313
+ resp.reasoning = None
314
+
263
315
  # ── DONE detection in assistant content ──
264
316
  if resp.content and "DONE:" in resp.content.upper():
265
317
  # Model said DONE — finish even if there are tool calls
@@ -280,6 +332,28 @@ class AgentOrchestrator:
280
332
  yield AgentEvent("message", {"content": resp.content})
281
333
  extract_and_save_memories(session.workdir, session.context.messages)
282
334
 
335
+ # Generate and write real walkthrough.md file to the project workspace
336
+ from pathlib import Path as _Path
337
+ try:
338
+ w_path = _Path(session.workdir) / "walkthrough.md"
339
+ w_content = generate_walkthrough_markdown(
340
+ task=task,
341
+ summary=resp.content,
342
+ files=sorted(list(session_touched_files)),
343
+ commands=session_executed_commands,
344
+ )
345
+ w_path.write_text(w_content, encoding="utf-8")
346
+ session_touched_files.add("walkthrough.md")
347
+ except Exception:
348
+ pass
349
+
350
+ yield AgentEvent("walkthrough", {
351
+ "path": "walkthrough.md",
352
+ "summary": resp.content,
353
+ "files": sorted(list(session_touched_files)),
354
+ "commands": session_executed_commands,
355
+ })
356
+
283
357
  # Cache if single-turn
284
358
  if is_cacheable and turn == 0:
285
359
  try:
@@ -315,6 +389,17 @@ class AgentOrchestrator:
315
389
  })
316
390
  session.context.add_tool_result(res.tool_call_id, res.content)
317
391
 
392
+ # Track touched files and commands for walkthrough
393
+ for tc in tool_calls:
394
+ if tc.name in ("write_file", "edit_file", "apply_patch"):
395
+ p = tc.arguments.get("path") or tc.arguments.get("filename")
396
+ if p:
397
+ session_touched_files.add(p)
398
+ elif tc.name == "bash":
399
+ c = tc.arguments.get("command", "")
400
+ if c:
401
+ session_executed_commands.append({"command": c, "exit_code": 0})
402
+
318
403
  # ── Record progress for stall detection ──
319
404
  tc_dicts = [{"name": tc.name, "arguments": tc.arguments} for tc in tool_calls]
320
405
  res_dicts = [{"content": res.content, "name": res.name} for res in results]
@@ -551,9 +551,13 @@ class PromptBuilder:
551
551
  self,
552
552
  is_offline: bool = False,
553
553
  complexity: str = "low",
554
+ task_context=None,
554
555
  ) -> List[dict]:
555
556
  if complexity == "qa":
556
557
  return [] # No tools for Q&A
558
+ if task_context is not None:
559
+ if getattr(task_context, "tool_set", None) == "none" or getattr(task_context, "intent", None) in ("qa", "explain"):
560
+ return []
557
561
  if is_offline:
558
562
  return OFFLINE_TOOLS
559
563
  if complexity in ("medium", "high"):
@@ -1,3 +1,4 @@
1
+ from .guardrails import validate_code_syntax
1
2
  _RECENTLY_WRITTEN_FILES = set()
2
3
 
3
4
  """
@@ -519,6 +520,11 @@ def _read_file(workdir: Path, path: str, start_line: int = None, end_line: int =
519
520
 
520
521
 
521
522
  def _write_file(workdir: Path, path: str, content: str) -> str:
523
+ # Pre-commit AST syntax check to prevent writing corrupt code
524
+ is_valid, err = validate_code_syntax(path, content)
525
+ if not is_valid:
526
+ return f"error: Pre-commit syntax check failed for '{path}':\n{err}\nPlease fix the syntax error before writing."
527
+
522
528
  p = workdir / path
523
529
  p.parent.mkdir(parents=True, exist_ok=True)
524
530
  p.write_text(content, encoding="utf-8")
@@ -532,23 +538,53 @@ def _edit_file(workdir: Path, path: str, old_str: str, new_str: str) -> str:
532
538
  return f"error: file not found: {path}"
533
539
  text = p.read_text(encoding="utf-8", errors="replace")
534
540
  count = text.count(old_str)
535
- if count == 0:
536
- import difflib
537
- lines = text.splitlines()
538
- old_lines = [ol.strip() for ol in old_str.splitlines() if ol.strip()]
539
- target_line = old_lines[0] if old_lines else old_str.strip()
540
- close_matches = difflib.get_close_matches(target_line, [l.strip() for l in lines], n=3, cutoff=0.3)
541
- nearby = [(i + 1, l) for i, l in enumerate(lines) if l.strip() in close_matches]
542
- hint = ""
543
- if nearby:
544
- hint = "\nClosest matching lines in file:\n" + "\n".join(f" Line {n}: {l.strip()[:80]}" for n, l in nearby[:3])
545
- return (
546
- f"error: old_str not found in file '{path}'. It must match existing text EXACTLY (including whitespace/indentation).{hint}\n"
547
- f"→ Action: Run read_file on '{path}' around these lines to see the exact whitespace, then retry edit_file."
548
- )
549
- if count > 1:
541
+
542
+ if count == 1:
543
+ new_text = text.replace(old_str, new_str, 1)
544
+ elif count > 1:
550
545
  return f"error: old_str matched {count} times in '{path}'. Must match exactly 1 occurrence. Include more surrounding lines in old_str to make it unique."
551
- p.write_text(text.replace(old_str, new_str, 1), encoding="utf-8")
546
+ else:
547
+ # Whitespace-tolerant fuzzy block matching
548
+ import difflib
549
+ lines = text.splitlines(keepends=True)
550
+ old_lines = old_str.splitlines(keepends=True)
551
+ n_old = len(old_lines)
552
+ best_ratio = 0.0
553
+ best_idx = -1
554
+
555
+ norm_old = "".join(l.strip() for l in old_lines)
556
+
557
+ for i in range(len(lines) - n_old + 1):
558
+ window = lines[i:i + n_old]
559
+ norm_win = "".join(l.strip() for l in window)
560
+ ratio = difflib.SequenceMatcher(None, norm_win, norm_old).ratio()
561
+ if ratio > best_ratio:
562
+ best_ratio = ratio
563
+ best_idx = i
564
+
565
+ if best_ratio >= 0.85 and best_idx >= 0:
566
+ # Fuzzy match found with high confidence
567
+ new_lines_slice = new_str.splitlines(keepends=True)
568
+ new_text = "".join(lines[:best_idx] + new_lines_slice + lines[best_idx + n_old:])
569
+ else:
570
+ old_stripped = [ol.strip() for ol in old_lines if ol.strip()]
571
+ target_line = old_stripped[0] if old_stripped else old_str.strip()
572
+ close_matches = difflib.get_close_matches(target_line, [l.strip() for l in lines], n=3, cutoff=0.3)
573
+ nearby = [(i + 1, l) for i, l in enumerate(lines) if l.strip() in close_matches]
574
+ hint = ""
575
+ if nearby:
576
+ hint = "\nClosest matching lines in file:\n" + "\n".join(f" Line {n}: {l.strip()[:80]}" for n, l in nearby[:3])
577
+ return (
578
+ f"error: old_str not found in file '{path}'.{hint}\n"
579
+ f"→ Action: Run read_file on '{path}' around these lines, then retry edit_file."
580
+ )
581
+
582
+ # Post-edit syntax validation
583
+ is_valid, err = validate_code_syntax(path, new_text)
584
+ if not is_valid:
585
+ return f"error: Edit would result in a syntax error in '{path}':\n{err}\nPlease revise the replacement."
586
+
587
+ p.write_text(new_text, encoding="utf-8")
552
588
  return "edit applied"
553
589
 
554
590
 
@@ -590,19 +626,29 @@ def _list_files(workdir: Path, path: str = ".", max_depth: int = 4) -> str:
590
626
 
591
627
  def _file_search(workdir: Path, pattern: str, path: str = ".", include: str = None,
592
628
  case_insensitive: bool = False) -> str:
593
- """Search for a pattern across files using grep."""
629
+ """Search for a pattern across files using ripgrep (rg) or grep."""
594
630
  target = workdir / path
595
-
596
- # Build grep command
597
- cmd = ["grep", "-rn", "--color=never"]
598
- if case_insensitive:
599
- cmd.append("-i")
600
- if include:
601
- cmd.extend(["--include", include])
602
- # Exclude common non-code directories
603
- for excl in [".git", "node_modules", "__pycache__", ".venv", "venv", ".egg-info"]:
604
- cmd.extend(["--exclude-dir", excl])
605
- cmd.extend([pattern, str(target)])
631
+ import shutil
632
+
633
+ rg_bin = shutil.which("rg")
634
+ if rg_bin:
635
+ cmd = [rg_bin, "--no-heading", "--line-number", "--color=never"]
636
+ if case_insensitive:
637
+ cmd.append("-i")
638
+ if include:
639
+ cmd.extend(["-g", include])
640
+ cmd.extend(["--hidden", "--glob", "!.git/*", "--glob", "!node_modules/*", "--glob", "!.venv/*"])
641
+ cmd.extend(["-e", pattern, str(target)])
642
+ else:
643
+ # Fallback to grep
644
+ cmd = ["grep", "-rn", "--color=never"]
645
+ if case_insensitive:
646
+ cmd.append("-i")
647
+ if include:
648
+ cmd.extend(["--include", include])
649
+ for excl in [".git", "node_modules", "__pycache__", ".venv", "venv", ".egg-info"]:
650
+ cmd.extend(["--exclude-dir", excl])
651
+ cmd.extend([pattern, str(target)])
606
652
 
607
653
  try:
608
654
  result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, cwd=workdir)
File without changes
File without changes