alpiecode 8.0.8__tar.gz → 9.0.0__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.8 → alpiecode-9.0.0}/PKG-INFO +1 -1
- {alpiecode-8.0.8 → alpiecode-9.0.0}/pyproject.toml +1 -1
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/alpiecode.egg-info/PKG-INFO +1 -1
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/alpiecode.egg-info/SOURCES.txt +3 -1
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/__init__.py +1 -1
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/backends/openai_backend.py +97 -11
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/client.py +19 -11
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/context.py +65 -17
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/extension/alpiecode.vsix +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/memory.py +48 -16
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/prompt.py +2 -2
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/tools.py +17 -3
- {alpiecode-8.0.8 → alpiecode-9.0.0}/test/test_phase1.py +54 -0
- alpiecode-9.0.0/test/test_phase2.py +231 -0
- alpiecode-9.0.0/test/test_phase3.py +154 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/README.md +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/setup.cfg +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/alpiecode/__init__.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/alpiecode.egg-info/dependency_links.txt +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/alpiecode.egg-info/entry_points.txt +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/alpiecode.egg-info/requires.txt +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/alpiecode.egg-info/top_level.txt +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/agent.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/backends/__init__.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/backends/base.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/backends/local_backend.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/cache.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/cli.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/compaction.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/config.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/discovery.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/doctor.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/executor.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/git_ops.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/github.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/guardian.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/guardrails.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/ipython_ext.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/local_model.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/media.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/orchestrator.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/progress.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/rephraser.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/server.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/session.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/updater.py +0 -0
- {alpiecode-8.0.8 → alpiecode-9.0.0}/src/codeagent/vscode_installer.py +0 -0
|
@@ -16,7 +16,7 @@ Supports:
|
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
from typing import Optional, Any, Dict
|
|
18
18
|
|
|
19
|
-
__version__ = "
|
|
19
|
+
__version__ = "9.0.0"
|
|
20
20
|
|
|
21
21
|
def run(task: str, workdir: str = ".", reasoning_level: str = "thinking", **kwargs):
|
|
22
22
|
"""Run an autonomous coding task programmatically."""
|
|
@@ -2,9 +2,103 @@
|
|
|
2
2
|
OpenAI-compatible API inference backend with smart model resolution.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
import ast
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
5
9
|
from typing import Any, List, Optional
|
|
6
10
|
from openai import OpenAI, NotFoundError
|
|
7
11
|
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _repair_and_parse_json_arguments(raw_args: str) -> dict:
|
|
16
|
+
"""
|
|
17
|
+
Robustly parse tool call arguments from potentially partial or malformed streaming strings.
|
|
18
|
+
Handles:
|
|
19
|
+
- Clean JSON
|
|
20
|
+
- Single-quoted JSON / Python dict strings via ast.literal_eval
|
|
21
|
+
- Truncated strings / missing closing quotes & braces from stream cutoffs
|
|
22
|
+
- Trailing commas
|
|
23
|
+
Returns a dict of parsed arguments. If unparseable, returns a dict preserving _raw_arguments
|
|
24
|
+
and _parse_error so the tool dispatcher can provide actionable error reporting.
|
|
25
|
+
"""
|
|
26
|
+
if not raw_args or not raw_args.strip():
|
|
27
|
+
return {}
|
|
28
|
+
|
|
29
|
+
text = raw_args.strip()
|
|
30
|
+
|
|
31
|
+
# 1. Direct standard JSON
|
|
32
|
+
try:
|
|
33
|
+
parsed = json.loads(text)
|
|
34
|
+
if isinstance(parsed, dict):
|
|
35
|
+
return parsed
|
|
36
|
+
return {"value": parsed}
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
# 2. Try ast.literal_eval (handles Python single quotes, True/False/None)
|
|
41
|
+
try:
|
|
42
|
+
parsed = ast.literal_eval(text)
|
|
43
|
+
if isinstance(parsed, dict):
|
|
44
|
+
return parsed
|
|
45
|
+
except Exception:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
# 3. Structural repair for streaming cutoffs / trailing commas
|
|
49
|
+
repaired = text
|
|
50
|
+
|
|
51
|
+
# Remove trailing commas like `..., }` or `..., ]`
|
|
52
|
+
repaired = re.sub(r",\s*([\}\]])", r"\1", repaired)
|
|
53
|
+
|
|
54
|
+
# If trailing comma at end: `{"a": 1,` -> `{"a": 1`
|
|
55
|
+
repaired = re.sub(r",\s*$", "", repaired)
|
|
56
|
+
|
|
57
|
+
# Check unclosed quotes: count non-escaped double quotes
|
|
58
|
+
quote_count = len(re.findall(r'(?<!\\)"', repaired))
|
|
59
|
+
if quote_count % 2 != 0:
|
|
60
|
+
repaired += '"'
|
|
61
|
+
|
|
62
|
+
# Balance unclosed brackets and braces
|
|
63
|
+
open_curly = repaired.count("{") - repaired.count("}")
|
|
64
|
+
open_square = repaired.count("[") - repaired.count("]")
|
|
65
|
+
|
|
66
|
+
if open_square > 0:
|
|
67
|
+
repaired += "]" * open_square
|
|
68
|
+
if open_curly > 0:
|
|
69
|
+
repaired += "}" * open_curly
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
parsed = json.loads(repaired)
|
|
73
|
+
if isinstance(parsed, dict):
|
|
74
|
+
return parsed
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
# 4. If still failing, attempt regex key-value extraction for simple {"key": "val"} forms
|
|
79
|
+
extracted = {}
|
|
80
|
+
pattern = r"""['"]([a-zA-Z0-9_-]+)['"]\s*:\s*(?:['"](.*?)['"]|([0-9.]+|true|false|null))"""
|
|
81
|
+
for kv in re.finditer(pattern, text, re.IGNORECASE):
|
|
82
|
+
k = kv.group(1)
|
|
83
|
+
v_str = kv.group(2)
|
|
84
|
+
v_lit = kv.group(3)
|
|
85
|
+
if v_str is not None:
|
|
86
|
+
extracted[k] = v_str
|
|
87
|
+
elif v_lit is not None:
|
|
88
|
+
try:
|
|
89
|
+
extracted[k] = json.loads(v_lit.lower())
|
|
90
|
+
except Exception:
|
|
91
|
+
extracted[k] = v_lit
|
|
92
|
+
|
|
93
|
+
if extracted:
|
|
94
|
+
return extracted
|
|
95
|
+
|
|
96
|
+
logger.warning("Failed to parse tool call arguments: %s", raw_args[:200])
|
|
97
|
+
return {
|
|
98
|
+
"_raw_arguments": raw_args,
|
|
99
|
+
"_parse_error": "Failed to parse streaming JSON arguments",
|
|
100
|
+
}
|
|
101
|
+
|
|
8
102
|
from ..config import Config, get_shared_http_client, is_server_reachable
|
|
9
103
|
from .base import ChatResponse, ToolCall
|
|
10
104
|
|
|
@@ -185,10 +279,7 @@ class OpenAIBackend:
|
|
|
185
279
|
tool_calls = []
|
|
186
280
|
for idx in sorted(tool_calls_dict.keys()):
|
|
187
281
|
item = tool_calls_dict[idx]
|
|
188
|
-
|
|
189
|
-
args = json.loads(item["arguments"])
|
|
190
|
-
except Exception:
|
|
191
|
-
args = {}
|
|
282
|
+
args = _repair_and_parse_json_arguments(item.get("arguments", ""))
|
|
192
283
|
tool_calls.append(ToolCall(id=item["id"], name=item["name"], arguments=args))
|
|
193
284
|
|
|
194
285
|
final_content_str = "".join(full_content).strip()
|
|
@@ -260,15 +351,10 @@ class OpenAIBackend:
|
|
|
260
351
|
|
|
261
352
|
tool_calls = None
|
|
262
353
|
if msg.tool_calls:
|
|
263
|
-
import json
|
|
264
354
|
tool_calls = []
|
|
265
355
|
for tc in msg.tool_calls:
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
except (json.JSONDecodeError, TypeError):
|
|
269
|
-
args = {}
|
|
270
|
-
if not isinstance(args, dict):
|
|
271
|
-
args = {}
|
|
356
|
+
raw_args = tc.function.arguments or "{}"
|
|
357
|
+
args = _repair_and_parse_json_arguments(raw_args)
|
|
272
358
|
tool_calls.append(
|
|
273
359
|
ToolCall(
|
|
274
360
|
id=tc.id,
|
|
@@ -102,31 +102,39 @@ class AlpieCodeClient:
|
|
|
102
102
|
try:
|
|
103
103
|
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
104
104
|
current_event_type = "message"
|
|
105
|
-
|
|
105
|
+
current_data_lines: List[str] = []
|
|
106
106
|
|
|
107
107
|
for raw_line in resp:
|
|
108
|
-
line = raw_line.decode("utf-8").
|
|
108
|
+
line = raw_line.decode("utf-8").rstrip("\r\n")
|
|
109
109
|
if not line:
|
|
110
|
-
if
|
|
110
|
+
if current_data_lines:
|
|
111
|
+
raw_data = "\n".join(current_data_lines)
|
|
111
112
|
try:
|
|
112
|
-
event_data = json.loads(
|
|
113
|
+
event_data = json.loads(raw_data)
|
|
113
114
|
except Exception:
|
|
114
|
-
event_data = {"content":
|
|
115
|
+
event_data = {"content": raw_data}
|
|
115
116
|
yield AgentEvent(type=current_event_type, data=event_data)
|
|
116
117
|
current_event_type = "message"
|
|
117
|
-
|
|
118
|
+
current_data_lines = []
|
|
118
119
|
continue
|
|
119
120
|
|
|
120
|
-
if line.startswith("
|
|
121
|
+
if line.startswith(":"):
|
|
122
|
+
# SSE comment/keepalive ping - ignore
|
|
123
|
+
continue
|
|
124
|
+
elif line.startswith("event:"):
|
|
121
125
|
current_event_type = line[6:].strip()
|
|
122
126
|
elif line.startswith("data:"):
|
|
123
|
-
|
|
127
|
+
chunk = line[5:]
|
|
128
|
+
if chunk.startswith(" "):
|
|
129
|
+
chunk = chunk[1:]
|
|
130
|
+
current_data_lines.append(chunk)
|
|
124
131
|
|
|
125
|
-
if
|
|
132
|
+
if current_data_lines:
|
|
133
|
+
raw_data = "\n".join(current_data_lines)
|
|
126
134
|
try:
|
|
127
|
-
event_data = json.loads(
|
|
135
|
+
event_data = json.loads(raw_data)
|
|
128
136
|
except Exception:
|
|
129
|
-
event_data = {"content":
|
|
137
|
+
event_data = {"content": raw_data}
|
|
130
138
|
yield AgentEvent(type=current_event_type, data=event_data)
|
|
131
139
|
except Exception as e:
|
|
132
140
|
yield AgentEvent(type="error", data={"error": f"Server connection failed: {e}"})
|
|
@@ -6,6 +6,7 @@ and dynamically assembles token-budgeted context windows via build_context()
|
|
|
6
6
|
for OpenAI-compatible chat completion endpoints.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
+
import copy
|
|
9
10
|
import json
|
|
10
11
|
import time
|
|
11
12
|
from typing import Any, Dict, List, Optional
|
|
@@ -52,9 +53,19 @@ class ContextManager:
|
|
|
52
53
|
def __init__(self, max_tokens: int = 262_144):
|
|
53
54
|
self.max_tokens = max_tokens
|
|
54
55
|
self._messages: List[dict] = []
|
|
56
|
+
self._raw_messages: List[dict] = []
|
|
55
57
|
self._metadata: List[Dict[str, Any]] = []
|
|
56
58
|
self._turn_counter: int = 0
|
|
57
59
|
self._cached_summary: Optional[str] = None
|
|
60
|
+
self._cached_summary_len: int = 0
|
|
61
|
+
self._cached_context: Optional[List[dict]] = None
|
|
62
|
+
|
|
63
|
+
def invalidate_cache(self, reset_summary: bool = False) -> None:
|
|
64
|
+
"""Invalidate cached context assembly."""
|
|
65
|
+
self._cached_context = None
|
|
66
|
+
if reset_summary:
|
|
67
|
+
self._cached_summary = None
|
|
68
|
+
self._cached_summary_len = 0
|
|
58
69
|
|
|
59
70
|
def _estimate_single_message_tokens(self, msg: dict) -> int:
|
|
60
71
|
"""Estimate token count for an individual message."""
|
|
@@ -141,27 +152,37 @@ class ContextManager:
|
|
|
141
152
|
|
|
142
153
|
def set_system_prompt(self, prompt: str) -> None:
|
|
143
154
|
"""Set or update the root system prompt (Layer 1)."""
|
|
155
|
+
self.invalidate_cache(reset_summary=True)
|
|
144
156
|
sys_msg = {"role": "system", "content": prompt}
|
|
145
157
|
if self._messages and self._messages[0].get("role") == "system":
|
|
146
|
-
self._messages[0] = sys_msg
|
|
158
|
+
self._messages[0] = dict(sys_msg)
|
|
159
|
+
if self._raw_messages and self._raw_messages[0].get("role") == "system":
|
|
160
|
+
self._raw_messages[0] = copy.deepcopy(sys_msg)
|
|
161
|
+
else:
|
|
162
|
+
self._raw_messages.insert(0, copy.deepcopy(sys_msg))
|
|
147
163
|
if self._metadata:
|
|
148
164
|
self._metadata[0] = self._build_metadata_entry(sys_msg, importance="high")
|
|
149
165
|
else:
|
|
150
|
-
self._messages.insert(0, sys_msg)
|
|
166
|
+
self._messages.insert(0, dict(sys_msg))
|
|
167
|
+
self._raw_messages.insert(0, copy.deepcopy(sys_msg))
|
|
151
168
|
self._metadata.insert(0, self._build_metadata_entry(sys_msg, importance="high"))
|
|
152
169
|
|
|
153
170
|
def add_user_message(self, content: Any, metadata: Optional[dict] = None) -> None:
|
|
154
171
|
"""Append a user request and record metadata."""
|
|
172
|
+
self.invalidate_cache()
|
|
155
173
|
self._turn_counter += 1
|
|
156
174
|
msg = {"role": "user", "content": content}
|
|
157
|
-
self._messages.append(msg)
|
|
175
|
+
self._messages.append(dict(msg))
|
|
176
|
+
self._raw_messages.append(copy.deepcopy(msg))
|
|
158
177
|
self._metadata.append(self._build_metadata_entry(msg, custom=metadata))
|
|
159
178
|
|
|
160
179
|
def add_assistant_response(self, response: ChatResponse, metadata: Optional[dict] = None) -> None:
|
|
161
180
|
"""Append an assistant response with tool calls and record metadata."""
|
|
162
181
|
if response.tool_calls or response.content:
|
|
182
|
+
self.invalidate_cache()
|
|
163
183
|
msg = _serialize_assistant_message(response)
|
|
164
|
-
self._messages.append(msg)
|
|
184
|
+
self._messages.append(dict(msg))
|
|
185
|
+
self._raw_messages.append(copy.deepcopy(msg))
|
|
165
186
|
tool_names = [tc.name for tc in response.tool_calls] if response.tool_calls else []
|
|
166
187
|
custom_meta = metadata.copy() if metadata else {}
|
|
167
188
|
custom_meta["tool_names"] = tool_names
|
|
@@ -176,12 +197,14 @@ class ContextManager:
|
|
|
176
197
|
metadata: Optional[dict] = None,
|
|
177
198
|
) -> None:
|
|
178
199
|
"""Append a tool execution result and record metadata."""
|
|
200
|
+
self.invalidate_cache()
|
|
179
201
|
msg = {
|
|
180
202
|
"role": "tool",
|
|
181
203
|
"tool_call_id": tool_call_id,
|
|
182
204
|
"content": content,
|
|
183
205
|
}
|
|
184
|
-
self._messages.append(msg)
|
|
206
|
+
self._messages.append(dict(msg))
|
|
207
|
+
self._raw_messages.append(copy.deepcopy(msg))
|
|
185
208
|
self._metadata.append(
|
|
186
209
|
self._build_metadata_entry(msg, tool_name=tool_name, custom=metadata)
|
|
187
210
|
)
|
|
@@ -190,7 +213,9 @@ class ContextManager:
|
|
|
190
213
|
"""
|
|
191
214
|
Truncate large historical tool outputs from turns older than keep_last_turns.
|
|
192
215
|
Preserves update_plan and short outputs.
|
|
216
|
+
Note: Operates on working _messages, while _raw_messages / raw_history remains pristine.
|
|
193
217
|
"""
|
|
218
|
+
self.invalidate_cache()
|
|
194
219
|
assistant_indices = [
|
|
195
220
|
i for i, m in enumerate(self._messages)
|
|
196
221
|
if isinstance(m, dict) and m.get("role") == "assistant"
|
|
@@ -206,6 +231,8 @@ class ContextManager:
|
|
|
206
231
|
# Never truncate update_plan output
|
|
207
232
|
if "[Plan updated]" in content or "Plan Status:" in content or len(content) <= 300:
|
|
208
233
|
continue
|
|
234
|
+
msg = dict(msg)
|
|
235
|
+
self._messages[i] = msg
|
|
209
236
|
lines = content.splitlines()
|
|
210
237
|
if len(lines) > 8:
|
|
211
238
|
preview_start = "\n".join(lines[:3])
|
|
@@ -215,6 +242,7 @@ class ContextManager:
|
|
|
215
242
|
msg["content"] = content[:150] + f"... [truncated {len(content)} chars]"
|
|
216
243
|
# Update tokens in metadata
|
|
217
244
|
if i < len(self._metadata):
|
|
245
|
+
self._metadata[i] = dict(self._metadata[i])
|
|
218
246
|
self._metadata[i]["tokens"] = self._estimate_single_message_tokens(msg)
|
|
219
247
|
|
|
220
248
|
def build_context(
|
|
@@ -230,6 +258,10 @@ class ContextManager:
|
|
|
230
258
|
4. recent messages (intact tool-call pairs)
|
|
231
259
|
5. current request (tail of conversation)
|
|
232
260
|
"""
|
|
261
|
+
is_default_params = (max_tokens is None or max_tokens == self.max_tokens) and recent_turns == 4
|
|
262
|
+
if is_default_params and self._cached_context is not None:
|
|
263
|
+
return [dict(m) for m in self._cached_context]
|
|
264
|
+
|
|
233
265
|
if not self._messages:
|
|
234
266
|
return []
|
|
235
267
|
|
|
@@ -276,10 +308,15 @@ class ContextManager:
|
|
|
276
308
|
context_blocks.append(system_msg)
|
|
277
309
|
|
|
278
310
|
if distant_messages:
|
|
279
|
-
# Layer 2: Distant Summary
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
311
|
+
# Layer 2: Distant Summary (Lazy-cached)
|
|
312
|
+
if self._cached_summary is not None and len(distant_messages) == self._cached_summary_len:
|
|
313
|
+
summary_text = self._cached_summary
|
|
314
|
+
else:
|
|
315
|
+
summary_text = extract_conversation_summary(
|
|
316
|
+
distant_messages, distant_metadata
|
|
317
|
+
)
|
|
318
|
+
self._cached_summary = summary_text
|
|
319
|
+
self._cached_summary_len = len(distant_messages)
|
|
283
320
|
# Layer 3: Relevant History Anchors
|
|
284
321
|
relevant_anchors = select_relevant_history(
|
|
285
322
|
distant_messages, distant_metadata
|
|
@@ -292,10 +329,16 @@ class ContextManager:
|
|
|
292
329
|
supplemental_parts.append(relevant_anchors)
|
|
293
330
|
|
|
294
331
|
if supplemental_parts:
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
332
|
+
supp_text = "=== CONVERSATION SUMMARY & RELEVANT HISTORY ===\n" + "\n\n".join(supplemental_parts)
|
|
333
|
+
if context_blocks and context_blocks[0].get("role") == "system":
|
|
334
|
+
# Merge into the existing primary system prompt to ensure strictly one system message at the beginning
|
|
335
|
+
context_blocks[0] = dict(context_blocks[0])
|
|
336
|
+
context_blocks[0]["content"] = (context_blocks[0].get("content") or "") + "\n\n" + supp_text
|
|
337
|
+
else:
|
|
338
|
+
context_blocks.insert(0, {
|
|
339
|
+
"role": "system",
|
|
340
|
+
"content": supp_text,
|
|
341
|
+
})
|
|
299
342
|
|
|
300
343
|
# Ensure user query anchor: endpoint requires at least one 'user' message
|
|
301
344
|
has_user_in_recent = any(m.get("role") == "user" for m in recent_messages)
|
|
@@ -325,7 +368,10 @@ class ContextManager:
|
|
|
325
368
|
if estimate_tokens(context_blocks) > effective_limit:
|
|
326
369
|
context_blocks = compact_messages(context_blocks)
|
|
327
370
|
|
|
328
|
-
|
|
371
|
+
if is_default_params:
|
|
372
|
+
self._cached_context = [dict(m) for m in context_blocks]
|
|
373
|
+
|
|
374
|
+
return [dict(m) for m in context_blocks]
|
|
329
375
|
|
|
330
376
|
@property
|
|
331
377
|
def messages(self) -> List[dict]:
|
|
@@ -335,18 +381,20 @@ class ContextManager:
|
|
|
335
381
|
@messages.setter
|
|
336
382
|
def messages(self, msgs: List[dict]) -> None:
|
|
337
383
|
"""Replace messages and rebuild metadata."""
|
|
338
|
-
self.
|
|
384
|
+
self.invalidate_cache(reset_summary=True)
|
|
385
|
+
self._messages = [dict(m) for m in msgs]
|
|
386
|
+
self._raw_messages = [copy.deepcopy(m) for m in msgs]
|
|
339
387
|
self._rebuild_metadata()
|
|
340
388
|
|
|
341
389
|
@property
|
|
342
390
|
def all_messages(self) -> List[dict]:
|
|
343
391
|
"""Returns the complete, uncompressed historical messages."""
|
|
344
|
-
return
|
|
392
|
+
return [copy.deepcopy(m) for m in self._raw_messages]
|
|
345
393
|
|
|
346
394
|
@property
|
|
347
395
|
def raw_history(self) -> List[dict]:
|
|
348
396
|
"""Alias for all_messages."""
|
|
349
|
-
return
|
|
397
|
+
return [copy.deepcopy(m) for m in self._raw_messages]
|
|
350
398
|
|
|
351
399
|
@property
|
|
352
400
|
def metadata(self) -> List[Dict[str, Any]]:
|
|
Binary file
|
|
@@ -47,36 +47,64 @@ def load_memories(workdir: Path) -> List[dict]:
|
|
|
47
47
|
return []
|
|
48
48
|
|
|
49
49
|
|
|
50
|
-
def
|
|
50
|
+
def save_memories_batch(workdir: Path, entries: List[dict]) -> None:
|
|
51
51
|
"""
|
|
52
|
-
Save
|
|
52
|
+
Save multiple memory entries for a project in a single atomic disk write.
|
|
53
53
|
|
|
54
54
|
Args:
|
|
55
55
|
workdir: Project directory
|
|
56
|
-
|
|
57
|
-
memory_type: Type of memory (learning, structure, command, pattern)
|
|
56
|
+
entries: List of dicts, each with 'content' and optional 'type'
|
|
58
57
|
"""
|
|
58
|
+
if not entries:
|
|
59
|
+
return
|
|
60
|
+
|
|
59
61
|
MEMORY_DIR.mkdir(parents=True, exist_ok=True)
|
|
60
62
|
path = _memory_path(workdir)
|
|
61
63
|
|
|
62
64
|
existing = load_memories(workdir)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
existing_contents = {m.get("content") for m in existing}
|
|
66
|
+
now = time.time()
|
|
67
|
+
workdir_str = str(workdir.resolve())
|
|
68
|
+
|
|
69
|
+
added = False
|
|
70
|
+
for entry in entries:
|
|
71
|
+
c = entry.get("content", "").strip()
|
|
72
|
+
if not c or c in existing_contents:
|
|
73
|
+
continue
|
|
74
|
+
existing.append({
|
|
75
|
+
"content": c,
|
|
76
|
+
"type": entry.get("type", "learning"),
|
|
77
|
+
"timestamp": now,
|
|
78
|
+
"workdir": workdir_str,
|
|
79
|
+
})
|
|
80
|
+
existing_contents.add(c)
|
|
81
|
+
added = True
|
|
82
|
+
|
|
83
|
+
if not added:
|
|
84
|
+
return
|
|
69
85
|
|
|
70
86
|
# Keep only the last 20 memories per project (FIFO)
|
|
71
87
|
if len(existing) > 20:
|
|
72
88
|
existing = existing[-20:]
|
|
73
89
|
|
|
74
90
|
path.write_text(json.dumps({
|
|
75
|
-
"project":
|
|
91
|
+
"project": workdir_str,
|
|
76
92
|
"memories": existing,
|
|
77
93
|
}, indent=2))
|
|
78
94
|
|
|
79
95
|
|
|
96
|
+
def save_memory(workdir: Path, content: str, memory_type: str = "learning") -> None:
|
|
97
|
+
"""
|
|
98
|
+
Save a single memory entry for a project.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
workdir: Project directory
|
|
102
|
+
content: The memory content to save
|
|
103
|
+
memory_type: Type of memory (learning, structure, command, pattern)
|
|
104
|
+
"""
|
|
105
|
+
save_memories_batch(workdir, [{"content": content, "type": memory_type}])
|
|
106
|
+
|
|
107
|
+
|
|
80
108
|
def format_memories_for_prompt(workdir: Path) -> Optional[str]:
|
|
81
109
|
"""
|
|
82
110
|
Format memories into a string suitable for injection into the system prompt.
|
|
@@ -102,13 +130,14 @@ def format_memories_for_prompt(workdir: Path) -> Optional[str]:
|
|
|
102
130
|
def extract_and_save_memories(workdir: Path, messages: list) -> None:
|
|
103
131
|
"""
|
|
104
132
|
After a session ends, extract key learnings from the conversation
|
|
105
|
-
and save them as memories.
|
|
133
|
+
and save them as memories in a single atomic batch.
|
|
106
134
|
|
|
107
135
|
Scans tool results for commonly useful information like:
|
|
108
136
|
- Successful build/test commands
|
|
109
137
|
- Project structure (from list_files results)
|
|
110
138
|
- Completion summaries
|
|
111
139
|
"""
|
|
140
|
+
to_save: List[dict] = []
|
|
112
141
|
saved_cmds = set() # Avoid duplicate command memories
|
|
113
142
|
|
|
114
143
|
for idx, msg in enumerate(messages):
|
|
@@ -137,20 +166,23 @@ def extract_and_save_memories(workdir: Path, messages: list) -> None:
|
|
|
137
166
|
cmd = ""
|
|
138
167
|
if cmd and cmd not in saved_cmds:
|
|
139
168
|
if any(kw in cmd for kw in ["pytest", "test", "unittest", "npm run test", "cargo test", "go test"]):
|
|
140
|
-
|
|
169
|
+
to_save.append({"content": f"Working test command: {cmd}", "type": "command"})
|
|
141
170
|
saved_cmds.add(cmd)
|
|
142
171
|
elif any(kw in cmd for kw in ["build", "compile", "g++", "gcc", "make", "cargo build", "npm run build"]):
|
|
143
|
-
|
|
172
|
+
to_save.append({"content": f"Working build command: {cmd}", "type": "command"})
|
|
144
173
|
saved_cmds.add(cmd)
|
|
145
174
|
break
|
|
146
175
|
|
|
147
176
|
# ── Save project structure from list_files / tree output ──
|
|
148
177
|
if "├" in content or "└" in content:
|
|
149
178
|
if len(content) < 2000:
|
|
150
|
-
|
|
179
|
+
to_save.append({"content": f"Project structure:\n{content[:500]}", "type": "structure"})
|
|
151
180
|
|
|
152
181
|
# ── Save completion summaries ──
|
|
153
182
|
if msg.get("role") == "assistant" and msg.get("content"):
|
|
154
183
|
content = msg["content"]
|
|
155
184
|
if content.strip().startswith("DONE"):
|
|
156
|
-
|
|
185
|
+
to_save.append({"content": content.strip()[:200], "type": "completion_summary"})
|
|
186
|
+
|
|
187
|
+
if to_save:
|
|
188
|
+
save_memories_batch(workdir, to_save)
|
|
@@ -375,7 +375,7 @@ COMPLEXITY_CONFIG = {
|
|
|
375
375
|
|
|
376
376
|
BASH_RULES = """\
|
|
377
377
|
## Shell Environment: Bash (Linux/macOS)
|
|
378
|
-
- You are ALREADY in the project root directory.
|
|
378
|
+
- You are ALREADY in the project root directory. Run all commands directly without using `cd`
|
|
379
379
|
- Use `&&` to chain commands
|
|
380
380
|
- Use `export VAR=value` for environment variables
|
|
381
381
|
- Use `python3` (not `python` which may be Python 2)
|
|
@@ -386,7 +386,7 @@ BASH_RULES = """\
|
|
|
386
386
|
|
|
387
387
|
WSL_RULES = """\
|
|
388
388
|
## Shell Environment: WSL Bash (Windows Subsystem for Linux)
|
|
389
|
-
- You are ALREADY in the project root directory.
|
|
389
|
+
- You are ALREADY in the project root directory. Run all commands directly without using `cd`
|
|
390
390
|
- Use bash syntax: `&&` to chain, `export VAR=value` for env vars
|
|
391
391
|
- Use `python3` (not `python`)
|
|
392
392
|
- For testing python code, use `python3 -m unittest`
|
|
@@ -425,12 +425,17 @@ def _bash(workdir: Path, command: str) -> str:
|
|
|
425
425
|
command = re.sub(r"(?:[^\s;&|]*/)?(?:python3?|py)(?:\.exe)?\s+-m\s+pip3?\s+install\b", f"{uv_bin} pip install", command)
|
|
426
426
|
command = re.sub(r"(?:[^\s;&|]*/)?pip3?\s+install\b", f"{uv_bin} pip install", command)
|
|
427
427
|
|
|
428
|
+
# Strip hallucinated benchmark directory switches (e.g. cd /testbed && ..., cd /root && ...)
|
|
429
|
+
command = re.sub(r"^\s*cd\s+/(?:testbed|root|home/[^\s;&]+)\s*(&&|;)\s*", "", command)
|
|
430
|
+
|
|
428
431
|
# Cross-platform shell resolution
|
|
429
432
|
if shutil.which("bash"):
|
|
430
433
|
shell_cmd = ["bash", "-c", command]
|
|
431
434
|
elif os.name == "nt":
|
|
435
|
+
# PowerShell 5.1 compatibility: replace '&&' with ';'
|
|
436
|
+
ps_command = re.sub(r"\s+&&\s+", "; ", command)
|
|
432
437
|
if shutil.which("powershell"):
|
|
433
|
-
shell_cmd = ["powershell", "-NoProfile", "-Command",
|
|
438
|
+
shell_cmd = ["powershell", "-NoProfile", "-Command", ps_command]
|
|
434
439
|
else:
|
|
435
440
|
shell_cmd = ["cmd.exe", "/c", command]
|
|
436
441
|
else:
|
|
@@ -607,10 +612,15 @@ def _list_files(workdir: Path, path: str = ".", max_depth: int = 4) -> str:
|
|
|
607
612
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
608
613
|
pass
|
|
609
614
|
|
|
610
|
-
# Fallback: os.walk
|
|
615
|
+
# Fallback: os.walk with directory pruning and early termination
|
|
616
|
+
prune_dirs = {
|
|
617
|
+
".git", ".venv", "venv", "node_modules", "__pycache__",
|
|
618
|
+
"build", "dist", ".pytest_cache", ".ruff_cache", ".mypy_cache",
|
|
619
|
+
".idea", ".vscode", ".next", ".nuxt", "target", "vendor",
|
|
620
|
+
}
|
|
611
621
|
entries = []
|
|
612
622
|
for root, dirs, files in os.walk(target):
|
|
613
|
-
dirs[:] = [d for d in dirs if not d.startswith(".")]
|
|
623
|
+
dirs[:] = [d for d in dirs if not d.startswith(".") and d not in prune_dirs]
|
|
614
624
|
rel_root = os.path.relpath(root, target)
|
|
615
625
|
depth = 0 if rel_root == "." else rel_root.count(os.sep) + 1
|
|
616
626
|
if depth >= max_depth:
|
|
@@ -621,6 +631,10 @@ def _list_files(workdir: Path, path: str = ".", max_depth: int = 4) -> str:
|
|
|
621
631
|
continue
|
|
622
632
|
rel_path = os.path.join(rel_root, fname) if rel_root != "." else fname
|
|
623
633
|
entries.append(rel_path)
|
|
634
|
+
if len(entries) >= 200:
|
|
635
|
+
break
|
|
636
|
+
if len(entries) >= 200:
|
|
637
|
+
break
|
|
624
638
|
return "\n".join(entries[:200]) or "(empty directory)"
|
|
625
639
|
|
|
626
640
|
|
|
@@ -6,11 +6,14 @@ Unit tests verifying Phase 1 stability fixes:
|
|
|
6
6
|
- BUG-06: orchestrator.py backend immutability / thread safety
|
|
7
7
|
- BUG-09: tools.py bash timeout error message alignment
|
|
8
8
|
- BUG-11: compaction.py tool-call pairing integrity
|
|
9
|
+
- System message single-instance guarantee (prevents 400 'System message must be at the beginning')
|
|
10
|
+
- Benchmark directory stripping (prevents hallucinated cd /testbed failures)
|
|
9
11
|
"""
|
|
10
12
|
import ast
|
|
11
13
|
from pathlib import Path
|
|
12
14
|
from unittest.mock import MagicMock, patch
|
|
13
15
|
import json
|
|
16
|
+
import re
|
|
14
17
|
import pytest
|
|
15
18
|
|
|
16
19
|
from codeagent.executor import ToolExecutor, ToolCall, ToolResult
|
|
@@ -21,6 +24,7 @@ from codeagent.config import Config
|
|
|
21
24
|
from codeagent.backends.base import ChatResponse
|
|
22
25
|
from codeagent.backends.openai_backend import OpenAIBackend
|
|
23
26
|
from codeagent.backends.local_backend import LocalBackend
|
|
27
|
+
from codeagent.context import ContextManager
|
|
24
28
|
|
|
25
29
|
|
|
26
30
|
def test_bug01_executor_dead_code_removed():
|
|
@@ -203,3 +207,53 @@ def test_bug11_compaction_tool_pairing_guard():
|
|
|
203
207
|
assert prev_msg.get("role") in ("assistant", "tool"), (
|
|
204
208
|
f"Tool message at index {idx} was preceded by role '{prev_msg.get('role')}'"
|
|
205
209
|
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_strictly_one_system_message_on_distant_turns():
|
|
213
|
+
"""Verify build_context produces strictly ONE system message at index 0, preventing 400 errors."""
|
|
214
|
+
cm = ContextManager(max_tokens=8192)
|
|
215
|
+
cm.set_system_prompt("Base system prompt for AlpieCode.")
|
|
216
|
+
|
|
217
|
+
# Add 10 turns (exceeding recent_turns=4, triggering distant turn extraction)
|
|
218
|
+
for i in range(10):
|
|
219
|
+
cm.add_user_message(f"User request {i}")
|
|
220
|
+
cm.add_assistant_response(ChatResponse(
|
|
221
|
+
content=f"Assistant response {i}",
|
|
222
|
+
reasoning=None,
|
|
223
|
+
tool_calls=[ToolCall(id=f"c_{i}", name="bash", arguments={"command": f"echo {i}"})]
|
|
224
|
+
))
|
|
225
|
+
cm.add_tool_result(f"c_{i}", f"output {i}", tool_name="bash")
|
|
226
|
+
|
|
227
|
+
context = cm.build_context(recent_turns=4)
|
|
228
|
+
|
|
229
|
+
# Must have exactly one system message
|
|
230
|
+
system_messages = [m for m in context if m.get("role") == "system"]
|
|
231
|
+
assert len(system_messages) == 1, f"Expected exactly 1 system message, got {len(system_messages)}"
|
|
232
|
+
|
|
233
|
+
# It must be at index 0
|
|
234
|
+
assert context[0].get("role") == "system"
|
|
235
|
+
|
|
236
|
+
# It should include the merged conversation summary/history
|
|
237
|
+
assert "CONVERSATION SUMMARY" in context[0]["content"]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_bash_strips_testbed_hallucination(tmp_path):
|
|
241
|
+
"""Verify tools.py _bash automatically strips 'cd /testbed &&' prefixes."""
|
|
242
|
+
import subprocess
|
|
243
|
+
from codeagent.tools import _bash
|
|
244
|
+
|
|
245
|
+
captured_cmds = []
|
|
246
|
+
def fake_run(cmd, **kwargs):
|
|
247
|
+
captured_cmds.append(cmd)
|
|
248
|
+
mock = MagicMock()
|
|
249
|
+
mock.stdout = "OK"
|
|
250
|
+
mock.stderr = ""
|
|
251
|
+
mock.returncode = 0
|
|
252
|
+
return mock
|
|
253
|
+
|
|
254
|
+
with patch("subprocess.run", side_effect=fake_run):
|
|
255
|
+
_bash(tmp_path, "cd /testbed && python -m pytest test_file.py")
|
|
256
|
+
assert len(captured_cmds) == 1
|
|
257
|
+
executed_str = " ".join(captured_cmds[0])
|
|
258
|
+
assert "/testbed" not in executed_str
|
|
259
|
+
assert "python -m pytest test_file.py" in executed_str
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests verifying Phase 2 correctness and resilience fixes:
|
|
3
|
+
- BUG-03: context.py context cache and invalidation
|
|
4
|
+
- BUG-07: context.py raw history preservation and deep copy
|
|
5
|
+
- BUG-08: openai_backend.py partial stream JSON argument repair
|
|
6
|
+
- BUG-10: client.py multi-line SSE data accumulation and comment handling
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
import io
|
|
11
|
+
import json
|
|
12
|
+
from unittest.mock import MagicMock, patch
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from codeagent.context import ContextManager
|
|
16
|
+
from codeagent.backends.base import ChatResponse, ToolCall
|
|
17
|
+
from codeagent.backends.openai_backend import _repair_and_parse_json_arguments
|
|
18
|
+
from codeagent.client import AlpieCodeClient
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ==============================================================================
|
|
22
|
+
# BUG-07: Raw history preservation & deep copy
|
|
23
|
+
# ==============================================================================
|
|
24
|
+
|
|
25
|
+
def test_bug07_raw_history_never_mutated_by_rolling_compact():
|
|
26
|
+
"""Verify rolling_compact_old_tools truncates working messages while raw_history remains pristine."""
|
|
27
|
+
cm = ContextManager(max_tokens=8192)
|
|
28
|
+
cm.set_system_prompt("System prompt")
|
|
29
|
+
cm.add_user_message("Task 1")
|
|
30
|
+
|
|
31
|
+
# Turn 1
|
|
32
|
+
cm.add_assistant_response(ChatResponse(
|
|
33
|
+
content="Running tool",
|
|
34
|
+
reasoning=None,
|
|
35
|
+
tool_calls=[ToolCall(id="call_1", name="bash", arguments={"command": "cat long_file"})]
|
|
36
|
+
))
|
|
37
|
+
long_tool_output = "\n".join([f"Line {i}: This is long output that should be truncated in compaction." for i in range(50)])
|
|
38
|
+
cm.add_tool_result("call_1", long_tool_output, tool_name="bash")
|
|
39
|
+
|
|
40
|
+
# Turn 2
|
|
41
|
+
cm.add_assistant_response(ChatResponse(
|
|
42
|
+
content="Running second tool",
|
|
43
|
+
reasoning=None,
|
|
44
|
+
tool_calls=[ToolCall(id="call_2", name="bash", arguments={"command": "echo done"})]
|
|
45
|
+
))
|
|
46
|
+
cm.add_tool_result("call_2", "short output", tool_name="bash")
|
|
47
|
+
|
|
48
|
+
# Turn 3
|
|
49
|
+
cm.add_assistant_response(ChatResponse(
|
|
50
|
+
content="Final turn",
|
|
51
|
+
reasoning=None,
|
|
52
|
+
tool_calls=[]
|
|
53
|
+
))
|
|
54
|
+
|
|
55
|
+
# Before compaction, verify raw history contains the full 50 lines
|
|
56
|
+
raw_before = cm.all_messages
|
|
57
|
+
tool_msg_raw = [m for m in raw_before if m.get("role") == "tool" and m.get("tool_call_id") == "call_1"][0]
|
|
58
|
+
assert tool_msg_raw["content"] == long_tool_output
|
|
59
|
+
|
|
60
|
+
# Run rolling compact keeping only the last turn
|
|
61
|
+
cm.rolling_compact_old_tools(keep_last_turns=1)
|
|
62
|
+
|
|
63
|
+
# In active messages, call_1 output should now be truncated
|
|
64
|
+
active_msgs = cm._messages
|
|
65
|
+
tool_msg_active = [m for m in active_msgs if m.get("role") == "tool" and m.get("tool_call_id") == "call_1"][0]
|
|
66
|
+
assert "[Output: 50 lines truncated for brevity]" in tool_msg_active["content"]
|
|
67
|
+
|
|
68
|
+
# In all_messages and raw_history, it MUST remain completely intact
|
|
69
|
+
raw_after = cm.all_messages
|
|
70
|
+
tool_msg_raw_after = [m for m in raw_after if m.get("role") == "tool" and m.get("tool_call_id") == "call_1"][0]
|
|
71
|
+
assert tool_msg_raw_after["content"] == long_tool_output
|
|
72
|
+
|
|
73
|
+
raw_hist_after = cm.raw_history
|
|
74
|
+
tool_msg_hist_after = [m for m in raw_hist_after if m.get("role") == "tool" and m.get("tool_call_id") == "call_1"][0]
|
|
75
|
+
assert tool_msg_hist_after["content"] == long_tool_output
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_bug07_raw_history_deep_copy_isolation():
|
|
79
|
+
"""Verify mutating dictionaries in the returned raw_history list cannot corrupt ContextManager internals."""
|
|
80
|
+
cm = ContextManager(max_tokens=8192)
|
|
81
|
+
cm.set_system_prompt("System prompt")
|
|
82
|
+
cm.add_user_message("Original user message")
|
|
83
|
+
|
|
84
|
+
history = cm.raw_history
|
|
85
|
+
assert len(history) == 2
|
|
86
|
+
|
|
87
|
+
# Mutate the returned dictionary
|
|
88
|
+
history[1]["content"] = "CORRUPTED_IN_PLACE"
|
|
89
|
+
|
|
90
|
+
# Fetching history again should yield the unmodified original content
|
|
91
|
+
fresh_history = cm.raw_history
|
|
92
|
+
assert fresh_history[1]["content"] == "Original user message"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ==============================================================================
|
|
96
|
+
# BUG-03: Context cache and invalidation
|
|
97
|
+
# ==============================================================================
|
|
98
|
+
|
|
99
|
+
def test_bug03_context_cache_and_invalidation():
|
|
100
|
+
"""Verify build_context caches results and invalidates properly on every mutation."""
|
|
101
|
+
cm = ContextManager(max_tokens=8192)
|
|
102
|
+
assert cm._cached_context is None
|
|
103
|
+
|
|
104
|
+
cm.set_system_prompt("System prompt")
|
|
105
|
+
cm.add_user_message("User message 1")
|
|
106
|
+
assert cm._cached_context is None
|
|
107
|
+
|
|
108
|
+
# First access to messages populates cache
|
|
109
|
+
msgs_1 = cm.messages
|
|
110
|
+
assert cm._cached_context is not None
|
|
111
|
+
cached_ref = cm._cached_context
|
|
112
|
+
|
|
113
|
+
# Second access returns cached content without rebuilding
|
|
114
|
+
msgs_2 = cm.messages
|
|
115
|
+
assert msgs_1 == msgs_2
|
|
116
|
+
assert cm._cached_context is cached_ref
|
|
117
|
+
|
|
118
|
+
# Adding assistant response must invalidate cache
|
|
119
|
+
cm.add_assistant_response(ChatResponse(content="Response 1", reasoning=None, tool_calls=[]))
|
|
120
|
+
assert cm._cached_context is None
|
|
121
|
+
|
|
122
|
+
# Accessing messages repopulates cache
|
|
123
|
+
msgs_3 = cm.messages
|
|
124
|
+
assert len(msgs_3) == 3
|
|
125
|
+
assert cm._cached_context is not None
|
|
126
|
+
|
|
127
|
+
# Adding tool result must invalidate cache
|
|
128
|
+
cm.add_tool_result("id_1", "output 1")
|
|
129
|
+
assert cm._cached_context is None
|
|
130
|
+
|
|
131
|
+
# Adding user message must invalidate cache
|
|
132
|
+
cm.messages # populate
|
|
133
|
+
assert cm._cached_context is not None
|
|
134
|
+
cm.add_user_message("User message 2")
|
|
135
|
+
assert cm._cached_context is None
|
|
136
|
+
|
|
137
|
+
# Reassigning messages setter must invalidate cache
|
|
138
|
+
cm.messages # populate
|
|
139
|
+
assert cm._cached_context is not None
|
|
140
|
+
cm.messages = [{"role": "system", "content": "New sys"}]
|
|
141
|
+
assert cm._cached_context is None
|
|
142
|
+
assert len(cm.messages) == 1
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ==============================================================================
|
|
146
|
+
# BUG-08: Partial stream JSON argument repair
|
|
147
|
+
# ==============================================================================
|
|
148
|
+
|
|
149
|
+
def test_bug08_partial_json_repair_clean():
|
|
150
|
+
"""Verify clean JSON parses directly."""
|
|
151
|
+
res = _repair_and_parse_json_arguments('{"command": "pytest -v", "timeout": 30}')
|
|
152
|
+
assert res == {"command": "pytest -v", "timeout": 30}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_bug08_partial_json_repair_truncated_quote_and_brace():
|
|
156
|
+
"""Verify truncated JSON missing closing quotes and braces is automatically repaired."""
|
|
157
|
+
# Split mid-string: '{"command": "python -m pytest test.py'
|
|
158
|
+
res = _repair_and_parse_json_arguments('{"command": "python -m pytest test.py')
|
|
159
|
+
assert res == {"command": "python -m pytest test.py"}
|
|
160
|
+
|
|
161
|
+
# Missing closing brace only: '{"path": "foo.py", "content": "abc"}'
|
|
162
|
+
res2 = _repair_and_parse_json_arguments('{"path": "foo.py", "content": "abc"')
|
|
163
|
+
assert res2 == {"path": "foo.py", "content": "abc"}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_bug08_partial_json_repair_trailing_comma():
|
|
167
|
+
"""Verify trailing commas are stripped before parsing."""
|
|
168
|
+
res = _repair_and_parse_json_arguments('{"path": "foo.py",}')
|
|
169
|
+
assert res == {"path": "foo.py"}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_bug08_partial_json_repair_python_syntax():
|
|
173
|
+
"""Verify Python single-quoted dict strings are parsed via literal_eval."""
|
|
174
|
+
res = _repair_and_parse_json_arguments("{'command': 'ls -la', 'active': True}")
|
|
175
|
+
assert res == {"command": "ls -la", "active": True}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_bug08_partial_json_unparseable_preserves_error():
|
|
179
|
+
"""Verify completely unparseable input preserves raw arguments and parse error rather than silent empty dict."""
|
|
180
|
+
res = _repair_and_parse_json_arguments("invalid garbage text without json structure")
|
|
181
|
+
assert "_raw_arguments" in res
|
|
182
|
+
assert res["_raw_arguments"] == "invalid garbage text without json structure"
|
|
183
|
+
assert "_parse_error" in res
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_bug08_partial_json_empty():
|
|
187
|
+
"""Verify empty or whitespace string returns empty dict."""
|
|
188
|
+
assert _repair_and_parse_json_arguments("") == {}
|
|
189
|
+
assert _repair_and_parse_json_arguments(" ") == {}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ==============================================================================
|
|
193
|
+
# BUG-10: Multi-line SSE parser accumulation
|
|
194
|
+
# ==============================================================================
|
|
195
|
+
|
|
196
|
+
def test_bug10_multiline_sse_parser():
|
|
197
|
+
"""Verify stream_chat correctly accumulates multi-line data fields and ignores comments."""
|
|
198
|
+
# Construct raw SSE stream simulating multi-line JSON payload and SSE comments
|
|
199
|
+
sse_payload = (
|
|
200
|
+
b": keepalive ping\n"
|
|
201
|
+
b"event: message\n"
|
|
202
|
+
b"data: {\"role\": \"assistant\",\n"
|
|
203
|
+
b"data: \"content\": \"hello\\nworld\"}\n"
|
|
204
|
+
b"\n"
|
|
205
|
+
b": another comment\n"
|
|
206
|
+
b"event: token\n"
|
|
207
|
+
b"data: {\"delta\": \" next token\"}\n"
|
|
208
|
+
b"\n"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
mock_resp = MagicMock()
|
|
212
|
+
mock_resp.__enter__.return_value = io.BytesIO(sse_payload)
|
|
213
|
+
mock_resp.__exit__.return_value = False
|
|
214
|
+
|
|
215
|
+
client = AlpieCodeClient(base_url="http://localhost:8000")
|
|
216
|
+
|
|
217
|
+
with patch("urllib.request.urlopen", return_value=mock_resp):
|
|
218
|
+
events = list(client.stream_chat(task="test multiline"))
|
|
219
|
+
|
|
220
|
+
assert len(events) == 2
|
|
221
|
+
|
|
222
|
+
# First event: multi-line message
|
|
223
|
+
evt1 = events[0]
|
|
224
|
+
assert evt1.type == "message"
|
|
225
|
+
assert evt1.data["role"] == "assistant"
|
|
226
|
+
assert evt1.data["content"] == "hello\nworld"
|
|
227
|
+
|
|
228
|
+
# Second event: token
|
|
229
|
+
evt2 = events[1]
|
|
230
|
+
assert evt2.type == "token"
|
|
231
|
+
assert evt2.data["delta"] == " next token"
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests verifying Phase 3 performance optimizations:
|
|
3
|
+
- PERF-01: context.py distant summary lazy caching
|
|
4
|
+
- PERF-06: tools.py _list_files directory pruning and early termination
|
|
5
|
+
- PERF-05: memory.py batched disk writes and deduplication
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from unittest.mock import MagicMock, patch
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from codeagent.context import ContextManager
|
|
14
|
+
from codeagent.backends.base import ChatResponse, ToolCall
|
|
15
|
+
from codeagent.tools import _list_files
|
|
16
|
+
import codeagent.memory as memory_mod
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ==============================================================================
|
|
20
|
+
# PERF-01: Distant summary lazy caching
|
|
21
|
+
# ==============================================================================
|
|
22
|
+
|
|
23
|
+
def test_perf01_summary_lazy_caching():
|
|
24
|
+
"""Verify distant summary is lazy-cached and not re-computed when distant count is unchanged."""
|
|
25
|
+
cm = ContextManager(max_tokens=8192)
|
|
26
|
+
cm.set_system_prompt("Base system prompt")
|
|
27
|
+
|
|
28
|
+
# Add 6 turns
|
|
29
|
+
for i in range(6):
|
|
30
|
+
cm.add_user_message(f"User turn {i}")
|
|
31
|
+
cm.add_assistant_response(ChatResponse(
|
|
32
|
+
content=f"Assistant turn {i}",
|
|
33
|
+
reasoning=None,
|
|
34
|
+
tool_calls=[ToolCall(id=f"c_{i}", name="bash", arguments={"command": f"echo {i}"})]
|
|
35
|
+
))
|
|
36
|
+
cm.add_tool_result(f"c_{i}", f"output {i}", tool_name="bash")
|
|
37
|
+
|
|
38
|
+
# First build with recent_turns=2 (creating distant messages)
|
|
39
|
+
context_1 = cm.build_context(recent_turns=2)
|
|
40
|
+
assert cm._cached_summary is not None
|
|
41
|
+
cached_summary = cm._cached_summary
|
|
42
|
+
assert cm._cached_summary_len > 0
|
|
43
|
+
|
|
44
|
+
# Next call with same distant turns should reuse the cached summary without calling extract_conversation_summary
|
|
45
|
+
with patch("codeagent.context.extract_conversation_summary", side_effect=RuntimeError("Should NOT be called")):
|
|
46
|
+
context_2 = cm.build_context(recent_turns=2)
|
|
47
|
+
assert context_2 == context_1
|
|
48
|
+
assert cm._cached_summary == cached_summary
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ==============================================================================
|
|
52
|
+
# PERF-06: _list_files directory pruning and short-circuit
|
|
53
|
+
# ==============================================================================
|
|
54
|
+
|
|
55
|
+
def test_perf06_list_files_pruning(tmp_path):
|
|
56
|
+
"""Verify _list_files prunes node_modules, .venv, __pycache__, and hidden dirs."""
|
|
57
|
+
# Create valid project file
|
|
58
|
+
src_dir = tmp_path / "src"
|
|
59
|
+
src_dir.mkdir()
|
|
60
|
+
(src_dir / "index.py").write_text("print('hello')", encoding="utf-8")
|
|
61
|
+
|
|
62
|
+
# Create directories that MUST be pruned
|
|
63
|
+
node_modules = tmp_path / "node_modules" / "pkg"
|
|
64
|
+
node_modules.mkdir(parents=True)
|
|
65
|
+
(node_modules / "index.js").write_text("module.exports = {}", encoding="utf-8")
|
|
66
|
+
|
|
67
|
+
venv = tmp_path / ".venv" / "lib"
|
|
68
|
+
venv.mkdir(parents=True)
|
|
69
|
+
(venv / "site.py").write_text("# venv file", encoding="utf-8")
|
|
70
|
+
|
|
71
|
+
pycache = tmp_path / "__pycache__"
|
|
72
|
+
pycache.mkdir()
|
|
73
|
+
(pycache / "index.pyc").write_text("bytes", encoding="utf-8")
|
|
74
|
+
|
|
75
|
+
result = _list_files(tmp_path, max_depth=4)
|
|
76
|
+
|
|
77
|
+
# Valid file present
|
|
78
|
+
assert "src/index.py" in result or "src\\index.py" in result
|
|
79
|
+
|
|
80
|
+
# Pruned directories absent
|
|
81
|
+
assert "node_modules" not in result
|
|
82
|
+
assert ".venv" not in result
|
|
83
|
+
assert "__pycache__" not in result
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_perf06_list_files_short_circuit(tmp_path):
|
|
87
|
+
"""Verify _list_files stops walking after collecting 200 entries."""
|
|
88
|
+
# Create 250 files
|
|
89
|
+
for i in range(250):
|
|
90
|
+
(tmp_path / f"file_{i:03d}.txt").write_text("content", encoding="utf-8")
|
|
91
|
+
|
|
92
|
+
result = _list_files(tmp_path, max_depth=2)
|
|
93
|
+
lines = result.splitlines()
|
|
94
|
+
assert len(lines) <= 200
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ==============================================================================
|
|
98
|
+
# PERF-05: Batched memory disk writes
|
|
99
|
+
# ==============================================================================
|
|
100
|
+
|
|
101
|
+
def test_perf05_batched_memory_saves(tmp_path, monkeypatch):
|
|
102
|
+
"""Verify save_memories_batch performs a single atomic disk update with deduplication and FIFO limits."""
|
|
103
|
+
mem_dir = tmp_path / "memories"
|
|
104
|
+
monkeypatch.setattr(memory_mod, "MEMORY_DIR", mem_dir)
|
|
105
|
+
|
|
106
|
+
workdir = tmp_path / "project"
|
|
107
|
+
workdir.mkdir()
|
|
108
|
+
|
|
109
|
+
entries = [
|
|
110
|
+
{"content": "Working test command: pytest -v", "type": "command"},
|
|
111
|
+
{"content": "Working build command: npm run build", "type": "command"},
|
|
112
|
+
{"content": "Working test command: pytest -v", "type": "command"}, # Duplicate
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
memory_mod.save_memories_batch(workdir, entries)
|
|
116
|
+
|
|
117
|
+
saved = memory_mod.load_memories(workdir)
|
|
118
|
+
assert len(saved) == 2 # Deduplicated from 3 to 2
|
|
119
|
+
contents = [m["content"] for m in saved]
|
|
120
|
+
assert "Working test command: pytest -v" in contents
|
|
121
|
+
assert "Working build command: npm run build" in contents
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_perf05_extract_and_save_memories_single_batch(tmp_path, monkeypatch):
|
|
125
|
+
"""Verify extract_and_save_memories aggregates all discovered items into exactly one batch call."""
|
|
126
|
+
mem_dir = tmp_path / "memories"
|
|
127
|
+
monkeypatch.setattr(memory_mod, "MEMORY_DIR", mem_dir)
|
|
128
|
+
|
|
129
|
+
workdir = tmp_path / "project"
|
|
130
|
+
workdir.mkdir()
|
|
131
|
+
|
|
132
|
+
messages = [
|
|
133
|
+
{
|
|
134
|
+
"role": "assistant",
|
|
135
|
+
"tool_calls": [
|
|
136
|
+
{"id": "c1", "type": "function", "function": {"name": "bash", "arguments": json.dumps({"command": "pytest -v"})}}
|
|
137
|
+
]
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"role": "tool",
|
|
141
|
+
"tool_call_id": "c1",
|
|
142
|
+
"content": '{"exit_code": 0, "stdout": "All tests passed"}'
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"role": "assistant",
|
|
146
|
+
"content": "DONE: Task completed successfully"
|
|
147
|
+
}
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
with patch.object(memory_mod, "save_memories_batch", wraps=memory_mod.save_memories_batch) as mock_batch:
|
|
151
|
+
memory_mod.extract_and_save_memories(workdir, messages)
|
|
152
|
+
assert mock_batch.call_count == 1
|
|
153
|
+
args, _ = mock_batch.call_args
|
|
154
|
+
assert len(args[1]) == 2 # 1 test command + 1 completion summary
|
|
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
|