aru-code 0.13.2__tar.gz → 0.14.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {aru_code-0.13.2/aru_code.egg-info → aru_code-0.14.0}/PKG-INFO +1 -1
- aru_code-0.14.0/aru/__init__.py +1 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru/agents/base.py +18 -1
- {aru_code-0.13.2 → aru_code-0.14.0}/aru/context.py +206 -49
- {aru_code-0.13.2 → aru_code-0.14.0}/aru/runner.py +24 -5
- {aru_code-0.13.2 → aru_code-0.14.0}/aru/tools/codebase.py +45 -2
- {aru_code-0.13.2 → aru_code-0.14.0/aru_code.egg-info}/PKG-INFO +1 -1
- {aru_code-0.13.2 → aru_code-0.14.0}/pyproject.toml +1 -1
- {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_codebase.py +5 -5
- aru_code-0.13.2/aru/__init__.py +0 -1
- {aru_code-0.13.2 → aru_code-0.14.0}/LICENSE +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/README.md +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru/agent_factory.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru/agents/__init__.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru/agents/executor.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru/agents/planner.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru/cli.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru/commands.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru/completers.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru/config.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru/display.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru/permissions.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru/providers.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru/runtime.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru/session.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru/tools/__init__.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru/tools/gitignore.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru/tools/ranker.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru/tools/tasklist.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru_code.egg-info/SOURCES.txt +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/setup.cfg +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_agents_base.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_ast_tools.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_cli.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_cli_advanced.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_cli_base.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_cli_completers.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_cli_new.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_cli_run_cli.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_cli_session.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_cli_shell.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_config.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_context.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_executor.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_gitignore.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_main.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_mcp_client.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_permissions.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_planner.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_providers.py +0 -0
- {aru_code-0.13.2 → aru_code-0.14.0}/tests/test_ranker.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.14.0"
|
|
@@ -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. \
|
|
@@ -10,8 +10,6 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
# ── Constants ──────────────────────────────────────────────────────
|
|
12
12
|
|
|
13
|
-
# Pruning: protect the most recent N chars of content from eviction
|
|
14
|
-
PRUNE_PROTECT_CHARS = 50_000 # ~14K tokens
|
|
15
13
|
# Pruning: minimum chars that must be freeable to justify a prune pass
|
|
16
14
|
PRUNE_MINIMUM_CHARS = 20_000 # ~5.7K tokens
|
|
17
15
|
# Placeholder that replaces evicted content
|
|
@@ -20,15 +18,22 @@ PRUNED_PLACEHOLDER = "[previous output cleared to save context]"
|
|
|
20
18
|
PRUNE_USER_MSG_THRESHOLD = 2_000 # ~570 tokens — catches @file mentions
|
|
21
19
|
# How many chars to keep from the start of a pruned user message
|
|
22
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"}
|
|
23
25
|
|
|
24
26
|
# Truncation: universal limits for any tool output
|
|
25
27
|
TRUNCATE_MAX_LINES = 500
|
|
26
28
|
TRUNCATE_MAX_BYTES = 20 * 1024 # 20 KB
|
|
27
29
|
TRUNCATE_KEEP_START = 350 # lines to keep from the start
|
|
28
30
|
TRUNCATE_KEEP_END = 100 # lines to keep from the end
|
|
31
|
+
TRUNCATE_MAX_LINE_LENGTH = 2000 # chars per individual line (prevents minified files)
|
|
29
32
|
|
|
30
|
-
# Compaction: trigger when
|
|
33
|
+
# Compaction: trigger when per-run input tokens exceed this fraction of model limit
|
|
31
34
|
COMPACTION_THRESHOLD_RATIO = 0.85
|
|
35
|
+
# Compaction: target post-compaction size as fraction of model context limit
|
|
36
|
+
COMPACTION_TARGET_RATIO = 0.15
|
|
32
37
|
# Default model context limits (input tokens)
|
|
33
38
|
MODEL_CONTEXT_LIMITS: dict[str, int] = {
|
|
34
39
|
# Anthropic
|
|
@@ -70,40 +75,85 @@ MODEL_CONTEXT_LIMITS: dict[str, int] = {
|
|
|
70
75
|
}
|
|
71
76
|
|
|
72
77
|
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
|
|
78
|
+
Summarize this conversation into the EXACT sections below. Be concise but complete — \
|
|
79
|
+
this summary replaces the full conversation history. Output ONLY these sections:
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
## Goal
|
|
82
|
+
What the user is trying to accomplish (1-2 sentences).
|
|
83
|
+
|
|
84
|
+
## Instructions
|
|
85
|
+
Important instructions or preferences the user stated (bullet list). \
|
|
86
|
+
If none, write "None stated."
|
|
87
|
+
|
|
88
|
+
## Discoveries
|
|
89
|
+
Notable things learned about the codebase, bugs, or architecture (bullet list). \
|
|
90
|
+
If none, write "None."
|
|
91
|
+
|
|
92
|
+
## Accomplished
|
|
93
|
+
What was done so far — be specific about files created/changed and functions added/modified. \
|
|
94
|
+
List what is in progress and what remains (bullet list).
|
|
95
|
+
|
|
96
|
+
## Relevant files / directories
|
|
97
|
+
Structured list of file paths relevant to continuing the work (one per line)."""
|
|
82
98
|
|
|
83
99
|
|
|
84
100
|
# ── Layer 1: Pruning ──────────────────────────────────────────────
|
|
85
101
|
|
|
86
|
-
def
|
|
102
|
+
def _get_prune_protect_chars(model_id: str = "default") -> int:
|
|
103
|
+
"""Scale protection window based on model context size.
|
|
104
|
+
|
|
105
|
+
Larger models get more protection; smaller models prune more aggressively
|
|
106
|
+
to delay compaction. Returns ~10% of the model's context in chars (~3.5 chars/token).
|
|
107
|
+
"""
|
|
108
|
+
limit = MODEL_CONTEXT_LIMITS.get(model_id, MODEL_CONTEXT_LIMITS["default"])
|
|
109
|
+
# ~3.5 chars per token, protect ~10% of context
|
|
110
|
+
protect = int(limit * 0.10 * 3.5)
|
|
111
|
+
# Clamp between 20K (minimum usable) and 80K (diminishing returns)
|
|
112
|
+
return max(20_000, min(protect, 80_000))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def prune_history(
|
|
116
|
+
history: list[dict[str, str]], model_id: str = "default"
|
|
117
|
+
) -> list[dict[str, str]]:
|
|
87
118
|
"""Replace old messages with a short placeholder to reduce tokens.
|
|
88
119
|
|
|
89
120
|
Walks backward through history, protecting the most recent content
|
|
90
|
-
(
|
|
91
|
-
|
|
92
|
-
- Assistant messages: replaced entirely with placeholder
|
|
121
|
+
(scaled to the model's context size). Older messages beyond that
|
|
122
|
+
budget are pruned:
|
|
123
|
+
- Assistant messages: replaced entirely with placeholder (unless protected)
|
|
93
124
|
- User messages over PRUNE_USER_MSG_THRESHOLD: truncated to first N chars
|
|
94
125
|
|
|
126
|
+
Protection layers:
|
|
127
|
+
1. Turn-based: last PRUNE_PROTECT_TURNS user turns always kept
|
|
128
|
+
2. Char-based: recent content within the protection window
|
|
129
|
+
3. Content-based: messages containing PRUNE_PROTECTED_MARKERS never pruned
|
|
130
|
+
|
|
95
131
|
Returns a new list (does not mutate the input).
|
|
96
132
|
"""
|
|
97
133
|
if len(history) <= 2:
|
|
98
134
|
return list(history)
|
|
99
135
|
|
|
136
|
+
protect_chars = _get_prune_protect_chars(model_id)
|
|
137
|
+
|
|
100
138
|
# Calculate total prunable chars (both roles)
|
|
101
139
|
total_chars = sum(len(msg["content"]) for msg in history)
|
|
102
140
|
|
|
103
141
|
# Not enough to prune
|
|
104
|
-
if total_chars <
|
|
142
|
+
if total_chars < protect_chars + PRUNE_MINIMUM_CHARS:
|
|
105
143
|
return list(history)
|
|
106
144
|
|
|
145
|
+
# Identify indices of last N user turns (always protected)
|
|
146
|
+
turn_protected: set[int] = set()
|
|
147
|
+
user_turns_seen = 0
|
|
148
|
+
for i in range(len(history) - 1, -1, -1):
|
|
149
|
+
if history[i]["role"] == "user":
|
|
150
|
+
user_turns_seen += 1
|
|
151
|
+
if user_turns_seen <= PRUNE_PROTECT_TURNS:
|
|
152
|
+
turn_protected.add(i)
|
|
153
|
+
# Also protect the assistant response right after this user turn
|
|
154
|
+
if i + 1 < len(history):
|
|
155
|
+
turn_protected.add(i + 1)
|
|
156
|
+
|
|
107
157
|
# Walk backward, protecting recent content
|
|
108
158
|
result = list(history)
|
|
109
159
|
protected = 0
|
|
@@ -112,17 +162,25 @@ def prune_history(history: list[dict[str, str]]) -> list[dict[str, str]]:
|
|
|
112
162
|
msg = result[i]
|
|
113
163
|
msg_len = len(msg["content"])
|
|
114
164
|
|
|
115
|
-
|
|
165
|
+
# Turn-based protection: never prune last N user turns
|
|
166
|
+
if i in turn_protected:
|
|
167
|
+
protected += msg_len
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
if protected + msg_len <= protect_chars:
|
|
116
171
|
# Still within protection window
|
|
117
172
|
protected += msg_len
|
|
118
173
|
else:
|
|
174
|
+
# Check protected markers before pruning
|
|
175
|
+
if any(marker in msg["content"] for marker in PRUNE_PROTECTED_MARKERS):
|
|
176
|
+
protected += msg_len
|
|
177
|
+
continue
|
|
178
|
+
|
|
119
179
|
# Beyond protection window — prune
|
|
120
180
|
if msg["role"] == "assistant":
|
|
121
181
|
if msg["content"] != PRUNED_PLACEHOLDER:
|
|
122
182
|
result[i] = {"role": "assistant", "content": PRUNED_PLACEHOLDER}
|
|
123
183
|
elif msg["role"] == "user" and msg_len > PRUNE_USER_MSG_THRESHOLD:
|
|
124
|
-
# Large user messages (e.g. @file mentions with file contents)
|
|
125
|
-
# are truncated to keep a brief summary of the original request
|
|
126
184
|
truncated = msg["content"][:PRUNE_USER_MSG_KEEP] + \
|
|
127
185
|
f"\n\n[... {msg_len - PRUNE_USER_MSG_KEEP:,} chars pruned to save context ...]"
|
|
128
186
|
result[i] = {"role": "user", "content": truncated}
|
|
@@ -132,11 +190,36 @@ def prune_history(history: list[dict[str, str]]) -> list[dict[str, str]]:
|
|
|
132
190
|
|
|
133
191
|
# ── Layer 2: Truncation ───────────────────────────────────────────
|
|
134
192
|
|
|
193
|
+
def _truncate_long_lines(lines: list[str]) -> list[str]:
|
|
194
|
+
"""Truncate individual lines that exceed MAX_LINE_LENGTH.
|
|
195
|
+
|
|
196
|
+
Prevents minified JS/CSS or log lines from consuming massive tokens.
|
|
197
|
+
"""
|
|
198
|
+
result = []
|
|
199
|
+
for line in lines:
|
|
200
|
+
if len(line) > TRUNCATE_MAX_LINE_LENGTH:
|
|
201
|
+
result.append(
|
|
202
|
+
line[:TRUNCATE_MAX_LINE_LENGTH]
|
|
203
|
+
+ f"... (line truncated to {TRUNCATE_MAX_LINE_LENGTH} chars)\n"
|
|
204
|
+
)
|
|
205
|
+
else:
|
|
206
|
+
result.append(line)
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
_TRUNCATION_HINT = (
|
|
211
|
+
"\n[Hint: Use grep_search to find specific content, or read_file with "
|
|
212
|
+
"start_line/end_line for incremental reading. "
|
|
213
|
+
"For large exploration tasks, use delegate_task to keep your context clean.]"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
135
217
|
def truncate_output(text: str) -> str:
|
|
136
218
|
"""Universal truncation for tool outputs.
|
|
137
219
|
|
|
138
220
|
Caps output at TRUNCATE_MAX_BYTES / TRUNCATE_MAX_LINES, keeping the
|
|
139
221
|
start and end with a middle marker showing what was cut.
|
|
222
|
+
Also truncates individual lines exceeding TRUNCATE_MAX_LINE_LENGTH.
|
|
140
223
|
"""
|
|
141
224
|
if not text:
|
|
142
225
|
return text
|
|
@@ -146,8 +229,11 @@ def truncate_output(text: str) -> str:
|
|
|
146
229
|
lines = text.splitlines(keepends=True)
|
|
147
230
|
line_count = len(lines)
|
|
148
231
|
|
|
232
|
+
# Truncate individual long lines first
|
|
233
|
+
lines = _truncate_long_lines(lines)
|
|
234
|
+
|
|
149
235
|
if byte_len <= TRUNCATE_MAX_BYTES and line_count <= TRUNCATE_MAX_LINES:
|
|
150
|
-
return
|
|
236
|
+
return "".join(lines)
|
|
151
237
|
|
|
152
238
|
# Truncate by lines
|
|
153
239
|
if line_count > TRUNCATE_MAX_LINES:
|
|
@@ -156,8 +242,8 @@ def truncate_output(text: str) -> str:
|
|
|
156
242
|
omitted = line_count - TRUNCATE_KEEP_START - TRUNCATE_KEEP_END
|
|
157
243
|
return (
|
|
158
244
|
"".join(head)
|
|
159
|
-
+ f"\n\n[... {omitted:,} lines omitted ({line_count:,} total)
|
|
160
|
-
|
|
245
|
+
+ f"\n\n[... {omitted:,} lines omitted ({line_count:,} total)]"
|
|
246
|
+
+ _TRUNCATION_HINT + "\n\n"
|
|
161
247
|
+ "".join(tail)
|
|
162
248
|
)
|
|
163
249
|
|
|
@@ -175,27 +261,86 @@ def truncate_output(text: str) -> str:
|
|
|
175
261
|
return (
|
|
176
262
|
"".join(kept_lines)
|
|
177
263
|
+ f"\n\n[... truncated at ~{TRUNCATE_MAX_BYTES // 1024}KB — "
|
|
178
|
-
f"{remaining:,} more lines
|
|
264
|
+
f"{remaining:,} more lines]"
|
|
265
|
+
+ _TRUNCATION_HINT + "\n"
|
|
179
266
|
)
|
|
180
267
|
|
|
181
268
|
|
|
182
269
|
# ── Layer 3: Compaction ───────────────────────────────────────────
|
|
183
270
|
|
|
184
|
-
def
|
|
185
|
-
"""
|
|
271
|
+
def estimate_history_tokens(history: list[dict[str, str]]) -> int:
|
|
272
|
+
"""Estimate token count from conversation history chars (~3.5 chars/token)."""
|
|
273
|
+
total_chars = sum(len(msg["content"]) for msg in history)
|
|
274
|
+
return int(total_chars / 3.5)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def should_compact(
|
|
278
|
+
history_or_tokens: int | list[dict[str, str]],
|
|
279
|
+
model_id: str = "default",
|
|
280
|
+
) -> bool:
|
|
281
|
+
"""Check if the conversation should be compacted (reactive, post-run).
|
|
282
|
+
|
|
283
|
+
Accepts either an estimated token count (int) or the history list
|
|
284
|
+
(from which tokens are estimated via char count).
|
|
285
|
+
"""
|
|
286
|
+
if isinstance(history_or_tokens, list):
|
|
287
|
+
tokens = estimate_history_tokens(history_or_tokens)
|
|
288
|
+
else:
|
|
289
|
+
tokens = history_or_tokens
|
|
186
290
|
limit = MODEL_CONTEXT_LIMITS.get(model_id, MODEL_CONTEXT_LIMITS["default"])
|
|
187
291
|
threshold = int(limit * COMPACTION_THRESHOLD_RATIO)
|
|
188
|
-
return
|
|
292
|
+
return tokens >= threshold
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def would_prune(history: list[dict[str, str]], model_id: str = "default") -> bool:
|
|
296
|
+
"""Check if prune_history would discard content from this history.
|
|
297
|
+
|
|
298
|
+
Uses the exact same criteria as prune_history: total chars exceed
|
|
299
|
+
the protection window + minimum prunable threshold.
|
|
300
|
+
"""
|
|
301
|
+
if len(history) <= 2:
|
|
302
|
+
return False
|
|
303
|
+
total_chars = sum(len(msg["content"]) for msg in history)
|
|
304
|
+
protect_chars = _get_prune_protect_chars(model_id)
|
|
305
|
+
return total_chars >= protect_chars + PRUNE_MINIMUM_CHARS
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _split_history(history: list[dict[str, str]], model_id: str = "default") -> tuple[list[dict[str, str]], list[dict[str, str]]]:
|
|
309
|
+
"""Split history into old (to summarize) and recent (to keep intact).
|
|
310
|
+
|
|
311
|
+
Uses the same protection window as pruning.
|
|
312
|
+
"""
|
|
313
|
+
protect_chars = _get_prune_protect_chars(model_id)
|
|
314
|
+
protected = 0
|
|
315
|
+
split_idx = len(history)
|
|
316
|
+
for i in range(len(history) - 1, -1, -1):
|
|
317
|
+
msg_len = len(history[i]["content"])
|
|
318
|
+
if protected + msg_len <= protect_chars:
|
|
319
|
+
protected += msg_len
|
|
320
|
+
split_idx = i
|
|
321
|
+
else:
|
|
322
|
+
break
|
|
323
|
+
return history[:split_idx], history[split_idx:]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def build_compaction_prompt(
|
|
327
|
+
history: list[dict[str, str]],
|
|
328
|
+
plan_task: str | None = None,
|
|
329
|
+
model_id: str = "default",
|
|
330
|
+
) -> str:
|
|
331
|
+
"""Build the prompt sent to the compaction agent.
|
|
189
332
|
|
|
333
|
+
Only includes OLD messages (outside the protection window) for
|
|
334
|
+
summarization. Recent messages are kept intact by apply_compaction.
|
|
335
|
+
"""
|
|
336
|
+
old_msgs, _ = _split_history(history, model_id)
|
|
190
337
|
|
|
191
|
-
def build_compaction_prompt(history: list[dict[str, str]], plan_task: str | None = None) -> str:
|
|
192
|
-
"""Build the prompt sent to the compaction agent to summarize the conversation."""
|
|
193
338
|
parts = [COMPACTION_TEMPLATE, "\n\n---\n\n## Conversation to summarize:\n"]
|
|
194
339
|
|
|
195
340
|
if plan_task:
|
|
196
341
|
parts.append(f"**Active task:** {plan_task}\n\n")
|
|
197
342
|
|
|
198
|
-
for msg in
|
|
343
|
+
for msg in old_msgs:
|
|
199
344
|
role = msg["role"].upper()
|
|
200
345
|
content = msg["content"]
|
|
201
346
|
# Cap individual messages in the compaction input to avoid blowing up
|
|
@@ -206,26 +351,37 @@ def build_compaction_prompt(history: list[dict[str, str]], plan_task: str | None
|
|
|
206
351
|
return "".join(parts)
|
|
207
352
|
|
|
208
353
|
|
|
209
|
-
|
|
210
|
-
|
|
354
|
+
|
|
355
|
+
def apply_compaction(
|
|
356
|
+
history: list[dict[str, str]], summary: str, model_id: str = "default"
|
|
357
|
+
) -> list[dict[str, str]]:
|
|
358
|
+
"""Replace OLD messages with a summary, keep RECENT messages intact.
|
|
359
|
+
|
|
360
|
+
Uses the same protection window as pruning: recent messages within
|
|
361
|
+
the window are preserved as-is, older messages are replaced by a
|
|
362
|
+
compaction summary. Replays the last user message to maintain continuity.
|
|
363
|
+
"""
|
|
364
|
+
_, recent = _split_history(history, model_id)
|
|
365
|
+
|
|
211
366
|
compacted = [
|
|
212
367
|
{"role": "user", "content": f"[Conversation compacted]\n\n{summary}"}
|
|
213
368
|
]
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
369
|
+
compacted.extend(recent)
|
|
370
|
+
|
|
371
|
+
# Replay: ensure the last message is from the user so the LLM continues naturally
|
|
372
|
+
if not compacted or compacted[-1]["role"] != "user":
|
|
373
|
+
# Find last user message in original history for replay
|
|
374
|
+
last_user = None
|
|
375
|
+
for msg in reversed(history):
|
|
376
|
+
if msg["role"] == "user":
|
|
377
|
+
last_user = msg["content"]
|
|
378
|
+
break
|
|
379
|
+
if last_user:
|
|
380
|
+
# Truncate replayed message to avoid re-bloating context
|
|
381
|
+
replay = last_user[:1000] if len(last_user) > 1000 else last_user
|
|
382
|
+
compacted.append({"role": "user", "content": replay})
|
|
383
|
+
else:
|
|
384
|
+
compacted.append({"role": "user", "content": "Continue if you have next steps, or stop and ask for clarification."})
|
|
229
385
|
|
|
230
386
|
return compacted
|
|
231
387
|
|
|
@@ -234,6 +390,7 @@ async def compact_conversation(
|
|
|
234
390
|
history: list[dict[str, str]],
|
|
235
391
|
model_ref: str,
|
|
236
392
|
plan_task: str | None = None,
|
|
393
|
+
model_id: str = "default",
|
|
237
394
|
) -> list[dict[str, str]]:
|
|
238
395
|
"""Run the compaction agent to summarize and replace history.
|
|
239
396
|
|
|
@@ -243,7 +400,7 @@ async def compact_conversation(
|
|
|
243
400
|
from aru.runtime import get_ctx
|
|
244
401
|
from aru.providers import create_model
|
|
245
402
|
|
|
246
|
-
prompt = build_compaction_prompt(history, plan_task)
|
|
403
|
+
prompt = build_compaction_prompt(history, plan_task, model_id=model_id)
|
|
247
404
|
|
|
248
405
|
try:
|
|
249
406
|
from agno.agent import Agent
|
|
@@ -263,12 +420,12 @@ async def compact_conversation(
|
|
|
263
420
|
# Fallback: simple mechanical summary
|
|
264
421
|
summary = _fallback_summary(history, plan_task)
|
|
265
422
|
|
|
266
|
-
return apply_compaction(history, summary)
|
|
423
|
+
return apply_compaction(history, summary, model_id=model_id)
|
|
267
424
|
|
|
268
425
|
except Exception:
|
|
269
426
|
# Fallback if agent fails
|
|
270
427
|
summary = _fallback_summary(history, plan_task)
|
|
271
|
-
return apply_compaction(history, summary)
|
|
428
|
+
return apply_compaction(history, summary, model_id=model_id)
|
|
272
429
|
|
|
273
430
|
|
|
274
431
|
def _fallback_summary(history: list[dict[str, str]], plan_task: str | None = None) -> str:
|
|
@@ -115,11 +115,25 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
|
|
|
115
115
|
run_message = message
|
|
116
116
|
|
|
117
117
|
# Build conversation history as real messages for the LLM
|
|
118
|
-
|
|
118
|
+
# Compact BEFORE pruning: if the history is large enough that pruning
|
|
119
|
+
# would discard content, compact first to preserve context via summary
|
|
120
|
+
# instead of losing it to placeholders.
|
|
121
|
+
from aru.context import prune_history, should_compact, compact_conversation, would_prune
|
|
122
|
+
if session and session.history and not lightweight:
|
|
123
|
+
if would_prune(session.history, model_id=session.model_id):
|
|
124
|
+
try:
|
|
125
|
+
session.history = await compact_conversation(
|
|
126
|
+
session.history, session.model_ref, session.plan_task,
|
|
127
|
+
model_id=session.model_id,
|
|
128
|
+
)
|
|
129
|
+
console.print("[dim]Context compacted to save tokens.[/dim]")
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
|
|
119
133
|
history_messages: list[Message] = []
|
|
120
134
|
if session and session.history and not lightweight:
|
|
121
135
|
prior_history = session.history[:-1]
|
|
122
|
-
pruned = prune_history(prior_history)
|
|
136
|
+
pruned = prune_history(prior_history, model_id=session.model_id)
|
|
123
137
|
for msg in pruned:
|
|
124
138
|
history_messages.append(Message(role=msg["role"], content=msg["content"], from_history=True))
|
|
125
139
|
|
|
@@ -228,11 +242,16 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
|
|
|
228
242
|
if run_output and session and hasattr(run_output, "metrics"):
|
|
229
243
|
session.track_tokens(run_output.metrics)
|
|
230
244
|
|
|
231
|
-
|
|
232
|
-
|
|
245
|
+
# Reactive compaction: use per-run input_tokens (sum of all API
|
|
246
|
+
# calls within this arun) as a conservative proxy for context pressure.
|
|
247
|
+
# session.history doesn't include tool results, so char-based estimates
|
|
248
|
+
# would miss the bulk of the context sent to the model.
|
|
249
|
+
run_input_tokens = getattr(run_output.metrics, "input_tokens", 0) or 0
|
|
250
|
+
if should_compact(run_input_tokens, session.model_id):
|
|
233
251
|
try:
|
|
234
252
|
session.history = await compact_conversation(
|
|
235
|
-
session.history, session.model_ref, session.plan_task
|
|
253
|
+
session.history, session.model_ref, session.plan_task,
|
|
254
|
+
model_id=session.model_id,
|
|
236
255
|
)
|
|
237
256
|
console.print("[dim]Context compacted to save tokens.[/dim]")
|
|
238
257
|
except Exception:
|
|
@@ -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.2/aru/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.13.2"
|
|
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
|