aru-code 0.13.3__tar.gz → 0.14.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {aru_code-0.13.3/aru_code.egg-info → aru_code-0.14.1}/PKG-INFO +1 -1
- aru_code-0.14.1/aru/__init__.py +1 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru/agents/base.py +18 -1
- {aru_code-0.13.3 → aru_code-0.14.1}/aru/context.py +120 -27
- {aru_code-0.13.3 → aru_code-0.14.1}/aru/runner.py +3 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru/tools/codebase.py +51 -8
- {aru_code-0.13.3 → aru_code-0.14.1/aru_code.egg-info}/PKG-INFO +1 -1
- {aru_code-0.13.3 → aru_code-0.14.1}/pyproject.toml +1 -1
- {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_codebase.py +5 -5
- aru_code-0.13.3/aru/__init__.py +0 -1
- {aru_code-0.13.3 → aru_code-0.14.1}/LICENSE +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/README.md +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru/agent_factory.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru/agents/__init__.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru/agents/executor.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru/agents/planner.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru/cli.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru/commands.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru/completers.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru/config.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru/display.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru/permissions.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru/providers.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru/runtime.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru/session.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru/tools/__init__.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru/tools/gitignore.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru/tools/ranker.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru/tools/tasklist.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru_code.egg-info/SOURCES.txt +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/setup.cfg +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_agents_base.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_ast_tools.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_cli.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_cli_advanced.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_cli_base.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_cli_completers.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_cli_new.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_cli_run_cli.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_cli_session.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_cli_shell.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_config.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_context.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_executor.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_gitignore.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_main.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_mcp_client.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_permissions.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_planner.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_providers.py +0 -0
- {aru_code-0.13.3 → aru_code-0.14.1}/tests/test_ranker.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.14.1"
|
|
@@ -3,9 +3,26 @@
|
|
|
3
3
|
# Common rules shared across all agents (planner, executor, general).
|
|
4
4
|
# Each agent appends its role-specific instructions to this base.
|
|
5
5
|
BASE_INSTRUCTIONS = """\
|
|
6
|
-
|
|
6
|
+
## Output rules — CRITICAL for token efficiency
|
|
7
|
+
|
|
8
|
+
Minimize output tokens. Your responses should be fewer than 4 lines unless the user \
|
|
9
|
+
asks for detail or you are writing code. One word answers are best when they suffice.
|
|
10
|
+
|
|
11
|
+
Do NOT add unnecessary preamble or postamble. Avoid introductions, conclusions, \
|
|
12
|
+
and explanations of what you will do or just did. Do not add code explanation \
|
|
13
|
+
summaries unless the user requests them. Only address the specific query or task at hand.
|
|
14
|
+
|
|
7
15
|
NEVER write narration before calling tools. Do NOT say "I will analyze...", "Let me check...", \
|
|
8
16
|
"Now I will...", or any similar preamble. Call the tool immediately and silently.
|
|
17
|
+
|
|
18
|
+
Examples of ideal responses:
|
|
19
|
+
- user: "2 + 2" → assistant: "4"
|
|
20
|
+
- user: "is 11 prime?" → assistant: "Yes"
|
|
21
|
+
- user: "what command lists files?" → assistant: "ls"
|
|
22
|
+
- user: "fix the typo in line 5" → [call edit_file immediately, no narration]
|
|
23
|
+
|
|
24
|
+
## Scope rules
|
|
25
|
+
|
|
9
26
|
NEVER create documentation files (*.md) unless the user explicitly asks for them.
|
|
10
27
|
Focus on writing working code, not documentation.
|
|
11
28
|
Deliver EXACTLY what was asked — no more, no less. \
|
|
@@ -11,24 +11,31 @@ from __future__ import annotations
|
|
|
11
11
|
# ── Constants ──────────────────────────────────────────────────────
|
|
12
12
|
|
|
13
13
|
# Pruning: minimum chars that must be freeable to justify a prune pass
|
|
14
|
-
PRUNE_MINIMUM_CHARS =
|
|
14
|
+
PRUNE_MINIMUM_CHARS = 12_000 # ~3K tokens (lower = prune sooner)
|
|
15
15
|
# Placeholder that replaces evicted content
|
|
16
16
|
PRUNED_PLACEHOLDER = "[previous output cleared to save context]"
|
|
17
17
|
# User messages larger than this threshold are truncated when outside protection window
|
|
18
18
|
PRUNE_USER_MSG_THRESHOLD = 2_000 # ~570 tokens — catches @file mentions
|
|
19
19
|
# How many chars to keep from the start of a pruned user message
|
|
20
20
|
PRUNE_USER_MSG_KEEP = 500 # ~140 tokens — enough to understand the request
|
|
21
|
+
# Minimum number of recent user turns always protected (regardless of char budget)
|
|
22
|
+
PRUNE_PROTECT_TURNS = 2
|
|
23
|
+
# Tool result markers that should never be pruned (critical context)
|
|
24
|
+
PRUNE_PROTECTED_MARKERS = {"[SubAgent-", "delegate_task"}
|
|
21
25
|
|
|
22
26
|
# Truncation: universal limits for any tool output
|
|
23
|
-
TRUNCATE_MAX_LINES =
|
|
24
|
-
TRUNCATE_MAX_BYTES =
|
|
25
|
-
TRUNCATE_KEEP_START =
|
|
26
|
-
TRUNCATE_KEEP_END =
|
|
27
|
+
TRUNCATE_MAX_LINES = 300
|
|
28
|
+
TRUNCATE_MAX_BYTES = 15 * 1024 # 15 KB (was 20KB — tighter to prevent context bloat)
|
|
29
|
+
TRUNCATE_KEEP_START = 200 # lines to keep from the start
|
|
30
|
+
TRUNCATE_KEEP_END = 60 # lines to keep from the end
|
|
31
|
+
TRUNCATE_MAX_LINE_LENGTH = 2000 # chars per individual line (prevents minified files)
|
|
27
32
|
|
|
28
33
|
# Compaction: trigger when per-run input tokens exceed this fraction of model limit
|
|
29
|
-
COMPACTION_THRESHOLD_RATIO = 0.85
|
|
34
|
+
COMPACTION_THRESHOLD_RATIO = 0.70 # was 0.85 — compact earlier to avoid hitting limits
|
|
30
35
|
# Compaction: target post-compaction size as fraction of model context limit
|
|
31
36
|
COMPACTION_TARGET_RATIO = 0.15
|
|
37
|
+
# Compaction: reserve buffer for the compaction process itself (like OpenCode's 20K)
|
|
38
|
+
COMPACTION_BUFFER_TOKENS = 20_000
|
|
32
39
|
# Default model context limits (input tokens)
|
|
33
40
|
MODEL_CONTEXT_LIMITS: dict[str, int] = {
|
|
34
41
|
# Anthropic
|
|
@@ -70,15 +77,26 @@ MODEL_CONTEXT_LIMITS: dict[str, int] = {
|
|
|
70
77
|
}
|
|
71
78
|
|
|
72
79
|
COMPACTION_TEMPLATE = """\
|
|
73
|
-
Summarize this conversation
|
|
74
|
-
|
|
75
|
-
2. **Key decisions**: Important choices made during the conversation
|
|
76
|
-
3. **Discoveries**: What was learned about the codebase or problem
|
|
77
|
-
4. **Accomplished**: What has been done so far (be specific about files changed)
|
|
78
|
-
5. **Relevant files**: File paths that are important for continuing the work
|
|
79
|
-
6. **Next steps**: What remains to be done
|
|
80
|
+
Summarize this conversation into the EXACT sections below. Be concise but complete — \
|
|
81
|
+
this summary replaces the full conversation history. Output ONLY these sections:
|
|
80
82
|
|
|
81
|
-
|
|
83
|
+
## Goal
|
|
84
|
+
What the user is trying to accomplish (1-2 sentences).
|
|
85
|
+
|
|
86
|
+
## Instructions
|
|
87
|
+
Important instructions or preferences the user stated (bullet list). \
|
|
88
|
+
If none, write "None stated."
|
|
89
|
+
|
|
90
|
+
## Discoveries
|
|
91
|
+
Notable things learned about the codebase, bugs, or architecture (bullet list). \
|
|
92
|
+
If none, write "None."
|
|
93
|
+
|
|
94
|
+
## Accomplished
|
|
95
|
+
What was done so far — be specific about files created/changed and functions added/modified. \
|
|
96
|
+
List what is in progress and what remains (bullet list).
|
|
97
|
+
|
|
98
|
+
## Relevant files / directories
|
|
99
|
+
Structured list of file paths relevant to continuing the work (one per line)."""
|
|
82
100
|
|
|
83
101
|
|
|
84
102
|
# ── Layer 1: Pruning ──────────────────────────────────────────────
|
|
@@ -87,13 +105,13 @@ def _get_prune_protect_chars(model_id: str = "default") -> int:
|
|
|
87
105
|
"""Scale protection window based on model context size.
|
|
88
106
|
|
|
89
107
|
Larger models get more protection; smaller models prune more aggressively
|
|
90
|
-
to
|
|
108
|
+
to prevent context overflow. Returns ~7% of the model's context in chars.
|
|
91
109
|
"""
|
|
92
110
|
limit = MODEL_CONTEXT_LIMITS.get(model_id, MODEL_CONTEXT_LIMITS["default"])
|
|
93
|
-
# ~
|
|
94
|
-
protect = int(limit * 0.
|
|
95
|
-
# Clamp between
|
|
96
|
-
return max(
|
|
111
|
+
# ~4 chars per token, protect ~7% of context (was 10% — tighter budget)
|
|
112
|
+
protect = int(limit * 0.07 * 4)
|
|
113
|
+
# Clamp between 15K (minimum usable) and 60K (diminishing returns)
|
|
114
|
+
return max(15_000, min(protect, 60_000))
|
|
97
115
|
|
|
98
116
|
|
|
99
117
|
def prune_history(
|
|
@@ -104,9 +122,14 @@ def prune_history(
|
|
|
104
122
|
Walks backward through history, protecting the most recent content
|
|
105
123
|
(scaled to the model's context size). Older messages beyond that
|
|
106
124
|
budget are pruned:
|
|
107
|
-
- Assistant messages: replaced entirely with placeholder
|
|
125
|
+
- Assistant messages: replaced entirely with placeholder (unless protected)
|
|
108
126
|
- User messages over PRUNE_USER_MSG_THRESHOLD: truncated to first N chars
|
|
109
127
|
|
|
128
|
+
Protection layers:
|
|
129
|
+
1. Turn-based: last PRUNE_PROTECT_TURNS user turns always kept
|
|
130
|
+
2. Char-based: recent content within the protection window
|
|
131
|
+
3. Content-based: messages containing PRUNE_PROTECTED_MARKERS never pruned
|
|
132
|
+
|
|
110
133
|
Returns a new list (does not mutate the input).
|
|
111
134
|
"""
|
|
112
135
|
if len(history) <= 2:
|
|
@@ -121,6 +144,18 @@ def prune_history(
|
|
|
121
144
|
if total_chars < protect_chars + PRUNE_MINIMUM_CHARS:
|
|
122
145
|
return list(history)
|
|
123
146
|
|
|
147
|
+
# Identify indices of last N user turns (always protected)
|
|
148
|
+
turn_protected: set[int] = set()
|
|
149
|
+
user_turns_seen = 0
|
|
150
|
+
for i in range(len(history) - 1, -1, -1):
|
|
151
|
+
if history[i]["role"] == "user":
|
|
152
|
+
user_turns_seen += 1
|
|
153
|
+
if user_turns_seen <= PRUNE_PROTECT_TURNS:
|
|
154
|
+
turn_protected.add(i)
|
|
155
|
+
# Also protect the assistant response right after this user turn
|
|
156
|
+
if i + 1 < len(history):
|
|
157
|
+
turn_protected.add(i + 1)
|
|
158
|
+
|
|
124
159
|
# Walk backward, protecting recent content
|
|
125
160
|
result = list(history)
|
|
126
161
|
protected = 0
|
|
@@ -129,10 +164,20 @@ def prune_history(
|
|
|
129
164
|
msg = result[i]
|
|
130
165
|
msg_len = len(msg["content"])
|
|
131
166
|
|
|
167
|
+
# Turn-based protection: never prune last N user turns
|
|
168
|
+
if i in turn_protected:
|
|
169
|
+
protected += msg_len
|
|
170
|
+
continue
|
|
171
|
+
|
|
132
172
|
if protected + msg_len <= protect_chars:
|
|
133
173
|
# Still within protection window
|
|
134
174
|
protected += msg_len
|
|
135
175
|
else:
|
|
176
|
+
# Check protected markers before pruning
|
|
177
|
+
if any(marker in msg["content"] for marker in PRUNE_PROTECTED_MARKERS):
|
|
178
|
+
protected += msg_len
|
|
179
|
+
continue
|
|
180
|
+
|
|
136
181
|
# Beyond protection window — prune
|
|
137
182
|
if msg["role"] == "assistant":
|
|
138
183
|
if msg["content"] != PRUNED_PLACEHOLDER:
|
|
@@ -147,11 +192,36 @@ def prune_history(
|
|
|
147
192
|
|
|
148
193
|
# ── Layer 2: Truncation ───────────────────────────────────────────
|
|
149
194
|
|
|
195
|
+
def _truncate_long_lines(lines: list[str]) -> list[str]:
|
|
196
|
+
"""Truncate individual lines that exceed MAX_LINE_LENGTH.
|
|
197
|
+
|
|
198
|
+
Prevents minified JS/CSS or log lines from consuming massive tokens.
|
|
199
|
+
"""
|
|
200
|
+
result = []
|
|
201
|
+
for line in lines:
|
|
202
|
+
if len(line) > TRUNCATE_MAX_LINE_LENGTH:
|
|
203
|
+
result.append(
|
|
204
|
+
line[:TRUNCATE_MAX_LINE_LENGTH]
|
|
205
|
+
+ f"... (line truncated to {TRUNCATE_MAX_LINE_LENGTH} chars)\n"
|
|
206
|
+
)
|
|
207
|
+
else:
|
|
208
|
+
result.append(line)
|
|
209
|
+
return result
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
_TRUNCATION_HINT = (
|
|
213
|
+
"\n[Hint: Use grep_search to find specific content, or read_file with "
|
|
214
|
+
"start_line/end_line for incremental reading. "
|
|
215
|
+
"For large exploration tasks, use delegate_task to keep your context clean.]"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
150
219
|
def truncate_output(text: str) -> str:
|
|
151
220
|
"""Universal truncation for tool outputs.
|
|
152
221
|
|
|
153
222
|
Caps output at TRUNCATE_MAX_BYTES / TRUNCATE_MAX_LINES, keeping the
|
|
154
223
|
start and end with a middle marker showing what was cut.
|
|
224
|
+
Also truncates individual lines exceeding TRUNCATE_MAX_LINE_LENGTH.
|
|
155
225
|
"""
|
|
156
226
|
if not text:
|
|
157
227
|
return text
|
|
@@ -161,8 +231,11 @@ def truncate_output(text: str) -> str:
|
|
|
161
231
|
lines = text.splitlines(keepends=True)
|
|
162
232
|
line_count = len(lines)
|
|
163
233
|
|
|
234
|
+
# Truncate individual long lines first
|
|
235
|
+
lines = _truncate_long_lines(lines)
|
|
236
|
+
|
|
164
237
|
if byte_len <= TRUNCATE_MAX_BYTES and line_count <= TRUNCATE_MAX_LINES:
|
|
165
|
-
return
|
|
238
|
+
return "".join(lines)
|
|
166
239
|
|
|
167
240
|
# Truncate by lines
|
|
168
241
|
if line_count > TRUNCATE_MAX_LINES:
|
|
@@ -171,8 +244,8 @@ def truncate_output(text: str) -> str:
|
|
|
171
244
|
omitted = line_count - TRUNCATE_KEEP_START - TRUNCATE_KEEP_END
|
|
172
245
|
return (
|
|
173
246
|
"".join(head)
|
|
174
|
-
+ f"\n\n[... {omitted:,} lines omitted ({line_count:,} total)
|
|
175
|
-
|
|
247
|
+
+ f"\n\n[... {omitted:,} lines omitted ({line_count:,} total)]"
|
|
248
|
+
+ _TRUNCATION_HINT + "\n\n"
|
|
176
249
|
+ "".join(tail)
|
|
177
250
|
)
|
|
178
251
|
|
|
@@ -190,7 +263,8 @@ def truncate_output(text: str) -> str:
|
|
|
190
263
|
return (
|
|
191
264
|
"".join(kept_lines)
|
|
192
265
|
+ f"\n\n[... truncated at ~{TRUNCATE_MAX_BYTES // 1024}KB — "
|
|
193
|
-
f"{remaining:,} more lines
|
|
266
|
+
f"{remaining:,} more lines]"
|
|
267
|
+
+ _TRUNCATION_HINT + "\n"
|
|
194
268
|
)
|
|
195
269
|
|
|
196
270
|
|
|
@@ -206,7 +280,10 @@ def should_compact(
|
|
|
206
280
|
history_or_tokens: int | list[dict[str, str]],
|
|
207
281
|
model_id: str = "default",
|
|
208
282
|
) -> bool:
|
|
209
|
-
"""Check if the conversation should be compacted
|
|
283
|
+
"""Check if the conversation should be compacted.
|
|
284
|
+
|
|
285
|
+
Uses OpenCode's approach: usable = model_limit - buffer, then
|
|
286
|
+
trigger when tokens >= usable * threshold_ratio.
|
|
210
287
|
|
|
211
288
|
Accepts either an estimated token count (int) or the history list
|
|
212
289
|
(from which tokens are estimated via char count).
|
|
@@ -216,7 +293,8 @@ def should_compact(
|
|
|
216
293
|
else:
|
|
217
294
|
tokens = history_or_tokens
|
|
218
295
|
limit = MODEL_CONTEXT_LIMITS.get(model_id, MODEL_CONTEXT_LIMITS["default"])
|
|
219
|
-
|
|
296
|
+
usable = limit - COMPACTION_BUFFER_TOKENS
|
|
297
|
+
threshold = int(usable * COMPACTION_THRESHOLD_RATIO)
|
|
220
298
|
return tokens >= threshold
|
|
221
299
|
|
|
222
300
|
|
|
@@ -287,7 +365,7 @@ def apply_compaction(
|
|
|
287
365
|
|
|
288
366
|
Uses the same protection window as pruning: recent messages within
|
|
289
367
|
the window are preserved as-is, older messages are replaced by a
|
|
290
|
-
compaction summary.
|
|
368
|
+
compaction summary. Replays the last user message to maintain continuity.
|
|
291
369
|
"""
|
|
292
370
|
_, recent = _split_history(history, model_id)
|
|
293
371
|
|
|
@@ -296,6 +374,21 @@ def apply_compaction(
|
|
|
296
374
|
]
|
|
297
375
|
compacted.extend(recent)
|
|
298
376
|
|
|
377
|
+
# Replay: ensure the last message is from the user so the LLM continues naturally
|
|
378
|
+
if not compacted or compacted[-1]["role"] != "user":
|
|
379
|
+
# Find last user message in original history for replay
|
|
380
|
+
last_user = None
|
|
381
|
+
for msg in reversed(history):
|
|
382
|
+
if msg["role"] == "user":
|
|
383
|
+
last_user = msg["content"]
|
|
384
|
+
break
|
|
385
|
+
if last_user:
|
|
386
|
+
# Truncate replayed message to avoid re-bloating context
|
|
387
|
+
replay = last_user[:1000] if len(last_user) > 1000 else last_user
|
|
388
|
+
compacted.append({"role": "user", "content": replay})
|
|
389
|
+
else:
|
|
390
|
+
compacted.append({"role": "user", "content": "Continue if you have next steps, or stop and ask for clarification."})
|
|
391
|
+
|
|
299
392
|
return compacted
|
|
300
393
|
|
|
301
394
|
|
|
@@ -249,12 +249,15 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
|
|
|
249
249
|
run_input_tokens = getattr(run_output.metrics, "input_tokens", 0) or 0
|
|
250
250
|
if should_compact(run_input_tokens, session.model_id):
|
|
251
251
|
try:
|
|
252
|
+
# Always prune first to shrink history before compaction
|
|
253
|
+
session.history = prune_history(session.history, model_id=session.model_id)
|
|
252
254
|
session.history = await compact_conversation(
|
|
253
255
|
session.history, session.model_ref, session.plan_task,
|
|
254
256
|
model_id=session.model_id,
|
|
255
257
|
)
|
|
256
258
|
console.print("[dim]Context compacted to save tokens.[/dim]")
|
|
257
259
|
except Exception:
|
|
260
|
+
# Even if compaction fails, keep the pruned history
|
|
258
261
|
pass
|
|
259
262
|
|
|
260
263
|
final_content = accumulated or final_content
|
|
@@ -54,23 +54,23 @@ def _format_diff(old_string: str, new_string: str) -> Group:
|
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
# Hard ceiling per tool result (~
|
|
58
|
-
_READ_HARD_CAP =
|
|
57
|
+
# Hard ceiling per tool result (~10K tokens). Even max_size=0 respects this per chunk.
|
|
58
|
+
_READ_HARD_CAP = 40_000 # bytes (was 60K — tighter to protect context)
|
|
59
59
|
|
|
60
60
|
def clear_read_cache():
|
|
61
61
|
"""Clear the read cache. Call after file mutations to avoid stale data."""
|
|
62
62
|
get_ctx().read_cache.clear()
|
|
63
63
|
|
|
64
64
|
|
|
65
|
-
def read_file(file_path: str, start_line: int = 0, end_line: int = 0, max_size: int =
|
|
65
|
+
def read_file(file_path: str, start_line: int = 0, end_line: int = 0, max_size: int = 12_000) -> str:
|
|
66
66
|
"""Read file contents. Returns chunked output for large files.
|
|
67
67
|
|
|
68
68
|
Args:
|
|
69
69
|
file_path: Path to the file (absolute or relative).
|
|
70
70
|
start_line: First line (1-indexed, inclusive). 0 = beginning.
|
|
71
71
|
end_line: Last line (1-indexed, inclusive). 0 = end.
|
|
72
|
-
max_size: Max bytes before truncation. Default
|
|
73
|
-
Set to 0 to read the full file in chunks — each chunk up to ~
|
|
72
|
+
max_size: Max bytes before truncation. Default 12KB.
|
|
73
|
+
Set to 0 to read the full file in chunks — each chunk up to ~40KB.
|
|
74
74
|
The first chunk includes a continuation hint so you can call again
|
|
75
75
|
with start_line to get the next chunk.
|
|
76
76
|
"""
|
|
@@ -204,7 +204,7 @@ async def read_file_smart(file_path: str, query: str) -> str:
|
|
|
204
204
|
query: The specific question you want answered about this file.
|
|
205
205
|
"""
|
|
206
206
|
# Read raw content first (reuse existing read_file logic)
|
|
207
|
-
raw = read_file(file_path, max_size=
|
|
207
|
+
raw = read_file(file_path, max_size=15_000)
|
|
208
208
|
|
|
209
209
|
if raw.startswith("Error:"):
|
|
210
210
|
return raw
|
|
@@ -321,6 +321,35 @@ def write_files(file_list: list[dict]) -> str:
|
|
|
321
321
|
return "\n".join(parts) or "No files to write."
|
|
322
322
|
|
|
323
323
|
|
|
324
|
+
def _compact_diff(old_string: str, new_string: str, file_path: str = "") -> str:
|
|
325
|
+
"""Generate a compact unified diff string for the LLM context.
|
|
326
|
+
|
|
327
|
+
Returns only the changed lines (not the full file), saving tokens while
|
|
328
|
+
giving the LLM enough context to continue working.
|
|
329
|
+
"""
|
|
330
|
+
old_lines = old_string.splitlines(keepends=True)
|
|
331
|
+
new_lines = new_string.splitlines(keepends=True)
|
|
332
|
+
# Ensure trailing newlines for clean diff
|
|
333
|
+
if old_lines and not old_lines[-1].endswith("\n"):
|
|
334
|
+
old_lines[-1] += "\n"
|
|
335
|
+
if new_lines and not new_lines[-1].endswith("\n"):
|
|
336
|
+
new_lines[-1] += "\n"
|
|
337
|
+
|
|
338
|
+
import difflib
|
|
339
|
+
diff_lines = list(difflib.unified_diff(
|
|
340
|
+
old_lines, new_lines,
|
|
341
|
+
fromfile=file_path, tofile=file_path,
|
|
342
|
+
lineterm="",
|
|
343
|
+
))
|
|
344
|
+
if not diff_lines:
|
|
345
|
+
return ""
|
|
346
|
+
# Cap diff output to avoid huge diffs bloating context
|
|
347
|
+
MAX_DIFF_LINES = 40
|
|
348
|
+
if len(diff_lines) > MAX_DIFF_LINES:
|
|
349
|
+
return "\n".join(diff_lines[:MAX_DIFF_LINES]) + f"\n... ({len(diff_lines) - MAX_DIFF_LINES} more diff lines)"
|
|
350
|
+
return "\n".join(diff_lines)
|
|
351
|
+
|
|
352
|
+
|
|
324
353
|
def edit_file(file_path: str, old_string: str, new_string: str) -> str:
|
|
325
354
|
"""Replace an exact string in a file. The old_string must appear exactly once.
|
|
326
355
|
|
|
@@ -347,7 +376,12 @@ def edit_file(file_path: str, old_string: str, new_string: str) -> str:
|
|
|
347
376
|
with open(file_path, "w", encoding="utf-8") as f:
|
|
348
377
|
f.write(new_content)
|
|
349
378
|
_notify_file_mutation()
|
|
350
|
-
|
|
379
|
+
|
|
380
|
+
# Return compact diff instead of just success message
|
|
381
|
+
diff_text = _compact_diff(old_string, new_string, file_path)
|
|
382
|
+
if diff_text:
|
|
383
|
+
return f"Edited {file_path}\n{diff_text}"
|
|
384
|
+
return f"Edited {file_path}"
|
|
351
385
|
except FileNotFoundError:
|
|
352
386
|
return f"Error: File not found: {file_path}"
|
|
353
387
|
except Exception as e:
|
|
@@ -423,7 +457,16 @@ def edit_files(edits: list[dict]) -> str:
|
|
|
423
457
|
if results:
|
|
424
458
|
_notify_file_mutation()
|
|
425
459
|
unique = list(dict.fromkeys(results)) # preserve order, dedupe
|
|
426
|
-
parts.append(f"
|
|
460
|
+
parts.append(f"Applied {len(results)} edits across {len(unique)} files: {', '.join(unique)}")
|
|
461
|
+
# Append compact diffs for each edit
|
|
462
|
+
for entry in edits:
|
|
463
|
+
old = entry.get("old_string", "")
|
|
464
|
+
new = entry.get("new_string", "")
|
|
465
|
+
path = entry.get("path", "")
|
|
466
|
+
if old and path in written:
|
|
467
|
+
diff_text = _compact_diff(old, new, path)
|
|
468
|
+
if diff_text:
|
|
469
|
+
parts.append(diff_text)
|
|
427
470
|
if errors:
|
|
428
471
|
parts.append("\n".join(errors))
|
|
429
472
|
return "\n".join(parts) or "No edits to apply."
|
|
@@ -209,7 +209,7 @@ def test_edit_file_basic(tmp_path):
|
|
|
209
209
|
finally:
|
|
210
210
|
set_skip_permissions(False)
|
|
211
211
|
|
|
212
|
-
assert "
|
|
212
|
+
assert "Edited" in result
|
|
213
213
|
assert f.read_text() == "def hello():\n return 'earth'\n"
|
|
214
214
|
|
|
215
215
|
|
|
@@ -234,7 +234,7 @@ def test_edit_file_search_replace(tmp_path):
|
|
|
234
234
|
finally:
|
|
235
235
|
set_skip_permissions(False)
|
|
236
236
|
|
|
237
|
-
assert "
|
|
237
|
+
assert "Edited" in result
|
|
238
238
|
updated = f.read_text()
|
|
239
239
|
assert "DB_HOST = 'production.example.com'" in updated
|
|
240
240
|
assert "DB_PORT = 5433" in updated
|
|
@@ -427,7 +427,7 @@ class TestEditFiles:
|
|
|
427
427
|
finally:
|
|
428
428
|
set_skip_permissions(False)
|
|
429
429
|
|
|
430
|
-
assert "
|
|
430
|
+
assert "Applied" in result or "Edited" in result
|
|
431
431
|
assert f1.read_text() == "alpha = 10"
|
|
432
432
|
assert f2.read_text() == "beta = 20"
|
|
433
433
|
|
|
@@ -446,7 +446,7 @@ class TestEditFiles:
|
|
|
446
446
|
finally:
|
|
447
447
|
set_skip_permissions(False)
|
|
448
448
|
|
|
449
|
-
assert "
|
|
449
|
+
assert "Applied" in result or "Edited" in result
|
|
450
450
|
content = f.read_text()
|
|
451
451
|
assert "HOST = 'prod.example.com'" in content
|
|
452
452
|
assert "PORT = 8080" in content
|
|
@@ -501,7 +501,7 @@ class TestEditFiles:
|
|
|
501
501
|
finally:
|
|
502
502
|
set_skip_permissions(False)
|
|
503
503
|
|
|
504
|
-
assert "
|
|
504
|
+
assert "Applied" in result or "Edited" in result
|
|
505
505
|
|
|
506
506
|
content_a = f1.read_text()
|
|
507
507
|
assert "import logging" in content_a
|
aru_code-0.13.3/aru/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.13.3"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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
|