alpiecode 8.0.9__tar.gz → 9.0.1__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.9 → alpiecode-9.0.1}/PKG-INFO +1 -1
- {alpiecode-8.0.9 → alpiecode-9.0.1}/pyproject.toml +1 -1
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/alpiecode.egg-info/PKG-INFO +1 -1
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/alpiecode.egg-info/SOURCES.txt +3 -1
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/__init__.py +1 -1
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/backends/local_backend.py +55 -1
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/backends/openai_backend.py +97 -11
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/client.py +19 -11
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/context.py +65 -17
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/extension/alpiecode.vsix +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/memory.py +48 -16
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/orchestrator.py +48 -17
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/prompt.py +2 -2
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/tools.py +17 -3
- {alpiecode-8.0.9 → alpiecode-9.0.1}/test/test_phase1.py +56 -1
- alpiecode-9.0.1/test/test_phase2.py +231 -0
- alpiecode-9.0.1/test/test_phase3.py +154 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/README.md +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/setup.cfg +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/alpiecode/__init__.py +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/alpiecode.egg-info/dependency_links.txt +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/alpiecode.egg-info/entry_points.txt +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/alpiecode.egg-info/requires.txt +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/alpiecode.egg-info/top_level.txt +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/agent.py +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/backends/__init__.py +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/backends/base.py +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/cache.py +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/cli.py +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/compaction.py +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/config.py +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/discovery.py +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/doctor.py +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/executor.py +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/git_ops.py +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/github.py +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/guardian.py +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/guardrails.py +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/ipython_ext.py +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/local_model.py +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/media.py +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/progress.py +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/rephraser.py +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/server.py +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/session.py +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/src/codeagent/updater.py +0 -0
- {alpiecode-8.0.9 → alpiecode-9.0.1}/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.1"
|
|
20
20
|
|
|
21
21
|
def run(task: str, workdir: str = ".", reasoning_level: str = "thinking", **kwargs):
|
|
22
22
|
"""Run an autonomous coding task programmatically."""
|
|
@@ -43,6 +43,59 @@ class LocalBackend:
|
|
|
43
43
|
model = self._ensure_model()
|
|
44
44
|
model.load()
|
|
45
45
|
|
|
46
|
+
@staticmethod
|
|
47
|
+
def _sanitize_messages_for_gguf(messages: List[dict]) -> List[dict]:
|
|
48
|
+
"""
|
|
49
|
+
Sanitize OpenAI-format messages for GGUF Jinja2 chat templates.
|
|
50
|
+
Fixes 'Can only get item pairs from a mapping' errors by:
|
|
51
|
+
- Ensuring content is always a string (never None or list)
|
|
52
|
+
- Stripping tool_calls and tool messages (GGUF templates don't support them)
|
|
53
|
+
- Removing tool-related role messages
|
|
54
|
+
"""
|
|
55
|
+
sanitized = []
|
|
56
|
+
for msg in messages:
|
|
57
|
+
role = msg.get("role", "")
|
|
58
|
+
# Skip tool result messages entirely — GGUF models don't understand them
|
|
59
|
+
if role == "tool":
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
clean = {"role": role}
|
|
63
|
+
|
|
64
|
+
# Ensure content is always a string
|
|
65
|
+
content = msg.get("content")
|
|
66
|
+
if content is None:
|
|
67
|
+
content = ""
|
|
68
|
+
elif isinstance(content, list):
|
|
69
|
+
# Multimodal content (image_url + text blocks) — extract text parts
|
|
70
|
+
text_parts = []
|
|
71
|
+
for part in content:
|
|
72
|
+
if isinstance(part, dict):
|
|
73
|
+
if part.get("type") == "text":
|
|
74
|
+
text_parts.append(part.get("text", ""))
|
|
75
|
+
elif isinstance(part, str):
|
|
76
|
+
text_parts.append(part)
|
|
77
|
+
content = "\n".join(text_parts) if text_parts else ""
|
|
78
|
+
elif not isinstance(content, str):
|
|
79
|
+
content = str(content)
|
|
80
|
+
|
|
81
|
+
# For assistant messages with tool_calls, embed tool call info in content text
|
|
82
|
+
if role == "assistant" and msg.get("tool_calls"):
|
|
83
|
+
tc_descriptions = []
|
|
84
|
+
for tc in msg["tool_calls"]:
|
|
85
|
+
if isinstance(tc, dict):
|
|
86
|
+
fn = tc.get("function", {})
|
|
87
|
+
name = fn.get("name", "unknown")
|
|
88
|
+
args = fn.get("arguments", "{}")
|
|
89
|
+
tc_descriptions.append(f"[Called tool: {name}({args})]")
|
|
90
|
+
if tc_descriptions:
|
|
91
|
+
content = (content + "\n" + "\n".join(tc_descriptions)).strip()
|
|
92
|
+
# Do NOT include tool_calls key — GGUF template will crash on it
|
|
93
|
+
|
|
94
|
+
clean["content"] = content
|
|
95
|
+
sanitized.append(clean)
|
|
96
|
+
|
|
97
|
+
return sanitized
|
|
98
|
+
|
|
46
99
|
def chat_completion(
|
|
47
100
|
self,
|
|
48
101
|
messages: List[dict],
|
|
@@ -52,8 +105,9 @@ class LocalBackend:
|
|
|
52
105
|
enable_thinking: bool = True,
|
|
53
106
|
) -> ChatResponse:
|
|
54
107
|
model = self._ensure_model()
|
|
108
|
+
sanitized_msgs = self._sanitize_messages_for_gguf(messages)
|
|
55
109
|
resp = model.create_chat_completion(
|
|
56
|
-
messages=
|
|
110
|
+
messages=sanitized_msgs,
|
|
57
111
|
tools=tools,
|
|
58
112
|
tool_choice="auto",
|
|
59
113
|
temperature=temperature,
|
|
@@ -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)
|