alpiecode 8.0.6__tar.gz → 8.0.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {alpiecode-8.0.6 → alpiecode-8.0.7}/PKG-INFO +1 -1
  2. {alpiecode-8.0.6 → alpiecode-8.0.7}/pyproject.toml +1 -1
  3. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/alpiecode.egg-info/PKG-INFO +1 -1
  4. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/__init__.py +1 -1
  5. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/compaction.py +139 -4
  6. alpiecode-8.0.7/src/codeagent/context.py +361 -0
  7. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/discovery.py +91 -0
  8. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/executor.py +76 -0
  9. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/extension/alpiecode.vsix +0 -0
  10. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/guardrails.py +66 -0
  11. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/orchestrator.py +40 -6
  12. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/progress.py +10 -2
  13. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/prompt.py +9 -0
  14. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/rephraser.py +13 -1
  15. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/session.py +1 -0
  16. alpiecode-8.0.6/src/codeagent/context.py +0 -106
  17. {alpiecode-8.0.6 → alpiecode-8.0.7}/README.md +0 -0
  18. {alpiecode-8.0.6 → alpiecode-8.0.7}/setup.cfg +0 -0
  19. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/alpiecode/__init__.py +0 -0
  20. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/alpiecode.egg-info/SOURCES.txt +0 -0
  21. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/alpiecode.egg-info/dependency_links.txt +0 -0
  22. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/alpiecode.egg-info/entry_points.txt +0 -0
  23. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/alpiecode.egg-info/requires.txt +0 -0
  24. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/alpiecode.egg-info/top_level.txt +0 -0
  25. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/agent.py +0 -0
  26. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/backends/__init__.py +0 -0
  27. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/backends/base.py +0 -0
  28. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/backends/local_backend.py +0 -0
  29. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/backends/openai_backend.py +0 -0
  30. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/cache.py +0 -0
  31. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/cli.py +0 -0
  32. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/client.py +0 -0
  33. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/config.py +0 -0
  34. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/doctor.py +0 -0
  35. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/git_ops.py +0 -0
  36. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/github.py +0 -0
  37. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/guardian.py +0 -0
  38. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/ipython_ext.py +0 -0
  39. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/local_model.py +0 -0
  40. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/media.py +0 -0
  41. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/memory.py +0 -0
  42. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/server.py +0 -0
  43. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/tools.py +0 -0
  44. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/updater.py +0 -0
  45. {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/vscode_installer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpiecode
3
- Version: 8.0.6
3
+ Version: 8.0.7
4
4
  Summary: AlpieCode — Autonomous AI Coding Agent CLI powered by local 169Pi GGUF VLM
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: huggingface_hub>=0.20.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "alpiecode"
7
- version = "8.0.6"
7
+ version = "8.0.7"
8
8
  description = "AlpieCode — Autonomous AI Coding Agent CLI powered by local 169Pi GGUF VLM"
9
9
  requires-python = ">=3.9"
10
10
  dependencies = [
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpiecode
3
- Version: 8.0.6
3
+ Version: 8.0.7
4
4
  Summary: AlpieCode — Autonomous AI Coding Agent CLI powered by local 169Pi GGUF VLM
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: huggingface_hub>=0.20.0
@@ -16,7 +16,7 @@ Supports:
16
16
  from pathlib import Path
17
17
  from typing import Optional, Any, Dict
18
18
 
19
- __version__ = "8.0.4"
19
+ __version__ = "8.0.7"
20
20
 
21
21
 
22
22
  def run(task: str, workdir: str = ".", reasoning_level: str = "thinking", **kwargs):
@@ -10,10 +10,12 @@ Strategy:
10
10
  - Summarize older tool calls and results into compact descriptions
11
11
  - Preserve all user messages verbatim
12
12
  - Track approximate token count using a simple heuristic (4 chars ≈ 1 token)
13
+ - Generate structured conversation summaries and extract relevant history
14
+ for the decoupled ContextManager build_context() pipeline.
13
15
  """
14
16
 
15
17
  import json
16
- from typing import List
18
+ from typing import Any, Dict, List, Optional
17
19
 
18
20
  # Our model's context window
19
21
  MAX_CONTEXT_TOKENS = 262_144
@@ -33,14 +35,16 @@ def estimate_tokens(messages: List[dict]) -> int:
33
35
  content = msg.get("content") or ""
34
36
  if isinstance(content, str):
35
37
  total_chars += len(content)
38
+ elif isinstance(content, list):
39
+ total_chars += sum(len(str(c)) for c in content)
36
40
  # Account for tool call arguments
37
41
  tool_calls = msg.get("tool_calls", [])
38
42
  if tool_calls:
39
43
  for tc in tool_calls:
40
44
  if isinstance(tc, dict):
41
45
  fn = tc.get("function", {})
42
- total_chars += len(fn.get("arguments", ""))
43
- total_chars += len(fn.get("name", ""))
46
+ total_chars += len(str(fn.get("arguments", "")))
47
+ total_chars += len(str(fn.get("name", "")))
44
48
  return total_chars // CHARS_PER_TOKEN
45
49
 
46
50
 
@@ -83,6 +87,138 @@ def _summarize_tool_result(tool_name: str, content: str) -> str:
83
87
  return content[:250] + f"... ({len(content)} chars total)"
84
88
 
85
89
 
90
+ def extract_conversation_summary(
91
+ messages: List[dict],
92
+ metadata: Optional[List[dict]] = None,
93
+ ) -> str:
94
+ """
95
+ Extract a concise, structured markdown summary of historical turns.
96
+
97
+ Extracts:
98
+ - Initial user task / goal
99
+ - Key files created, edited, or inspected
100
+ - Commands executed and their success/failure
101
+ - Execution plan status
102
+ - Crucial findings or errors encountered
103
+ """
104
+ if not messages:
105
+ return ""
106
+
107
+ initial_task = ""
108
+ files_touched = set()
109
+ commands_run = []
110
+ latest_plan = ""
111
+ key_events = []
112
+
113
+ for i, msg in enumerate(messages):
114
+ role = msg.get("role", "")
115
+ content = msg.get("content") or ""
116
+
117
+ # Extract initial goal from first non-system user message
118
+ if role == "user" and not initial_task:
119
+ if isinstance(content, str) and content.strip():
120
+ lines = content.strip().splitlines()
121
+ initial_task = lines[0][:150]
122
+ if len(lines[0]) > 150:
123
+ initial_task += "..."
124
+
125
+ # Assistant tool calls
126
+ if role == "assistant" and msg.get("tool_calls"):
127
+ for tc in msg.get("tool_calls", []):
128
+ tc_dict = tc if isinstance(tc, dict) else {}
129
+ fn = tc_dict.get("function", {})
130
+ name = fn.get("name", "")
131
+ args_raw = fn.get("arguments", "{}")
132
+ try:
133
+ args = json.loads(args_raw) if isinstance(args_raw, str) else args_raw
134
+ except Exception:
135
+ args = {}
136
+
137
+ if name in ("write_file", "patch_file", "read_file"):
138
+ p = args.get("path") or args.get("file_path")
139
+ if p:
140
+ files_touched.add(f"{name}:{p}")
141
+ elif name == "bash":
142
+ cmd = args.get("command", "")
143
+ if cmd:
144
+ commands_run.append(cmd[:80])
145
+
146
+ # Tool outputs
147
+ if role == "tool":
148
+ content_str = str(content)
149
+ if "[Plan updated]" in content_str or "Plan Status:" in content_str or "## Implementation Plan" in content_str:
150
+ latest_plan = content_str[-500:] # Keep latest plan snapshot
151
+ elif "exit_code" in content_str and '"exit_code": 0' not in content_str:
152
+ # Capture error indication
153
+ key_events.append("Command execution returned an error (addressed in subsequent turns)")
154
+
155
+ summary_lines = []
156
+ if initial_task:
157
+ summary_lines.append(f"- **Initial Goal**: {initial_task}")
158
+ if files_touched:
159
+ files_preview = ", ".join(list(files_touched)[:6])
160
+ if len(files_touched) > 6:
161
+ files_preview += f" (+{len(files_touched) - 6} more)"
162
+ summary_lines.append(f"- **Files Touched**: {files_preview}")
163
+ if commands_run:
164
+ recent_cmds = ", ".join(commands_run[-4:])
165
+ summary_lines.append(f"- **Recent Commands Executed**: {recent_cmds}")
166
+ if latest_plan:
167
+ summary_lines.append(f"- **Execution Plan Snapshot**:\n {latest_plan.strip()}")
168
+ if key_events:
169
+ summary_lines.append(f"- **Notes**: {key_events[-1]}")
170
+
171
+ if not summary_lines:
172
+ return ""
173
+
174
+ return "### Conversation Progress Summary (Prior Turns):\n" + "\n".join(summary_lines)
175
+
176
+
177
+ def select_relevant_history(
178
+ messages: List[dict],
179
+ metadata: Optional[List[dict]] = None,
180
+ current_query: str = "",
181
+ token_budget: int = 2048,
182
+ ) -> str:
183
+ """
184
+ Select high-value historical anchors from distant history (e.g. plan updates,
185
+ key decisions, important command outcomes) formatted as concise contextual notes.
186
+ """
187
+ if not messages:
188
+ return ""
189
+
190
+ high_value_notes = []
191
+ seen_plans = set()
192
+
193
+ # Search backwards for high-importance messages
194
+ for i in range(len(messages) - 1, -1, -1):
195
+ msg = messages[i]
196
+ role = msg.get("role", "")
197
+ content = str(msg.get("content") or "")
198
+
199
+ # High priority: update_plan outputs
200
+ if role == "tool" and ("[Plan updated]" in content or "Plan Status:" in content):
201
+ if "plan" not in seen_plans:
202
+ seen_plans.add("plan")
203
+ lines = [l.strip() for l in content.splitlines() if l.strip() and not l.startswith("```")]
204
+ snippet = "\n".join(lines[:8])
205
+ high_value_notes.append(f"- **Active Plan Anchor**:\n{snippet}")
206
+
207
+ # High priority: stall advice or system corrections
208
+ if role == "user" and ("[STALL DETECTED]" in content or "[SYSTEM]" in content):
209
+ high_value_notes.append(f"- **System Intervention**: {content[:200]}")
210
+
211
+ # If we have enough context, stop
212
+ total_len = sum(len(n) for n in high_value_notes)
213
+ if (total_len // CHARS_PER_TOKEN) >= token_budget:
214
+ break
215
+
216
+ if not high_value_notes:
217
+ return ""
218
+
219
+ return "### Relevant Historical Anchors:\n" + "\n".join(reversed(high_value_notes))
220
+
221
+
86
222
  def compact_messages(messages: List[dict]) -> List[dict]:
87
223
  """
88
224
  Compact a message list by summarizing older turns.
@@ -109,7 +245,6 @@ def compact_messages(messages: List[dict]) -> List[dict]:
109
245
 
110
246
  # Build a compacted summary of old messages
111
247
  compacted_old = []
112
- summary_parts = []
113
248
 
114
249
  for msg in old_messages:
115
250
  role = msg.get("role", "")
@@ -0,0 +1,361 @@
1
+ """
2
+ Conversation context manager for AlpieCode.
3
+
4
+ Manages the complete conversation history (_messages), message metadata (_metadata),
5
+ and dynamically assembles token-budgeted context windows via build_context()
6
+ for OpenAI-compatible chat completion endpoints.
7
+ """
8
+
9
+ import json
10
+ import time
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from .backends.base import ChatResponse, ToolCall
14
+ from .compaction import (
15
+ CHARS_PER_TOKEN,
16
+ compact_messages,
17
+ estimate_tokens,
18
+ extract_conversation_summary,
19
+ needs_compaction,
20
+ select_relevant_history,
21
+ )
22
+
23
+
24
+ def _serialize_assistant_message(msg: ChatResponse) -> dict:
25
+ """Serialize a ChatResponse into OpenAI chat message dict format."""
26
+ result: Dict[str, Any] = {"role": "assistant"}
27
+ result["content"] = msg.content if msg.content else None
28
+
29
+ if msg.tool_calls:
30
+ result["tool_calls"] = [
31
+ {
32
+ "id": tc.id,
33
+ "type": "function",
34
+ "function": {
35
+ "name": tc.name,
36
+ "arguments": json.dumps(tc.arguments) if isinstance(tc.arguments, dict) else str(tc.arguments or "{}"),
37
+ },
38
+ }
39
+ for tc in msg.tool_calls
40
+ ]
41
+ return result
42
+
43
+
44
+ class ContextManager:
45
+ """
46
+ Decoupled context manager maintaining:
47
+ - _messages: Complete, uncompressed OpenAI-compatible message history.
48
+ - _metadata: Parallel metadata tracking per-message (tokens, turn, timestamp, importance, tool info).
49
+ - build_context(): 5-layer context assembly pipeline for OpenAI API calls.
50
+ """
51
+
52
+ def __init__(self, max_tokens: int = 262_144):
53
+ self.max_tokens = max_tokens
54
+ self._messages: List[dict] = []
55
+ self._metadata: List[Dict[str, Any]] = []
56
+ self._turn_counter: int = 0
57
+ self._cached_summary: Optional[str] = None
58
+
59
+ def _estimate_single_message_tokens(self, msg: dict) -> int:
60
+ """Estimate token count for an individual message."""
61
+ chars = 0
62
+ content = msg.get("content")
63
+ if isinstance(content, str):
64
+ chars += len(content)
65
+ elif isinstance(content, list):
66
+ chars += sum(len(str(c)) for c in content)
67
+
68
+ tool_calls = msg.get("tool_calls", [])
69
+ if tool_calls:
70
+ for tc in tool_calls:
71
+ if isinstance(tc, dict):
72
+ fn = tc.get("function", {})
73
+ chars += len(str(fn.get("name", "")))
74
+ chars += len(str(fn.get("arguments", "")))
75
+ return max(1, chars // CHARS_PER_TOKEN)
76
+
77
+ def _build_metadata_entry(
78
+ self,
79
+ msg: dict,
80
+ tool_name: Optional[str] = None,
81
+ importance: Optional[str] = None,
82
+ custom: Optional[dict] = None,
83
+ ) -> Dict[str, Any]:
84
+ """Construct a structured metadata dict for a message."""
85
+ role = msg.get("role", "unknown")
86
+ tokens = self._estimate_single_message_tokens(msg)
87
+ content_str = str(msg.get("content") or "")
88
+
89
+ # Infer tool name if not provided
90
+ resolved_tool_name = tool_name
91
+ if role == "tool" and not resolved_tool_name:
92
+ t_id = msg.get("tool_call_id", "")
93
+ for prev in reversed(self._messages):
94
+ if prev.get("role") == "assistant" and prev.get("tool_calls"):
95
+ for tc in prev["tool_calls"]:
96
+ tc_dict = tc if isinstance(tc, dict) else {}
97
+ if tc_dict.get("id") == t_id:
98
+ resolved_tool_name = tc_dict.get("function", {}).get("name")
99
+ break
100
+ if resolved_tool_name:
101
+ break
102
+
103
+ # Infer importance
104
+ resolved_importance = importance
105
+ if not resolved_importance:
106
+ if role == "system":
107
+ resolved_importance = "high"
108
+ elif role == "user":
109
+ resolved_importance = "high" if "[STALL DETECTED]" in content_str or "[SYSTEM]" in content_str else "normal"
110
+ elif role == "tool":
111
+ if resolved_tool_name == "update_plan" or "[Plan updated]" in content_str or "Plan Status:" in content_str:
112
+ resolved_importance = "high"
113
+ elif "exit_code" in content_str and '"exit_code": 0' not in content_str:
114
+ resolved_importance = "high"
115
+ elif resolved_tool_name in ("read_file", "list_files") and len(content_str) > 1000:
116
+ resolved_importance = "low"
117
+ else:
118
+ resolved_importance = "normal"
119
+ else:
120
+ resolved_importance = "normal"
121
+
122
+ entry: Dict[str, Any] = {
123
+ "turn": self._turn_counter,
124
+ "timestamp": time.time(),
125
+ "role": role,
126
+ "tokens": tokens,
127
+ "tool_name": resolved_tool_name,
128
+ "tool_call_id": msg.get("tool_call_id"),
129
+ "importance": resolved_importance,
130
+ "is_summary": False,
131
+ }
132
+ if custom:
133
+ entry.update(custom)
134
+ return entry
135
+
136
+ def _rebuild_metadata(self) -> None:
137
+ """Rebuild _metadata to maintain 1-to-1 sync if _messages is reassigned."""
138
+ self._metadata = [
139
+ self._build_metadata_entry(m) for m in self._messages
140
+ ]
141
+
142
+ def set_system_prompt(self, prompt: str) -> None:
143
+ """Set or update the root system prompt (Layer 1)."""
144
+ sys_msg = {"role": "system", "content": prompt}
145
+ if self._messages and self._messages[0].get("role") == "system":
146
+ self._messages[0] = sys_msg
147
+ if self._metadata:
148
+ self._metadata[0] = self._build_metadata_entry(sys_msg, importance="high")
149
+ else:
150
+ self._messages.insert(0, sys_msg)
151
+ self._metadata.insert(0, self._build_metadata_entry(sys_msg, importance="high"))
152
+
153
+ def add_user_message(self, content: Any, metadata: Optional[dict] = None) -> None:
154
+ """Append a user request and record metadata."""
155
+ self._turn_counter += 1
156
+ msg = {"role": "user", "content": content}
157
+ self._messages.append(msg)
158
+ self._metadata.append(self._build_metadata_entry(msg, custom=metadata))
159
+
160
+ def add_assistant_response(self, response: ChatResponse, metadata: Optional[dict] = None) -> None:
161
+ """Append an assistant response with tool calls and record metadata."""
162
+ if response.tool_calls or response.content:
163
+ msg = _serialize_assistant_message(response)
164
+ self._messages.append(msg)
165
+ tool_names = [tc.name for tc in response.tool_calls] if response.tool_calls else []
166
+ custom_meta = metadata.copy() if metadata else {}
167
+ custom_meta["tool_names"] = tool_names
168
+ custom_meta["has_tools"] = bool(response.tool_calls)
169
+ self._metadata.append(self._build_metadata_entry(msg, custom=custom_meta))
170
+
171
+ def add_tool_result(
172
+ self,
173
+ tool_call_id: str,
174
+ content: str,
175
+ tool_name: Optional[str] = None,
176
+ metadata: Optional[dict] = None,
177
+ ) -> None:
178
+ """Append a tool execution result and record metadata."""
179
+ msg = {
180
+ "role": "tool",
181
+ "tool_call_id": tool_call_id,
182
+ "content": content,
183
+ }
184
+ self._messages.append(msg)
185
+ self._metadata.append(
186
+ self._build_metadata_entry(msg, tool_name=tool_name, custom=metadata)
187
+ )
188
+
189
+ def rolling_compact_old_tools(self, keep_last_turns: int = 3) -> None:
190
+ """
191
+ Truncate large historical tool outputs from turns older than keep_last_turns.
192
+ Preserves update_plan and short outputs.
193
+ """
194
+ assistant_indices = [
195
+ i for i, m in enumerate(self._messages)
196
+ if isinstance(m, dict) and m.get("role") == "assistant"
197
+ ]
198
+ if len(assistant_indices) <= keep_last_turns:
199
+ return
200
+
201
+ cutoff_idx = assistant_indices[-keep_last_turns]
202
+ for i in range(cutoff_idx):
203
+ msg = self._messages[i]
204
+ if isinstance(msg, dict) and msg.get("role") == "tool":
205
+ content = str(msg.get("content", ""))
206
+ # Never truncate update_plan output
207
+ if "[Plan updated]" in content or "Plan Status:" in content or len(content) <= 300:
208
+ continue
209
+ lines = content.splitlines()
210
+ if len(lines) > 8:
211
+ preview_start = "\n".join(lines[:3])
212
+ preview_end = "\n".join(lines[-2:])
213
+ msg["content"] = f"[Output: {len(lines)} lines truncated for brevity]\n{preview_start}\n...\n{preview_end}"
214
+ else:
215
+ msg["content"] = content[:150] + f"... [truncated {len(content)} chars]"
216
+ # Update tokens in metadata
217
+ if i < len(self._metadata):
218
+ self._metadata[i]["tokens"] = self._estimate_single_message_tokens(msg)
219
+
220
+ def build_context(
221
+ self,
222
+ max_tokens: Optional[int] = None,
223
+ recent_turns: int = 4,
224
+ ) -> List[dict]:
225
+ """
226
+ Assemble the 5-layer context window:
227
+ 1. system prompt
228
+ 2. summary (of distant turns)
229
+ 3. relevant history (high-value anchors)
230
+ 4. recent messages (intact tool-call pairs)
231
+ 5. current request (tail of conversation)
232
+ """
233
+ if not self._messages:
234
+ return []
235
+
236
+ # ── Layer 1: System Prompt ──
237
+ system_msg: Optional[dict] = None
238
+ work_messages: List[dict] = []
239
+ work_metadata: List[dict] = []
240
+
241
+ if self._messages[0].get("role") == "system":
242
+ system_msg = dict(self._messages[0])
243
+ work_messages = self._messages[1:]
244
+ work_metadata = self._metadata[1:] if len(self._metadata) > 1 else []
245
+ else:
246
+ work_messages = list(self._messages)
247
+ work_metadata = list(self._metadata)
248
+
249
+ if not work_messages:
250
+ return [system_msg] if system_msg else []
251
+
252
+ # ── Determine recent turns cutoff ──
253
+ assistant_indices = [
254
+ i for i, m in enumerate(work_messages)
255
+ if isinstance(m, dict) and m.get("role") == "assistant"
256
+ ]
257
+
258
+ if len(assistant_indices) <= recent_turns:
259
+ cutoff_idx = 0
260
+ else:
261
+ cutoff_idx = assistant_indices[-recent_turns]
262
+
263
+ # ── Ensure Strict OpenAI Tool-Call Pairing Integrity ──
264
+ # If cutoff starts on a 'tool' message, move cutoff backwards to include
265
+ # the preceding 'assistant' message that owns the tool_call_id.
266
+ while cutoff_idx > 0 and work_messages[cutoff_idx].get("role") == "tool":
267
+ cutoff_idx -= 1
268
+
269
+ distant_messages = work_messages[:cutoff_idx]
270
+ distant_metadata = work_metadata[:cutoff_idx]
271
+ recent_messages = [dict(m) for m in work_messages[cutoff_idx:]]
272
+
273
+ # ── Layer 2 & 3: Summary and Relevant History from Distant Turns ──
274
+ context_blocks = []
275
+ if system_msg:
276
+ context_blocks.append(system_msg)
277
+
278
+ if distant_messages:
279
+ # Layer 2: Distant Summary
280
+ summary_text = self._cached_summary or extract_conversation_summary(
281
+ distant_messages, distant_metadata
282
+ )
283
+ # Layer 3: Relevant History Anchors
284
+ relevant_anchors = select_relevant_history(
285
+ distant_messages, distant_metadata
286
+ )
287
+
288
+ supplemental_parts = []
289
+ if summary_text:
290
+ supplemental_parts.append(summary_text)
291
+ if relevant_anchors:
292
+ supplemental_parts.append(relevant_anchors)
293
+
294
+ if supplemental_parts:
295
+ context_blocks.append({
296
+ "role": "system",
297
+ "content": "\n\n".join(supplemental_parts),
298
+ })
299
+
300
+ # ── Layer 4 & 5: Recent Messages and Current Request ──
301
+ if len(recent_messages) > 6:
302
+ for i in range(len(recent_messages) - 4):
303
+ m = recent_messages[i]
304
+ if m.get("role") == "tool":
305
+ c = str(m.get("content") or "")
306
+ if len(c) > 400 and "[Plan updated]" not in c and "Plan Status:" not in c:
307
+ lines = c.splitlines()
308
+ if len(lines) > 8:
309
+ m["content"] = f"[Output: {len(lines)} lines truncated]\n" + "\n".join(lines[:3]) + "\n...\n" + "\n".join(lines[-2:])
310
+ else:
311
+ m["content"] = c[:200] + "... [truncated]"
312
+
313
+ context_blocks.extend(recent_messages)
314
+
315
+ # ── Budget Verification ──
316
+ effective_limit = max_tokens or self.max_tokens
317
+ if estimate_tokens(context_blocks) > effective_limit:
318
+ context_blocks = compact_messages(context_blocks)
319
+
320
+ return context_blocks
321
+
322
+ @property
323
+ def messages(self) -> List[dict]:
324
+ """Returns the optimized, context-budgeted messages for model consumption."""
325
+ return self.build_context()
326
+
327
+ @messages.setter
328
+ def messages(self, msgs: List[dict]) -> None:
329
+ """Replace messages and rebuild metadata."""
330
+ self._messages = list(msgs)
331
+ self._rebuild_metadata()
332
+
333
+ @property
334
+ def all_messages(self) -> List[dict]:
335
+ """Returns the complete, uncompressed historical messages."""
336
+ return list(self._messages)
337
+
338
+ @property
339
+ def raw_history(self) -> List[dict]:
340
+ """Alias for all_messages."""
341
+ return list(self._messages)
342
+
343
+ @property
344
+ def metadata(self) -> List[Dict[str, Any]]:
345
+ """Returns parallel metadata list for all messages."""
346
+ return list(self._metadata)
347
+
348
+ def estimate_tokens(self) -> int:
349
+ """Estimate tokens of the active built context."""
350
+ return estimate_tokens(self.build_context())
351
+
352
+ def estimate_total_tokens(self) -> int:
353
+ """Estimate tokens of the entire raw history."""
354
+ return estimate_tokens(self._messages)
355
+
356
+ def check_and_compact(self) -> bool:
357
+ """Check if context needs compaction and refresh rolling summary cache."""
358
+ if needs_compaction(self._messages, max_tokens=self.max_tokens):
359
+ self._cached_summary = extract_conversation_summary(self._messages, self._metadata)
360
+ return True
361
+ return False
@@ -521,6 +521,97 @@ COMPLEXITY_CONFIG = {
521
521
  }
522
522
 
523
523
 
524
+ # -- Pre-Read Context Injection ---------------------------------------------
525
+
526
+ def gather_relevant_context(
527
+ task: str,
528
+ workdir: Path,
529
+ task_context: "TaskContext",
530
+ max_chars: int = 16000,
531
+ ) -> Optional[str]:
532
+ """Gather relevant project files and inject them as pre-loaded context.
533
+
534
+ Reads key files based on task intent so the agent doesn't code blindly.
535
+ Runs fast (filesystem only, no LLM calls). Returns None if nothing useful found.
536
+
537
+ Budget: ~4000 tokens = 16000 chars. Prioritises config > entry points > task-mentioned files.
538
+ """
539
+ if not task_context or getattr(task_context, "project_type", "") == "empty" or getattr(task_context, "file_count", 0) == 0:
540
+ return None
541
+
542
+ # Skip for pure Q&A -- no code context needed
543
+ if getattr(task_context, "intent", "") in ("qa",):
544
+ return None
545
+
546
+ sections: List[str] = []
547
+ chars_used = 0
548
+
549
+ def _add_file(rel_path: str, label: str = "") -> bool:
550
+ """Read a file and add it to sections. Returns True if added."""
551
+ nonlocal chars_used
552
+ fp = workdir / rel_path
553
+ if not fp.is_file():
554
+ return False
555
+ try:
556
+ content = fp.read_text(errors="replace")
557
+ except Exception:
558
+ return False
559
+ if not content.strip():
560
+ return False
561
+ # Truncate individual files at 3000 chars to leave room for others
562
+ if len(content) > 3000:
563
+ content = content[:3000] + f"\n... (truncated, {len(content)} chars total)"
564
+ if chars_used + len(content) > max_chars:
565
+ return False
566
+ tag = label or rel_path
567
+ sections.append(f"### {tag}\n```\n{content}\n```")
568
+ chars_used += len(content) + 50 # overhead for markdown
569
+ return True
570
+
571
+ # -- Priority 1: Config files (always useful) --
572
+ config_files = [
573
+ ("pyproject.toml", "pyproject.toml (project config)"),
574
+ ("package.json", "package.json (project config)"),
575
+ ("requirements.txt", "requirements.txt (dependencies)"),
576
+ ("Cargo.toml", "Cargo.toml (project config)"),
577
+ ("go.mod", "go.mod (project config)"),
578
+ ]
579
+ for rel, label in config_files:
580
+ _add_file(rel, label)
581
+
582
+ # -- Priority 2: Entry points (helps understand project structure) --
583
+ if task_context.intent in ("modify", "debug", "create"):
584
+ for ep in task_context.entry_points[:3]:
585
+ _add_file(ep, f"{ep} (entry point)")
586
+
587
+ # -- Priority 3: Files mentioned in the task (fuzzy match) --
588
+ task_lower = task.lower()
589
+ try:
590
+ all_files = []
591
+ for p in workdir.rglob("*"):
592
+ if p.is_file() and not any(
593
+ skip in str(p) for skip in [
594
+ ".git/", "__pycache__", "node_modules", ".venv",
595
+ ".egg-info", "dist/", "build/", ".mypy_cache",
596
+ ]
597
+ ):
598
+ all_files.append(str(p.relative_to(workdir)))
599
+ # Match filenames or stems mentioned in the task
600
+ for rel in all_files:
601
+ fname = Path(rel).stem.lower()
602
+ if len(fname) > 2 and fname in task_lower:
603
+ _add_file(rel, f"{rel} (mentioned in task)")
604
+ if chars_used > max_chars * 0.8:
605
+ break
606
+ except Exception:
607
+ pass
608
+
609
+ if not sections:
610
+ return None
611
+
612
+ return "## Pre-loaded Project Context\n" + "\n\n".join(sections)
613
+
614
+
524
615
  # -- Main Entry Point -------------------------------------------------------
525
616
 
526
617
  def build_task_context(task: str, workdir: Path) -> TaskContext:
@@ -84,6 +84,68 @@ def parse_text_tool_calls(text: str) -> List[dict]:
84
84
  return tool_calls
85
85
 
86
86
 
87
+
88
+ def _parse_error_hint(stderr: str, stdout: str) -> str:
89
+ """Parse common error patterns from bash output and return a targeted hint."""
90
+ combined = (stderr + " " + stdout).strip()
91
+ if not combined:
92
+ return ""
93
+
94
+ hints = []
95
+
96
+ # Python errors
97
+ if "ModuleNotFoundError" in combined or "ImportError" in combined:
98
+ mod_match = re.search(r"(?:No module named|cannot import name)\s+['\"]?([a-zA-Z0-9_\.\-]+)['\"]?", combined)
99
+ mod = mod_match.group(1) if mod_match else ""
100
+ target = f"'{mod}' " if mod else ""
101
+ hints.append(
102
+ f"Hint: Module {target}not found. Check if it is installed (pip list | grep {mod or 'package'}) "
103
+ "and that the import path matches the project structure. If using a venv, ensure it is activated."
104
+ )
105
+ elif "SyntaxError" in combined:
106
+ # Extract line number if present
107
+ line_match = re.search(r"line (\d+)", combined)
108
+ line_ref = f" at line {line_match.group(1)}" if line_match else ""
109
+ hints.append(
110
+ f"Hint: Python SyntaxError{line_ref}. Use read_file to inspect the exact lines "
111
+ "around the error before attempting a fix. Do NOT guess -- read first, fix once."
112
+ )
113
+ elif "FileNotFoundError" in combined or "No such file or directory" in combined:
114
+ hints.append(
115
+ "Hint: File or directory not found. Use list_files to verify the correct path "
116
+ "before retrying. The file may be in a different directory than expected."
117
+ )
118
+ elif "TypeError" in combined or "AttributeError" in combined:
119
+ hints.append(
120
+ "Hint: Type or attribute error. Use read_file to check the function/class definition "
121
+ "and verify the correct argument types and available attributes."
122
+ )
123
+ elif "NameError" in combined:
124
+ hints.append(
125
+ "Hint: NameError -- a variable or function is used before definition. "
126
+ "Read the file to check for typos, missing imports, or incorrect scope."
127
+ )
128
+ elif "IndentationError" in combined or "TabError" in combined:
129
+ hints.append(
130
+ "Hint: Indentation error. Read the full file with read_file to see the actual "
131
+ "whitespace. Do NOT guess indentation levels -- verify from the source."
132
+ )
133
+ elif "Permission denied" in combined:
134
+ hints.append(
135
+ "Hint: Permission denied. Check file permissions or if the path is correct. "
136
+ "You may need to use chmod or run with appropriate privileges."
137
+ )
138
+ elif "command not found" in combined:
139
+ cmd_match = combined.split("command not found")[0].strip().split()
140
+ cmd = cmd_match[-1] if cmd_match else "unknown"
141
+ hints.append(
142
+ f"Hint: Command '{cmd}' not found. It may not be installed or not in PATH. "
143
+ "Check available commands or install the required package."
144
+ )
145
+
146
+ return "\n".join(hints)
147
+
148
+
87
149
  class ToolExecutor:
88
150
  """Executes tool calls for a workspace."""
89
151
 
@@ -240,6 +302,20 @@ class ToolExecutor:
240
302
  "All checks have passed. Do NOT run this tool again. Output your final summary starting with: DONE: <summary>."
241
303
  )
242
304
 
305
+ # Smart error hint injection for failed bash commands
306
+ if tc.name == "bash" and '"exit_code"' in res_str:
307
+ try:
308
+ _res_parsed = json.loads(res_str.split("\n", 1)[-1] if res_str.startswith("\u26a0") else res_str)
309
+ if _res_parsed.get("exit_code", 0) != 0:
310
+ _hint = _parse_error_hint(
311
+ _res_parsed.get("stderr", ""),
312
+ _res_parsed.get("stdout", ""),
313
+ )
314
+ if _hint:
315
+ res_str += "\n\n" + _hint
316
+ except Exception:
317
+ pass
318
+
243
319
  # Compilation failure recovery hint
244
320
  if tc.name == "bash":
245
321
  cmd = tc.arguments.get("command", "") if isinstance(tc.arguments, dict) else ""
@@ -37,6 +37,10 @@ def validate_code_syntax(file_path: str, content: str) -> Tuple[bool, Optional[s
37
37
  if ext == ".py":
38
38
  try:
39
39
  ast.parse(content, filename=file_path)
40
+ # Syntax OK -- run semantic checks
41
+ sem_ok, sem_err = check_python_semantics(content, file_path)
42
+ if not sem_ok:
43
+ return False, sem_err
40
44
  return True, None
41
45
  except SyntaxError as e:
42
46
  line = e.lineno or 0
@@ -94,6 +98,68 @@ def validate_code_syntax(file_path: str, content: str) -> Tuple[bool, Optional[s
94
98
  return True, None
95
99
 
96
100
 
101
+ def check_python_semantics(content: str, file_path: str) -> Tuple[bool, Optional[str]]:
102
+ """Lightweight semantic checks for Python files using AST.
103
+
104
+ Catches common first-attempt errors that pass syntax validation but
105
+ would fail at runtime. Runs in < 10ms.
106
+ """
107
+ try:
108
+ tree = ast.parse(content, filename=file_path)
109
+ except SyntaxError:
110
+ return True, None # Syntax errors are caught elsewhere
111
+
112
+ warnings = []
113
+
114
+ # 1. Detect duplicate function/class definitions at module level
115
+ seen_names = {}
116
+ for node in ast.iter_child_nodes(tree):
117
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
118
+ name = node.name
119
+ if name in seen_names:
120
+ warnings.append(
121
+ f"Duplicate definition of '{name}' at line {node.lineno} "
122
+ f"(first defined at line {seen_names[name]})"
123
+ )
124
+ else:
125
+ seen_names[name] = node.lineno
126
+
127
+ # 2. Detect stub-only functions (body is just `pass` or `...`)
128
+ for node in ast.walk(tree):
129
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
130
+ body = node.body
131
+ if len(body) == 1:
132
+ stmt = body[0]
133
+ is_pass = isinstance(stmt, ast.Pass)
134
+ is_ellipsis = (
135
+ isinstance(stmt, ast.Expr)
136
+ and isinstance(stmt.value, ast.Constant)
137
+ and stmt.value.value is ...
138
+ )
139
+ is_docstring_only = (
140
+ isinstance(stmt, ast.Expr)
141
+ and isinstance(stmt.value, ast.Constant)
142
+ and isinstance(stmt.value.value, str)
143
+ )
144
+ # Skip __init__ with just pass (common pattern)
145
+ if node.name == "__init__":
146
+ continue
147
+ if is_pass or is_ellipsis:
148
+ warnings.append(
149
+ f"Function '{node.name}' at line {node.lineno} is a stub "
150
+ f"(body is only 'pass' or '...'). Implement the actual logic."
151
+ )
152
+
153
+ if warnings:
154
+ return False, (
155
+ f"Semantic warnings in '{file_path}':\n"
156
+ + "\n".join(f" - {w}" for w in warnings)
157
+ + "\nPlease fix these issues before writing the file."
158
+ )
159
+
160
+ return True, None
161
+
162
+
97
163
  def check_bracket_balance(code: str) -> Tuple[bool, Optional[str]]:
98
164
  """Fast lexical check for unclosed brackets or strings in code."""
99
165
  stack = []
@@ -48,7 +48,7 @@ from .backends.openai_backend import OpenAIBackend
48
48
  from .cache import get_cache
49
49
  from .config import Config, is_server_reachable
50
50
  from .memory import extract_and_save_memories
51
- from .discovery import build_task_context, COMPLEXITY_CONFIG
51
+ from .discovery import build_task_context, gather_relevant_context, COMPLEXITY_CONFIG
52
52
  from .progress import ProgressMonitor
53
53
  from .rephraser import PromptRephraser
54
54
  from .prompt import PromptBuilder, classify_task
@@ -174,8 +174,20 @@ class AgentOrchestrator:
174
174
  "rephrased": rephrased_task,
175
175
  })
176
176
 
177
+ # ── Phase 0.7: Pre-Read Context Injection ──
178
+ pre_read_ctx = None
179
+ try:
180
+ pre_read_ctx = gather_relevant_context(task, session.workdir, task_context)
181
+ except Exception:
182
+ pass # Fail open -- never block the agent
183
+
184
+ # Append pre-read context to the rephrased task so the agent sees it
185
+ effective_task = rephrased_task
186
+ if pre_read_ctx:
187
+ effective_task = rephrased_task + "\n\n" + pre_read_ctx
188
+
177
189
  user_content = self.prompt_builder.build_user_content(
178
- task=rephrased_task,
190
+ task=effective_task,
179
191
  image_path=image_path,
180
192
  video_path=video_path,
181
193
  url=url,
@@ -340,7 +352,7 @@ class AgentOrchestrator:
340
352
  if not (hasattr(self.backend, "chat_completion_stream") and not is_offline):
341
353
  yield AgentEvent("message", {"content": resp.content})
342
354
 
343
- extract_and_save_memories(session.workdir, session.context.messages)
355
+ extract_and_save_memories(session.workdir, getattr(session.context, 'all_messages', session.context.messages))
344
356
 
345
357
  # Generate and write real walkthrough.md file to the project workspace
346
358
  from pathlib import Path as _Path
@@ -364,6 +376,28 @@ class AgentOrchestrator:
364
376
  "commands": session_executed_commands,
365
377
  })
366
378
 
379
+ # ── Soft Verification Gate ──
380
+ # If files were written/modified but no verification was run, nudge the agent
381
+ has_code_files = any(
382
+ f.endswith((".py", ".js", ".ts", ".jsx", ".tsx", ".rs", ".go", ".java", ".c", ".cpp"))
383
+ for f in session_touched_files
384
+ )
385
+ has_successful_verify = any(
386
+ c.get("exit_code", -1) == 0 for c in session_executed_commands
387
+ )
388
+ if has_code_files and not has_successful_verify and not getattr(self, "_verification_nudge", False):
389
+ self._verification_nudge = True
390
+ session.context.add_user_message(
391
+ "[SYSTEM - VERIFICATION] You created/modified code files but did not run "
392
+ "any verification command. Please run a quick syntax check or test before finishing. "
393
+ "Then output DONE: <summary>."
394
+ )
395
+ yield AgentEvent("verification_nudge", {
396
+ "files": sorted(list(session_touched_files)),
397
+ "message": "Nudging agent to verify before completing",
398
+ })
399
+ continue
400
+
367
401
  # Cache if single-turn
368
402
  if is_cacheable and turn == 0:
369
403
  try:
@@ -444,15 +478,15 @@ class AgentOrchestrator:
444
478
  if not (hasattr(self.backend, "chat_completion_stream") and not is_offline):
445
479
  yield AgentEvent("message", {"content": resp.content})
446
480
 
447
- extract_and_save_memories(session.workdir, session.context.messages)
481
+ extract_and_save_memories(session.workdir, getattr(session.context, 'all_messages', session.context.messages))
448
482
  yield AgentEvent("done", {"summary": resp.content})
449
483
  return
450
484
 
451
485
  # Empty response
452
- extract_and_save_memories(session.workdir, session.context.messages)
486
+ extract_and_save_memories(session.workdir, getattr(session.context, 'all_messages', session.context.messages))
453
487
  yield AgentEvent("done", {"summary": "Task completed."})
454
488
  return
455
489
 
456
490
  status = progress_monitor.get_status_summary()
457
491
  yield AgentEvent("max_turns_reached", {"max_turns": safety_ceiling, "progress": status})
458
- extract_and_save_memories(session.workdir, session.context.messages)
492
+ extract_and_save_memories(session.workdir, getattr(session.context, 'all_messages', session.context.messages))
@@ -128,8 +128,11 @@ class ProgressMonitor:
128
128
 
129
129
  recent = self.history[-3:] if len(self.history) >= 3 else self.history
130
130
  deleted = set()
131
+ last_error_text = ""
131
132
  for snap in recent:
132
133
  deleted.update(snap.files_deleted)
134
+ if snap.errors:
135
+ last_error_text = snap.errors[-1][:200]
133
136
 
134
137
  created_names = {Path(f).name for f in self.all_files_created}
135
138
  thrashing = deleted & created_names
@@ -143,18 +146,23 @@ class ProgressMonitor:
143
146
  )
144
147
 
145
148
  if all(snap.errors or (snap.bash_exit_codes and 0 not in snap.bash_exit_codes) for snap in recent):
149
+ err_msg = f"\nLast error encountered: {last_error_text}\n" if last_error_text else ""
146
150
  return (
147
151
  "[SYSTEM - PROGRESS MONITOR] You have encountered errors for 3 consecutive turns. "
148
- "STOP retrying the same approach. Instead:\n"
152
+ f"STOP retrying the same approach.{err_msg} Instead:\n"
149
153
  "1. Use read_file to examine the FULL current state of the file(s) you\'re editing\n"
150
154
  "2. Identify the root cause of the error (not the symptom)\n"
151
155
  "3. Make ONE comprehensive fix that addresses all issues\n"
152
156
  "If the task approach is fundamentally wrong, start with a simpler design."
153
157
  )
154
158
 
159
+ error_context = ""
160
+ if last_error_text:
161
+ error_context = f"\nLast error encountered: {last_error_text}\n"
155
162
  return (
156
163
  "[SYSTEM - PROGRESS MONITOR] No measurable progress detected for 3 turns. "
157
- "You may be stuck in a loop. Either:\n"
164
+ f"You may be stuck in a loop.{error_context}"
165
+ "Either:\n"
158
166
  "1. Complete your current work and output DONE: <summary>\n"
159
167
  "2. Try a completely different approach to the problem\n"
160
168
  "3. If the code is written and working, verify with bash and finish."
@@ -442,6 +442,15 @@ GOAL_DRIVEN_RULES = """\
442
442
  - No Thrashing: NEVER delete a file (e.g. `rm <file>`) immediately after creating it to start over. Use `edit_file` to modify what needs fixing.
443
443
  - Immediate Completion: As soon as your code is written and verified, output `DONE: <summary>`. Do NOT linger or rerun commands that already passed.
444
444
  - Targeted Editing: If `edit_file` fails, use `read_file` to inspect the exact lines and whitespace before attempting another edit.
445
+
446
+ ## Mandatory Verification Before Completion
447
+ - Before outputting DONE, you MUST verify your work:
448
+ - For Python: Run the script or run `python3 -c "import ast; ast.parse(open('<file>').read())"` for syntax check
449
+ - For JavaScript/TypeScript: Run `node --check <file>` for syntax check
450
+ - For any executable code: Run it at least once with bash
451
+ - If tests exist in the project: Run the test suite
452
+ - NEVER say DONE if your last bash command returned a non-zero exit code
453
+ - If you created or modified files but haven't run ANY verification command, you must verify before finishing
445
454
  """
446
455
 
447
456
  INTENT_MODIFY = """\
@@ -104,9 +104,21 @@ class PromptRephraser:
104
104
  return task
105
105
 
106
106
  try:
107
+ # Inject project context if available so rephrased spec is project-aware
108
+ project_hint = ""
109
+ if task_context:
110
+ _fw = ", ".join(getattr(task_context, "frameworks", []) or []) or "none"
111
+ _pt = getattr(task_context, "project_type", "unknown")
112
+ _fc = getattr(task_context, "file_count", 0)
113
+ _deps = ", ".join((getattr(task_context, "dependencies", []) or [])[:6]) or "none"
114
+ project_hint = (
115
+ f"\nProject Context: type={_pt}, frameworks={_fw}, "
116
+ f"files={_fc}, key_deps={_deps}"
117
+ )
118
+
107
119
  messages = [
108
120
  {"role": "system", "content": self.system_prompt},
109
- {"role": "user", "content": f"User Task:\n{task.strip()}"},
121
+ {"role": "user", "content": f"User Task:\n{task.strip()}{project_hint}"},
110
122
  ]
111
123
 
112
124
  # Invoke backend with enable_thinking=False for ultra-fast, deterministic response
@@ -64,6 +64,7 @@ class SessionManager:
64
64
  "workdir": str(s.workdir),
65
65
  "created_at": s.created_at,
66
66
  "estimated_tokens": s.context.estimate_tokens(),
67
+ "total_history_tokens": getattr(s.context, "estimate_total_tokens", s.context.estimate_tokens)(),
67
68
  }
68
69
  for s in self._sessions.values()
69
70
  ]
@@ -1,106 +0,0 @@
1
- """
2
- Conversation context manager for AlpieCode.
3
-
4
- Manages the list of messages in a conversation session, tracking estimated tokens
5
- and performing compaction when context limits are approached.
6
- """
7
-
8
- from typing import Any, Dict, List, Optional
9
-
10
- from .backends.base import ChatResponse, ToolCall
11
- from .compaction import compact_messages, estimate_tokens, needs_compaction
12
-
13
-
14
- def _serialize_assistant_message(msg: ChatResponse) -> dict:
15
- """Serialize a ChatResponse into OpenAI chat message dict format."""
16
- result = {"role": "assistant"}
17
- result["content"] = msg.content if msg.content else None
18
-
19
- if msg.tool_calls:
20
- import json
21
- result["tool_calls"] = [
22
- {
23
- "id": tc.id,
24
- "type": "function",
25
- "function": {
26
- "name": tc.name,
27
- "arguments": json.dumps(tc.arguments) if isinstance(tc.arguments, dict) else tc.arguments,
28
- },
29
- }
30
- for tc in msg.tool_calls
31
- ]
32
- return result
33
-
34
-
35
- class ContextManager:
36
- """Manages session messages and context window token budgeting."""
37
-
38
- def __init__(self, max_tokens: int = 262_144):
39
- self.max_tokens = max_tokens
40
- self._messages: List[dict] = []
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
-
67
- @property
68
- def messages(self) -> List[dict]:
69
- self.rolling_compact_old_tools(keep_last_turns=3)
70
- return self._messages
71
-
72
-
73
- @messages.setter
74
- def messages(self, msgs: List[dict]) -> None:
75
- self._messages = msgs
76
-
77
- def set_system_prompt(self, prompt: str) -> None:
78
- if self._messages and self._messages[0].get("role") == "system":
79
- self._messages[0]["content"] = prompt
80
- else:
81
- self._messages.insert(0, {"role": "system", "content": prompt})
82
-
83
- def add_user_message(self, content: Any) -> None:
84
- self._messages.append({"role": "user", "content": content})
85
-
86
- def add_assistant_response(self, response: ChatResponse) -> None:
87
- if response.tool_calls or response.content:
88
- serialized = _serialize_assistant_message(response)
89
- self._messages.append(serialized)
90
-
91
- def add_tool_result(self, tool_call_id: str, content: str) -> None:
92
- self._messages.append({
93
- "role": "tool",
94
- "tool_call_id": tool_call_id,
95
- "content": content,
96
- })
97
-
98
- def estimate_tokens(self) -> int:
99
- return estimate_tokens(self._messages)
100
-
101
- def check_and_compact(self) -> bool:
102
- """Compact context if approaching limit. Returns True if compaction occurred."""
103
- if needs_compaction(self._messages, max_tokens=self.max_tokens):
104
- self._messages = compact_messages(self._messages)
105
- return True
106
- return False
File without changes
File without changes