aru-code 0.1.0__py3-none-any.whl
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/__init__.py +1 -0
- aru/agents/__init__.py +0 -0
- aru/agents/base.py +188 -0
- aru/agents/executor.py +32 -0
- aru/agents/planner.py +85 -0
- aru/cli.py +1993 -0
- aru/config.py +237 -0
- aru/context.py +287 -0
- aru/providers.py +433 -0
- aru/tools/__init__.py +0 -0
- aru/tools/ast_tools.py +422 -0
- aru/tools/codebase.py +1328 -0
- aru/tools/gitignore.py +109 -0
- aru/tools/mcp_client.py +156 -0
- aru/tools/ranker.py +220 -0
- aru/tools/tasklist.py +183 -0
- aru_code-0.1.0.dist-info/METADATA +385 -0
- aru_code-0.1.0.dist-info/RECORD +22 -0
- aru_code-0.1.0.dist-info/WHEEL +5 -0
- aru_code-0.1.0.dist-info/entry_points.txt +2 -0
- aru_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- aru_code-0.1.0.dist-info/top_level.txt +1 -0
aru/config.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Configuration loader for AGENTS.md and .agents/ directory.
|
|
2
|
+
|
|
3
|
+
Supports:
|
|
4
|
+
- AGENTS.md: Project-level agent instructions (appended to system prompt)
|
|
5
|
+
- .agents/commands/*.md: Custom slash commands (filename = command name)
|
|
6
|
+
- .agents/skills/*.md: Custom skills/personas (loaded as additional instructions)
|
|
7
|
+
|
|
8
|
+
Follows the Gemini .agents convention for cross-platform compatibility.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class CustomCommand:
|
|
20
|
+
"""A custom command defined in .agents/commands/."""
|
|
21
|
+
name: str
|
|
22
|
+
description: str
|
|
23
|
+
template: str
|
|
24
|
+
source_path: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Skill:
|
|
29
|
+
"""A skill defined in .agents/skills/."""
|
|
30
|
+
name: str
|
|
31
|
+
description: str
|
|
32
|
+
content: str
|
|
33
|
+
source_path: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
MAX_README_CHARS = 2000 # Reduced from 8000 to save ~1.7K tokens per request
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class AgentConfig:
|
|
41
|
+
"""Loaded configuration from AGENTS.md, README.md, and .agents/ directory."""
|
|
42
|
+
readme_md: str = ""
|
|
43
|
+
agents_md: str = ""
|
|
44
|
+
commands: dict[str, CustomCommand] = field(default_factory=dict)
|
|
45
|
+
skills: dict[str, Skill] = field(default_factory=dict)
|
|
46
|
+
permissions: dict[str, Any] = field(default_factory=dict)
|
|
47
|
+
model_defaults: dict[str, str] = field(default_factory=dict)
|
|
48
|
+
plan_reviewer: bool = True
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def has_instructions(self) -> bool:
|
|
52
|
+
return bool(self.agents_md) or bool(self.skills)
|
|
53
|
+
|
|
54
|
+
def get_extra_instructions(self, active_skills: list[str] | None = None, lightweight: bool = False) -> str:
|
|
55
|
+
"""Build extra instructions from README.md, AGENTS.md, and active skills.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
active_skills: List of skill names to include.
|
|
59
|
+
lightweight: If True, skip README.md to save tokens (for executor steps).
|
|
60
|
+
"""
|
|
61
|
+
parts = []
|
|
62
|
+
if self.readme_md and not lightweight:
|
|
63
|
+
parts.append(f"## Project Overview (README.md)\n\n{self.readme_md}")
|
|
64
|
+
if self.agents_md:
|
|
65
|
+
parts.append(f"## Project Instructions (AGENTS.md)\n\n{self.agents_md}")
|
|
66
|
+
if active_skills:
|
|
67
|
+
for name in active_skills:
|
|
68
|
+
if name in self.skills:
|
|
69
|
+
skill = self.skills[name]
|
|
70
|
+
parts.append(f"## Skill: {skill.name}\n\n{skill.content}")
|
|
71
|
+
return "\n\n".join(parts)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _parse_frontmatter(content: str) -> tuple[dict[str, str], str]:
|
|
75
|
+
"""Parse YAML-like frontmatter from a markdown file.
|
|
76
|
+
|
|
77
|
+
Returns (metadata_dict, body_content).
|
|
78
|
+
"""
|
|
79
|
+
metadata: dict[str, str] = {}
|
|
80
|
+
body = content
|
|
81
|
+
|
|
82
|
+
if content.startswith("---"):
|
|
83
|
+
lines = content.split("\n")
|
|
84
|
+
end_idx = -1
|
|
85
|
+
for i in range(1, len(lines)):
|
|
86
|
+
if lines[i].strip() == "---":
|
|
87
|
+
end_idx = i
|
|
88
|
+
break
|
|
89
|
+
if end_idx > 0:
|
|
90
|
+
for line in lines[1:end_idx]:
|
|
91
|
+
if ":" in line:
|
|
92
|
+
key, _, value = line.partition(":")
|
|
93
|
+
metadata[key.strip()] = value.strip()
|
|
94
|
+
body = "\n".join(lines[end_idx + 1:]).strip()
|
|
95
|
+
|
|
96
|
+
return metadata, body
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _load_commands(agents_dir: Path) -> dict[str, CustomCommand]:
|
|
100
|
+
"""Load custom commands from .agents/commands/."""
|
|
101
|
+
commands_dir = agents_dir / "commands"
|
|
102
|
+
commands: dict[str, CustomCommand] = {}
|
|
103
|
+
|
|
104
|
+
if not commands_dir.is_dir():
|
|
105
|
+
return commands
|
|
106
|
+
|
|
107
|
+
for filepath in sorted(commands_dir.iterdir()):
|
|
108
|
+
if filepath.suffix != ".md":
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
name = filepath.stem
|
|
112
|
+
try:
|
|
113
|
+
content = filepath.read_text(encoding="utf-8")
|
|
114
|
+
except (OSError, UnicodeDecodeError):
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
metadata, body = _parse_frontmatter(content)
|
|
118
|
+
description = metadata.get("description", f"Custom command: {name}")
|
|
119
|
+
|
|
120
|
+
commands[name] = CustomCommand(
|
|
121
|
+
name=name,
|
|
122
|
+
description=description,
|
|
123
|
+
template=body,
|
|
124
|
+
source_path=str(filepath),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return commands
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _load_skills(agents_dir: Path) -> dict[str, Skill]:
|
|
131
|
+
"""Load skills from .agents/skills/."""
|
|
132
|
+
skills_dir = agents_dir / "skills"
|
|
133
|
+
skills: dict[str, Skill] = {}
|
|
134
|
+
|
|
135
|
+
if not skills_dir.is_dir():
|
|
136
|
+
return skills
|
|
137
|
+
|
|
138
|
+
for filepath in sorted(skills_dir.iterdir()):
|
|
139
|
+
if filepath.suffix != ".md":
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
name = filepath.stem
|
|
143
|
+
try:
|
|
144
|
+
content = filepath.read_text(encoding="utf-8")
|
|
145
|
+
except (OSError, UnicodeDecodeError):
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
metadata, body = _parse_frontmatter(content)
|
|
149
|
+
description = metadata.get("description", f"Skill: {name}")
|
|
150
|
+
|
|
151
|
+
skills[name] = Skill(
|
|
152
|
+
name=name,
|
|
153
|
+
description=description,
|
|
154
|
+
content=body,
|
|
155
|
+
source_path=str(filepath),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
return skills
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def load_config(cwd: str | None = None) -> AgentConfig:
|
|
162
|
+
"""Load agent configuration from AGENTS.md and .agents/ directory.
|
|
163
|
+
|
|
164
|
+
Searches the current working directory for:
|
|
165
|
+
- AGENTS.md: Project-level instructions
|
|
166
|
+
- .agents/commands/*.md: Custom slash commands
|
|
167
|
+
- .agents/skills/*.md: Custom skills/personas
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
cwd: Working directory to search in. Defaults to os.getcwd().
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
AgentConfig with all loaded configuration.
|
|
174
|
+
"""
|
|
175
|
+
root = Path(cwd or os.getcwd())
|
|
176
|
+
config = AgentConfig()
|
|
177
|
+
|
|
178
|
+
# Load README.md first — gives the agent project context upfront
|
|
179
|
+
for readme_name in ("README.md", "readme.md", "Readme.md"):
|
|
180
|
+
readme_path = root / readme_name
|
|
181
|
+
if readme_path.is_file():
|
|
182
|
+
try:
|
|
183
|
+
content = readme_path.read_text(encoding="utf-8").strip()
|
|
184
|
+
config.readme_md = content[:MAX_README_CHARS]
|
|
185
|
+
except (OSError, UnicodeDecodeError):
|
|
186
|
+
pass
|
|
187
|
+
break
|
|
188
|
+
|
|
189
|
+
# Load AGENTS.md
|
|
190
|
+
agents_md_path = root / "AGENTS.md"
|
|
191
|
+
if agents_md_path.is_file():
|
|
192
|
+
try:
|
|
193
|
+
config.agents_md = agents_md_path.read_text(encoding="utf-8").strip()
|
|
194
|
+
except (OSError, UnicodeDecodeError):
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
# Load .agents/ directory
|
|
198
|
+
agents_dir = root / ".agents"
|
|
199
|
+
if agents_dir.is_dir():
|
|
200
|
+
config.commands = _load_commands(agents_dir)
|
|
201
|
+
config.skills = _load_skills(agents_dir)
|
|
202
|
+
|
|
203
|
+
# Load opencode-style config (aru.json or .aru/config.json)
|
|
204
|
+
config_paths = [root / "aru.json", root / ".aru" / "config.json"]
|
|
205
|
+
for config_path in config_paths:
|
|
206
|
+
if config_path.is_file():
|
|
207
|
+
try:
|
|
208
|
+
content = config_path.read_text(encoding="utf-8")
|
|
209
|
+
data = json.loads(content)
|
|
210
|
+
if isinstance(data, dict):
|
|
211
|
+
if "permission" in data:
|
|
212
|
+
config.permissions = data["permission"]
|
|
213
|
+
# Load provider configuration
|
|
214
|
+
if "providers" in data or "models" in data:
|
|
215
|
+
from aru.providers import load_providers_from_config
|
|
216
|
+
load_providers_from_config(data)
|
|
217
|
+
# Store model defaults for CLI
|
|
218
|
+
if "models" in data and isinstance(data["models"], dict):
|
|
219
|
+
config.model_defaults = data["models"]
|
|
220
|
+
if "plan_reviewer" in data:
|
|
221
|
+
config.plan_reviewer = bool(data["plan_reviewer"])
|
|
222
|
+
break
|
|
223
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
224
|
+
pass
|
|
225
|
+
|
|
226
|
+
return config
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def render_command_template(template: str, user_input: str) -> str:
|
|
230
|
+
"""Render a command template with user input.
|
|
231
|
+
|
|
232
|
+
Replaces $INPUT with the user's arguments.
|
|
233
|
+
Also supports $SELECTION (empty if not provided) for future use.
|
|
234
|
+
"""
|
|
235
|
+
result = template.replace("$INPUT", user_input)
|
|
236
|
+
result = result.replace("$SELECTION", "")
|
|
237
|
+
return result
|
aru/context.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Context management for token optimization.
|
|
2
|
+
|
|
3
|
+
Implements three layers of token reduction:
|
|
4
|
+
1. Pruning — evict old tool/assistant outputs from history
|
|
5
|
+
2. Truncation — universal cap on tool output size
|
|
6
|
+
3. Compaction — summarize entire conversation when approaching context limits
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# ── Constants ──────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
# Pruning: protect the most recent N chars of assistant content from eviction
|
|
14
|
+
PRUNE_PROTECT_CHARS = 50_000 # ~14K tokens
|
|
15
|
+
# Pruning: minimum chars that must be freeable to justify a prune pass
|
|
16
|
+
PRUNE_MINIMUM_CHARS = 20_000 # ~5.7K tokens
|
|
17
|
+
# Placeholder that replaces evicted content
|
|
18
|
+
PRUNED_PLACEHOLDER = "[previous output cleared to save context]"
|
|
19
|
+
|
|
20
|
+
# Truncation: universal limits for any tool output
|
|
21
|
+
TRUNCATE_MAX_LINES = 500
|
|
22
|
+
TRUNCATE_MAX_BYTES = 20 * 1024 # 20 KB
|
|
23
|
+
TRUNCATE_KEEP_START = 350 # lines to keep from the start
|
|
24
|
+
TRUNCATE_KEEP_END = 100 # lines to keep from the end
|
|
25
|
+
|
|
26
|
+
# Compaction: trigger when cumulative input tokens exceed this fraction of model limit
|
|
27
|
+
COMPACTION_THRESHOLD_RATIO = 0.50
|
|
28
|
+
# Default model context limits (input tokens)
|
|
29
|
+
MODEL_CONTEXT_LIMITS: dict[str, int] = {
|
|
30
|
+
"claude-sonnet-4-5-20250929": 200_000,
|
|
31
|
+
"claude-sonnet-4-20250514": 200_000,
|
|
32
|
+
"claude-haiku-4-5-20251001": 200_000,
|
|
33
|
+
"claude-opus-4-20250514": 200_000,
|
|
34
|
+
"claude-opus-4-6": 1_000_000,
|
|
35
|
+
"claude-sonnet-4-6": 1_000_000,
|
|
36
|
+
"gpt-4o": 128_000,
|
|
37
|
+
"gpt-4o-mini": 128_000,
|
|
38
|
+
"default": 200_000,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
COMPACTION_TEMPLATE = """\
|
|
42
|
+
Summarize this conversation concisely. Preserve:
|
|
43
|
+
1. **Goal**: What the user wants to accomplish
|
|
44
|
+
2. **Key decisions**: Important choices made during the conversation
|
|
45
|
+
3. **Discoveries**: What was learned about the codebase or problem
|
|
46
|
+
4. **Accomplished**: What has been done so far (be specific about files changed)
|
|
47
|
+
5. **Relevant files**: File paths that are important for continuing the work
|
|
48
|
+
6. **Next steps**: What remains to be done
|
|
49
|
+
|
|
50
|
+
Be concise but complete. This summary replaces the full conversation history."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ── Layer 1: Pruning ──────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
def prune_history(history: list[dict[str, str]]) -> list[dict[str, str]]:
|
|
56
|
+
"""Replace old assistant messages with a short placeholder to reduce tokens.
|
|
57
|
+
|
|
58
|
+
Walks backward through history, protecting the most recent assistant
|
|
59
|
+
content (up to PRUNE_PROTECT_CHARS). Older assistant messages beyond
|
|
60
|
+
that budget are replaced with a compact placeholder.
|
|
61
|
+
|
|
62
|
+
Returns a new list (does not mutate the input).
|
|
63
|
+
"""
|
|
64
|
+
if len(history) <= 2:
|
|
65
|
+
return list(history)
|
|
66
|
+
|
|
67
|
+
# Calculate total assistant chars
|
|
68
|
+
total_assistant_chars = sum(
|
|
69
|
+
len(msg["content"]) for msg in history if msg["role"] == "assistant"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Not enough to prune
|
|
73
|
+
if total_assistant_chars < PRUNE_PROTECT_CHARS + PRUNE_MINIMUM_CHARS:
|
|
74
|
+
return list(history)
|
|
75
|
+
|
|
76
|
+
# Walk backward, protecting recent content
|
|
77
|
+
result = list(history)
|
|
78
|
+
protected = 0
|
|
79
|
+
|
|
80
|
+
for i in range(len(result) - 1, -1, -1):
|
|
81
|
+
msg = result[i]
|
|
82
|
+
if msg["role"] != "assistant":
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
msg_len = len(msg["content"])
|
|
86
|
+
if protected + msg_len <= PRUNE_PROTECT_CHARS:
|
|
87
|
+
# Still within protection window
|
|
88
|
+
protected += msg_len
|
|
89
|
+
else:
|
|
90
|
+
# Beyond protection window — prune this message
|
|
91
|
+
if msg["content"] != PRUNED_PLACEHOLDER:
|
|
92
|
+
result[i] = {"role": "assistant", "content": PRUNED_PLACEHOLDER}
|
|
93
|
+
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── Layer 2: Truncation ───────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
def truncate_output(text: str) -> str:
|
|
100
|
+
"""Universal truncation for tool outputs.
|
|
101
|
+
|
|
102
|
+
Caps output at TRUNCATE_MAX_BYTES / TRUNCATE_MAX_LINES, keeping the
|
|
103
|
+
start and end with a middle marker showing what was cut.
|
|
104
|
+
"""
|
|
105
|
+
if not text:
|
|
106
|
+
return text
|
|
107
|
+
|
|
108
|
+
# Check byte size
|
|
109
|
+
byte_len = len(text.encode("utf-8", errors="replace"))
|
|
110
|
+
lines = text.splitlines(keepends=True)
|
|
111
|
+
line_count = len(lines)
|
|
112
|
+
|
|
113
|
+
if byte_len <= TRUNCATE_MAX_BYTES and line_count <= TRUNCATE_MAX_LINES:
|
|
114
|
+
return text
|
|
115
|
+
|
|
116
|
+
# Truncate by lines
|
|
117
|
+
if line_count > TRUNCATE_MAX_LINES:
|
|
118
|
+
head = lines[:TRUNCATE_KEEP_START]
|
|
119
|
+
tail = lines[-TRUNCATE_KEEP_END:]
|
|
120
|
+
omitted = line_count - TRUNCATE_KEEP_START - TRUNCATE_KEEP_END
|
|
121
|
+
return (
|
|
122
|
+
"".join(head)
|
|
123
|
+
+ f"\n\n[... {omitted:,} lines omitted ({line_count:,} total) — "
|
|
124
|
+
f"use offset/limit or a more specific query ...]\n\n"
|
|
125
|
+
+ "".join(tail)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Truncate by bytes (lines fit but total bytes too large)
|
|
129
|
+
kept_lines: list[str] = []
|
|
130
|
+
total = 0
|
|
131
|
+
for line in lines:
|
|
132
|
+
line_bytes = len(line.encode("utf-8", errors="replace"))
|
|
133
|
+
if total + line_bytes > TRUNCATE_MAX_BYTES:
|
|
134
|
+
break
|
|
135
|
+
kept_lines.append(line)
|
|
136
|
+
total += line_bytes
|
|
137
|
+
|
|
138
|
+
remaining = line_count - len(kept_lines)
|
|
139
|
+
return (
|
|
140
|
+
"".join(kept_lines)
|
|
141
|
+
+ f"\n\n[... truncated at ~{TRUNCATE_MAX_BYTES // 1024}KB — "
|
|
142
|
+
f"{remaining:,} more lines — use offset/limit to read further ...]\n"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ── Layer 3: Compaction ───────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
def should_compact(total_input_tokens: int, model_id: str = "default") -> bool:
|
|
149
|
+
"""Check if the conversation should be compacted based on token usage."""
|
|
150
|
+
limit = MODEL_CONTEXT_LIMITS.get(model_id, MODEL_CONTEXT_LIMITS["default"])
|
|
151
|
+
threshold = int(limit * COMPACTION_THRESHOLD_RATIO)
|
|
152
|
+
return total_input_tokens >= threshold
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def build_compaction_prompt(history: list[dict[str, str]], plan_task: str | None = None) -> str:
|
|
156
|
+
"""Build the prompt sent to the compaction agent to summarize the conversation."""
|
|
157
|
+
parts = [COMPACTION_TEMPLATE, "\n\n---\n\n## Conversation to summarize:\n"]
|
|
158
|
+
|
|
159
|
+
if plan_task:
|
|
160
|
+
parts.append(f"**Active task:** {plan_task}\n\n")
|
|
161
|
+
|
|
162
|
+
for msg in history:
|
|
163
|
+
role = msg["role"].upper()
|
|
164
|
+
content = msg["content"]
|
|
165
|
+
# Cap individual messages in the compaction input to avoid blowing up
|
|
166
|
+
if len(content) > 2000:
|
|
167
|
+
content = content[:2000] + f"... [{len(content) - 2000} chars truncated]"
|
|
168
|
+
parts.append(f"**{role}:** {content}\n\n")
|
|
169
|
+
|
|
170
|
+
return "".join(parts)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def apply_compaction(history: list[dict[str, str]], summary: str) -> list[dict[str, str]]:
|
|
174
|
+
"""Replace history with a compaction summary + the most recent exchange."""
|
|
175
|
+
compacted = [
|
|
176
|
+
{"role": "user", "content": f"[Conversation compacted]\n\n{summary}"}
|
|
177
|
+
]
|
|
178
|
+
# Keep the last user message and last assistant message for continuity
|
|
179
|
+
last_user = None
|
|
180
|
+
last_assistant = None
|
|
181
|
+
for msg in reversed(history):
|
|
182
|
+
if msg["role"] == "user" and last_user is None:
|
|
183
|
+
last_user = msg
|
|
184
|
+
elif msg["role"] == "assistant" and last_assistant is None:
|
|
185
|
+
last_assistant = msg
|
|
186
|
+
if last_user and last_assistant:
|
|
187
|
+
break
|
|
188
|
+
|
|
189
|
+
if last_assistant:
|
|
190
|
+
compacted.append(last_assistant)
|
|
191
|
+
if last_user and last_user != compacted[0]:
|
|
192
|
+
compacted.append(last_user)
|
|
193
|
+
|
|
194
|
+
return compacted
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def compact_conversation(
|
|
198
|
+
history: list[dict[str, str]],
|
|
199
|
+
model_ref: str,
|
|
200
|
+
plan_task: str | None = None,
|
|
201
|
+
) -> list[dict[str, str]]:
|
|
202
|
+
"""Run the compaction agent to summarize and replace history.
|
|
203
|
+
|
|
204
|
+
Uses a small/fast model for the summarization to minimize cost.
|
|
205
|
+
Falls back to simple truncation if the agent call fails.
|
|
206
|
+
"""
|
|
207
|
+
from aru.tools.codebase import _get_small_model_ref
|
|
208
|
+
from aru.providers import create_model
|
|
209
|
+
|
|
210
|
+
prompt = build_compaction_prompt(history, plan_task)
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
from agno.agent import Agent
|
|
214
|
+
|
|
215
|
+
small_ref = _get_small_model_ref()
|
|
216
|
+
compactor = Agent(
|
|
217
|
+
name="Compactor",
|
|
218
|
+
model=create_model(small_ref, max_tokens=2048),
|
|
219
|
+
instructions="You summarize conversations concisely. Output ONLY the summary, no preamble.",
|
|
220
|
+
markdown=True,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
result = await compactor.arun(prompt, stream=False)
|
|
224
|
+
summary = result.content if result and result.content else ""
|
|
225
|
+
|
|
226
|
+
if not summary:
|
|
227
|
+
# Fallback: simple mechanical summary
|
|
228
|
+
summary = _fallback_summary(history, plan_task)
|
|
229
|
+
|
|
230
|
+
return apply_compaction(history, summary)
|
|
231
|
+
|
|
232
|
+
except Exception:
|
|
233
|
+
# Fallback if agent fails
|
|
234
|
+
summary = _fallback_summary(history, plan_task)
|
|
235
|
+
return apply_compaction(history, summary)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _fallback_summary(history: list[dict[str, str]], plan_task: str | None = None) -> str:
|
|
239
|
+
"""Mechanical summary when the compaction agent is unavailable."""
|
|
240
|
+
parts = []
|
|
241
|
+
if plan_task:
|
|
242
|
+
parts.append(f"**Task:** {plan_task}")
|
|
243
|
+
|
|
244
|
+
msg_count = len(history)
|
|
245
|
+
user_msgs = sum(1 for m in history if m["role"] == "user")
|
|
246
|
+
parts.append(f"**Conversation:** {msg_count} messages ({user_msgs} from user)")
|
|
247
|
+
|
|
248
|
+
# Extract file paths mentioned
|
|
249
|
+
import re
|
|
250
|
+
all_text = " ".join(m["content"] for m in history)
|
|
251
|
+
files = set(re.findall(r'[\w./\\-]+\.(?:py|js|ts|tsx|jsx|go|rs|java|md|json|yaml|yml|toml)', all_text))
|
|
252
|
+
if files:
|
|
253
|
+
parts.append(f"**Files referenced:** {', '.join(sorted(files)[:20])}")
|
|
254
|
+
|
|
255
|
+
# Keep last 3 messages as brief excerpts
|
|
256
|
+
parts.append("\n**Recent context:**")
|
|
257
|
+
for msg in history[-3:]:
|
|
258
|
+
role = msg["role"]
|
|
259
|
+
text = msg["content"][:300]
|
|
260
|
+
if len(msg["content"]) > 300:
|
|
261
|
+
text += "..."
|
|
262
|
+
parts.append(f"- [{role}]: {text}")
|
|
263
|
+
|
|
264
|
+
return "\n".join(parts)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def format_context_block(content: str, label: str = "Context", include_timestamp: bool = True) -> str:
|
|
268
|
+
"""Format a context block with separator and optional timestamp.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
content: The content to include in the block.
|
|
272
|
+
label: Label for the context block.
|
|
273
|
+
include_timestamp: Whether to include timestamp in the separator.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Formatted context block with separators and timestamp.
|
|
277
|
+
"""
|
|
278
|
+
from datetime import datetime
|
|
279
|
+
|
|
280
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
281
|
+
|
|
282
|
+
if include_timestamp:
|
|
283
|
+
separator = f"-- {label} ({timestamp}) --"
|
|
284
|
+
else:
|
|
285
|
+
separator = f"-- {label} --"
|
|
286
|
+
|
|
287
|
+
return f"{separator}\n{content}\n{separator}"
|