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.
- {alpiecode-8.0.0 → alpiecode-8.0.2}/PKG-INFO +1 -1
- {alpiecode-8.0.0 → alpiecode-8.0.2}/pyproject.toml +1 -1
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/alpiecode.egg-info/PKG-INFO +1 -1
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/alpiecode.egg-info/SOURCES.txt +1 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/__init__.py +1 -1
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/backends/openai_backend.py +35 -2
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/context.py +27 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/executor.py +94 -53
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/extension/alpiecode.vsix +0 -0
- alpiecode-8.0.2/src/codeagent/guardrails.py +135 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/orchestrator.py +86 -1
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/prompt.py +4 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/tools.py +74 -28
- {alpiecode-8.0.0 → alpiecode-8.0.2}/README.md +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/setup.cfg +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/alpiecode/__init__.py +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/alpiecode.egg-info/dependency_links.txt +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/alpiecode.egg-info/entry_points.txt +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/alpiecode.egg-info/requires.txt +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/alpiecode.egg-info/top_level.txt +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/agent.py +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/backends/__init__.py +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/backends/base.py +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/backends/local_backend.py +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/cache.py +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/cli.py +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/client.py +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/compaction.py +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/config.py +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/discovery.py +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/doctor.py +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/git_ops.py +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/github.py +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/guardian.py +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/ipython_ext.py +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/local_model.py +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/media.py +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/memory.py +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/progress.py +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/rephraser.py +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/server.py +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/session.py +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/updater.py +0 -0
- {alpiecode-8.0.0 → alpiecode-8.0.2}/src/codeagent/vscode_installer.py +0 -0
|
@@ -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
|
-
|
|
183
|
-
|
|
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:
|
|
175
|
-
for tc in mut_calls
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
|
Binary file
|
|
@@ -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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
if
|
|
599
|
-
cmd
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
cmd.extend(["--
|
|
605
|
-
|
|
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
|
|
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
|