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.
- {alpiecode-8.0.6 → alpiecode-8.0.7}/PKG-INFO +1 -1
- {alpiecode-8.0.6 → alpiecode-8.0.7}/pyproject.toml +1 -1
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/alpiecode.egg-info/PKG-INFO +1 -1
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/__init__.py +1 -1
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/compaction.py +139 -4
- alpiecode-8.0.7/src/codeagent/context.py +361 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/discovery.py +91 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/executor.py +76 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/extension/alpiecode.vsix +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/guardrails.py +66 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/orchestrator.py +40 -6
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/progress.py +10 -2
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/prompt.py +9 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/rephraser.py +13 -1
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/session.py +1 -0
- alpiecode-8.0.6/src/codeagent/context.py +0 -106
- {alpiecode-8.0.6 → alpiecode-8.0.7}/README.md +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/setup.cfg +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/alpiecode/__init__.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/alpiecode.egg-info/SOURCES.txt +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/alpiecode.egg-info/dependency_links.txt +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/alpiecode.egg-info/entry_points.txt +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/alpiecode.egg-info/requires.txt +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/alpiecode.egg-info/top_level.txt +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/agent.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/backends/__init__.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/backends/base.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/backends/local_backend.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/backends/openai_backend.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/cache.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/cli.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/client.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/config.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/doctor.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/git_ops.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/github.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/guardian.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/ipython_ext.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/local_model.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/media.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/memory.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/server.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/tools.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/updater.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.7}/src/codeagent/vscode_installer.py +0 -0
|
@@ -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 ""
|
|
Binary file
|
|
@@ -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=
|
|
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.
|
|
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
|
|
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
|