alpiecode 8.0.1__tar.gz → 8.0.3__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.1 → alpiecode-8.0.3}/PKG-INFO +1 -1
- {alpiecode-8.0.1 → alpiecode-8.0.3}/pyproject.toml +1 -1
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/alpiecode.egg-info/PKG-INFO +1 -1
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/alpiecode.egg-info/SOURCES.txt +1 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/__init__.py +1 -1
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/context.py +27 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/executor.py +94 -53
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/extension/alpiecode.vsix +0 -0
- alpiecode-8.0.3/src/codeagent/guardrails.py +135 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/orchestrator.py +23 -3
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/prompt.py +4 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/tools.py +74 -28
- {alpiecode-8.0.1 → alpiecode-8.0.3}/README.md +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/setup.cfg +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/alpiecode/__init__.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/alpiecode.egg-info/dependency_links.txt +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/alpiecode.egg-info/entry_points.txt +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/alpiecode.egg-info/requires.txt +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/alpiecode.egg-info/top_level.txt +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/agent.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/backends/__init__.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/backends/base.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/backends/local_backend.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/backends/openai_backend.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/cache.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/cli.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/client.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/compaction.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/config.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/discovery.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/doctor.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/git_ops.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/github.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/guardian.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/ipython_ext.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/local_model.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/media.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/memory.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/progress.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/rephraser.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/server.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/session.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/updater.py +0 -0
- {alpiecode-8.0.1 → alpiecode-8.0.3}/src/codeagent/vscode_installer.py +0 -0
|
@@ -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
|
|
@@ -150,7 +150,7 @@ class AgentOrchestrator:
|
|
|
150
150
|
session.is_offline = is_offline
|
|
151
151
|
|
|
152
152
|
# ── Configure tools & system prompt based on complexity ──
|
|
153
|
-
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)
|
|
154
154
|
system_prompt = self.prompt_builder.build_system_prompt(
|
|
155
155
|
session.workdir, is_offline=is_offline, complexity=complexity,
|
|
156
156
|
task_context=task_context,
|
|
@@ -329,7 +329,17 @@ class AgentOrchestrator:
|
|
|
329
329
|
})
|
|
330
330
|
session.context.add_tool_result(res.tool_call_id, res.content)
|
|
331
331
|
|
|
332
|
-
|
|
332
|
+
if resp.content:
|
|
333
|
+
# Clean duplicate DONE: if repeated
|
|
334
|
+
if resp.content.upper().count("DONE:") > 1:
|
|
335
|
+
parts = re.split(r"(?i)\bDONE:\s*", resp.content)
|
|
336
|
+
if len(parts) >= 3 and parts[1].strip() == parts[2].strip():
|
|
337
|
+
resp.content = parts[0] + "DONE: " + parts[1].strip()
|
|
338
|
+
|
|
339
|
+
# Only yield 'message' if tokens were NOT already streamed chunk-by-chunk
|
|
340
|
+
if not (hasattr(self.backend, "chat_completion_stream") and not is_offline):
|
|
341
|
+
yield AgentEvent("message", {"content": resp.content})
|
|
342
|
+
|
|
333
343
|
extract_and_save_memories(session.workdir, session.context.messages)
|
|
334
344
|
|
|
335
345
|
# Generate and write real walkthrough.md file to the project workspace
|
|
@@ -423,7 +433,17 @@ class AgentOrchestrator:
|
|
|
423
433
|
except Exception:
|
|
424
434
|
pass
|
|
425
435
|
|
|
426
|
-
|
|
436
|
+
if resp.content:
|
|
437
|
+
# Clean duplicate DONE: if repeated
|
|
438
|
+
if resp.content.upper().count("DONE:") > 1:
|
|
439
|
+
parts = re.split(r"(?i)\bDONE:\s*", resp.content)
|
|
440
|
+
if len(parts) >= 3 and parts[1].strip() == parts[2].strip():
|
|
441
|
+
resp.content = parts[0] + "DONE: " + parts[1].strip()
|
|
442
|
+
|
|
443
|
+
# Only yield 'message' if tokens were NOT already streamed chunk-by-chunk
|
|
444
|
+
if not (hasattr(self.backend, "chat_completion_stream") and not is_offline):
|
|
445
|
+
yield AgentEvent("message", {"content": resp.content})
|
|
446
|
+
|
|
427
447
|
extract_and_save_memories(session.workdir, session.context.messages)
|
|
428
448
|
yield AgentEvent("done", {"summary": resp.content})
|
|
429
449
|
return
|
|
@@ -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
|
|
File without changes
|