aru-code 0.15.0__tar.gz → 0.16.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.15.0/aru_code.egg-info → aru_code-0.16.0}/PKG-INFO +6 -7
- {aru_code-0.15.0 → aru_code-0.16.0}/README.md +5 -6
- aru_code-0.16.0/aru/__init__.py +1 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/aru/agent_factory.py +8 -18
- aru_code-0.16.0/aru/cache_patch.py +133 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/aru/cli.py +21 -4
- {aru_code-0.15.0 → aru_code-0.16.0}/aru/completers.py +29 -19
- {aru_code-0.15.0 → aru_code-0.16.0}/aru/context.py +73 -54
- {aru_code-0.15.0 → aru_code-0.16.0}/aru/session.py +2 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/aru/tools/codebase.py +9 -9
- {aru_code-0.15.0 → aru_code-0.16.0/aru_code.egg-info}/PKG-INFO +6 -7
- {aru_code-0.15.0 → aru_code-0.16.0}/aru_code.egg-info/SOURCES.txt +1 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/pyproject.toml +1 -1
- {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_cli.py +22 -17
- {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_cli_completers.py +28 -36
- aru_code-0.15.0/aru/__init__.py +0 -1
- {aru_code-0.15.0 → aru_code-0.16.0}/LICENSE +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/aru/agents/__init__.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/aru/agents/base.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/aru/agents/executor.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/aru/agents/planner.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/aru/commands.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/aru/config.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/aru/display.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/aru/permissions.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/aru/providers.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/aru/runner.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/aru/runtime.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/aru/tools/__init__.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/aru/tools/gitignore.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/aru/tools/ranker.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/aru/tools/tasklist.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/setup.cfg +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_agents_base.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_cli_advanced.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_cli_base.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_cli_new.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_cli_run_cli.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_cli_session.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_cli_shell.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_codebase.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_config.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_context.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_executor.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_gitignore.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_main.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_mcp_client.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_permissions.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_planner.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_providers.py +0 -0
- {aru_code-0.15.0 → aru_code-0.16.0}/tests/test_ranker.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aru-code
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.16.0
|
|
4
4
|
Summary: A Claude Code clone built with Agno agents
|
|
5
5
|
Author-email: Estevao <estevaofon@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -56,7 +56,7 @@ An intelligent coding assistant for the terminal, powered by LLMs and [Agno](htt
|
|
|
56
56
|
- **Multi-Agent Architecture** — Specialized agents for planning, execution, and conversation
|
|
57
57
|
- **Interactive CLI** — Streaming responses, multi-line paste, session management
|
|
58
58
|
- **Image Support** — Attach images via `@` mentions for multimodal analysis (Claude, GPT-4o, Gemini)
|
|
59
|
-
- **
|
|
59
|
+
- **11 Integrated Tools** — File operations, code search, shell, web search, task delegation
|
|
60
60
|
- **Task Planning** — Break down complex tasks into steps with automatic execution
|
|
61
61
|
- **Multi-Provider** — Anthropic, OpenAI, Ollama, Groq, OpenRouter, DeepSeek, and others via custom configuration
|
|
62
62
|
- **Custom Commands, Skills, and Agents** — Extend aru via the `.agents/` directory
|
|
@@ -479,15 +479,14 @@ Aru can load tools from MCP servers. Configure in `.aru/mcp_config.json`:
|
|
|
479
479
|
|
|
480
480
|
### File Operations
|
|
481
481
|
- `read_file` — Reads files with line range support and binary detection
|
|
482
|
-
- `read_file_smart` —
|
|
483
|
-
- `write_file` — Writes files
|
|
484
|
-
- `edit_file` — Find-replace edits
|
|
482
|
+
- `read_file_smart` — Answers specific questions about a file without returning raw content
|
|
483
|
+
- `write_file` — Writes content to files, creating directories as needed
|
|
484
|
+
- `edit_file` — Find-and-replace edits on files
|
|
485
485
|
|
|
486
486
|
### Search & Discovery
|
|
487
487
|
- `glob_search` — Find files by pattern (respects .gitignore)
|
|
488
488
|
- `grep_search` — Content search with regex and file filtering
|
|
489
489
|
- `list_directory` — Directory listing with gitignore filtering
|
|
490
|
-
- `rank_files` — Multi-factor file relevance ranking (name, structure, recency)
|
|
491
490
|
|
|
492
491
|
### Shell & Web
|
|
493
492
|
- `bash` — Executes shell commands with permission gates
|
|
@@ -517,7 +516,7 @@ aru-code/
|
|
|
517
516
|
│ │ ├── planner.py # Planning agent
|
|
518
517
|
│ │ └── executor.py # Execution agent
|
|
519
518
|
│ └── tools/
|
|
520
|
-
│ ├── codebase.py #
|
|
519
|
+
│ ├── codebase.py # 11 core tools
|
|
521
520
|
│ ├── ast_tools.py # Tree-sitter code analysis
|
|
522
521
|
│ ├── ranker.py # File relevance ranking
|
|
523
522
|
│ ├── mcp_client.py # MCP client
|
|
@@ -9,7 +9,7 @@ An intelligent coding assistant for the terminal, powered by LLMs and [Agno](htt
|
|
|
9
9
|
- **Multi-Agent Architecture** — Specialized agents for planning, execution, and conversation
|
|
10
10
|
- **Interactive CLI** — Streaming responses, multi-line paste, session management
|
|
11
11
|
- **Image Support** — Attach images via `@` mentions for multimodal analysis (Claude, GPT-4o, Gemini)
|
|
12
|
-
- **
|
|
12
|
+
- **11 Integrated Tools** — File operations, code search, shell, web search, task delegation
|
|
13
13
|
- **Task Planning** — Break down complex tasks into steps with automatic execution
|
|
14
14
|
- **Multi-Provider** — Anthropic, OpenAI, Ollama, Groq, OpenRouter, DeepSeek, and others via custom configuration
|
|
15
15
|
- **Custom Commands, Skills, and Agents** — Extend aru via the `.agents/` directory
|
|
@@ -432,15 +432,14 @@ Aru can load tools from MCP servers. Configure in `.aru/mcp_config.json`:
|
|
|
432
432
|
|
|
433
433
|
### File Operations
|
|
434
434
|
- `read_file` — Reads files with line range support and binary detection
|
|
435
|
-
- `read_file_smart` —
|
|
436
|
-
- `write_file` — Writes files
|
|
437
|
-
- `edit_file` — Find-replace edits
|
|
435
|
+
- `read_file_smart` — Answers specific questions about a file without returning raw content
|
|
436
|
+
- `write_file` — Writes content to files, creating directories as needed
|
|
437
|
+
- `edit_file` — Find-and-replace edits on files
|
|
438
438
|
|
|
439
439
|
### Search & Discovery
|
|
440
440
|
- `glob_search` — Find files by pattern (respects .gitignore)
|
|
441
441
|
- `grep_search` — Content search with regex and file filtering
|
|
442
442
|
- `list_directory` — Directory listing with gitignore filtering
|
|
443
|
-
- `rank_files` — Multi-factor file relevance ranking (name, structure, recency)
|
|
444
443
|
|
|
445
444
|
### Shell & Web
|
|
446
445
|
- `bash` — Executes shell commands with permission gates
|
|
@@ -470,7 +469,7 @@ aru-code/
|
|
|
470
469
|
│ │ ├── planner.py # Planning agent
|
|
471
470
|
│ │ └── executor.py # Execution agent
|
|
472
471
|
│ └── tools/
|
|
473
|
-
│ ├── codebase.py #
|
|
472
|
+
│ ├── codebase.py # 11 core tools
|
|
474
473
|
│ ├── ast_tools.py # Tree-sitter code analysis
|
|
475
474
|
│ ├── ranker.py # File relevance ranking
|
|
476
475
|
│ ├── mcp_client.py # MCP client
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.16.0"
|
|
@@ -21,12 +21,16 @@ def create_general_agent(
|
|
|
21
21
|
in the system prompt. Placed in instructions so it's cacheable.
|
|
22
22
|
"""
|
|
23
23
|
from agno.agent import Agent
|
|
24
|
-
from agno.compression.manager import CompressionManager
|
|
25
24
|
|
|
26
25
|
from aru.tools.codebase import GENERAL_TOOLS
|
|
27
|
-
|
|
26
|
+
tools = GENERAL_TOOLS
|
|
28
27
|
|
|
29
|
-
|
|
28
|
+
# Only include AGENTS.md/project instructions on first turn to save ~1.6K tokens/turn
|
|
29
|
+
if config and not session.extra_instructions_sent:
|
|
30
|
+
extra = config.get_extra_instructions()
|
|
31
|
+
session.extra_instructions_sent = True
|
|
32
|
+
else:
|
|
33
|
+
extra = ""
|
|
30
34
|
if env_context:
|
|
31
35
|
extra = f"{extra}\n\n{env_context}" if extra else env_context
|
|
32
36
|
model_ref = model_override or session.model_ref
|
|
@@ -34,15 +38,9 @@ def create_general_agent(
|
|
|
34
38
|
return Agent(
|
|
35
39
|
name="Aru",
|
|
36
40
|
model=create_model(model_ref, max_tokens=8192),
|
|
37
|
-
tools=
|
|
41
|
+
tools=tools,
|
|
38
42
|
instructions=_build_instructions("general", extra),
|
|
39
43
|
markdown=True,
|
|
40
|
-
compress_tool_results=True,
|
|
41
|
-
compression_manager=CompressionManager(
|
|
42
|
-
model=create_model(get_ctx().small_model_ref, max_tokens=1024),
|
|
43
|
-
compress_tool_results=True,
|
|
44
|
-
compress_tool_results_limit=25,
|
|
45
|
-
),
|
|
46
44
|
tool_call_limit=20,
|
|
47
45
|
)
|
|
48
46
|
|
|
@@ -52,10 +50,8 @@ def create_custom_agent_instance(agent_def: CustomAgent, session: Session,
|
|
|
52
50
|
env_context: str = ""):
|
|
53
51
|
"""Create an Agno Agent from a CustomAgent definition."""
|
|
54
52
|
from agno.agent import Agent
|
|
55
|
-
from agno.compression.manager import CompressionManager
|
|
56
53
|
from aru.agents.base import BASE_INSTRUCTIONS
|
|
57
54
|
from aru.tools.codebase import resolve_tools
|
|
58
|
-
from aru.runtime import get_ctx
|
|
59
55
|
|
|
60
56
|
model_ref = agent_def.model or session.model_ref
|
|
61
57
|
tools = resolve_tools(agent_def.tools)
|
|
@@ -74,11 +70,5 @@ def create_custom_agent_instance(agent_def: CustomAgent, session: Session,
|
|
|
74
70
|
tools=tools,
|
|
75
71
|
instructions=instructions,
|
|
76
72
|
markdown=True,
|
|
77
|
-
compress_tool_results=True,
|
|
78
|
-
compression_manager=CompressionManager(
|
|
79
|
-
model=create_model(get_ctx().small_model_ref, max_tokens=1024),
|
|
80
|
-
compress_tool_results=True,
|
|
81
|
-
compress_tool_results_limit=25,
|
|
82
|
-
),
|
|
83
73
|
tool_call_limit=agent_def.max_turns or 20,
|
|
84
74
|
)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Monkey-patch Agno's model layer to reduce token consumption.
|
|
2
|
+
|
|
3
|
+
Two optimizations:
|
|
4
|
+
|
|
5
|
+
1. **Tool result pruning** (ALL providers): After each tool execution, old tool
|
|
6
|
+
results in the message list are truncated to a short summary. This prevents
|
|
7
|
+
O(n²) token growth where each API call re-sends all previous tool results.
|
|
8
|
+
|
|
9
|
+
2. **Cache breakpoints** (Anthropic only): Marks the last 2 messages with
|
|
10
|
+
cache_control for Anthropic's prompt caching.
|
|
11
|
+
|
|
12
|
+
These patches intercept Agno's internal loop so they work transparently
|
|
13
|
+
regardless of which provider is used.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
# Max chars to keep from old tool results
|
|
19
|
+
_TOOL_RESULT_KEEP_CHARS = 200
|
|
20
|
+
# Number of recent tool results to keep in full
|
|
21
|
+
_KEEP_RECENT_RESULTS = 1
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _prune_tool_messages(messages):
|
|
25
|
+
"""Truncate old tool result content in the message list.
|
|
26
|
+
|
|
27
|
+
Keeps only the last N tool results in full. Older ones are truncated
|
|
28
|
+
to a short preview. This runs BEFORE each API call, so accumulated
|
|
29
|
+
tool results don't bloat the context on every re-send.
|
|
30
|
+
"""
|
|
31
|
+
# Find all tool message indices
|
|
32
|
+
tool_indices = [
|
|
33
|
+
i for i, msg in enumerate(messages)
|
|
34
|
+
if getattr(msg, "role", None) == "tool"
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
if len(tool_indices) <= _KEEP_RECENT_RESULTS:
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
# Prune all except the last N
|
|
41
|
+
for idx in tool_indices[:-_KEEP_RECENT_RESULTS]:
|
|
42
|
+
msg = messages[idx]
|
|
43
|
+
content = getattr(msg, "content", None)
|
|
44
|
+
if content is None:
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
content_str = str(content)
|
|
48
|
+
if len(content_str) <= _TOOL_RESULT_KEEP_CHARS:
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
truncated = content_str[:_TOOL_RESULT_KEEP_CHARS] + "\n[...truncated]"
|
|
52
|
+
try:
|
|
53
|
+
msg.content = truncated
|
|
54
|
+
if hasattr(msg, "compressed_content"):
|
|
55
|
+
msg.compressed_content = None
|
|
56
|
+
except (AttributeError, TypeError):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def apply_cache_patch():
|
|
61
|
+
"""Apply all patches to reduce Agno's token consumption."""
|
|
62
|
+
_patch_tool_result_pruning()
|
|
63
|
+
_patch_claude_cache_breakpoints()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _patch_tool_result_pruning():
|
|
67
|
+
"""Patch format_function_call_results to prune old tool results.
|
|
68
|
+
|
|
69
|
+
This is called after each tool execution, right before the next API call.
|
|
70
|
+
Works for ALL providers (Claude, OpenAI, Qwen, etc.) since it patches
|
|
71
|
+
the base Model class.
|
|
72
|
+
"""
|
|
73
|
+
from agno.models.base import Model
|
|
74
|
+
|
|
75
|
+
_original_format_results = Model.format_function_call_results
|
|
76
|
+
|
|
77
|
+
def _patched_format_results(self, messages, function_call_results, **kwargs):
|
|
78
|
+
# First: prune old tool results already in messages
|
|
79
|
+
_prune_tool_messages(messages)
|
|
80
|
+
# Then: add new results normally
|
|
81
|
+
return _original_format_results(self, messages, function_call_results, **kwargs)
|
|
82
|
+
|
|
83
|
+
Model.format_function_call_results = _patched_format_results
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _patch_claude_cache_breakpoints():
|
|
87
|
+
"""Patch Claude's format_messages to add cache breakpoints.
|
|
88
|
+
|
|
89
|
+
Marks the last 2 messages with cache_control for Anthropic's prompt
|
|
90
|
+
caching. Non-Anthropic providers ignore these fields.
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
import agno.utils.models.claude as claude_utils
|
|
94
|
+
except ImportError:
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
_original_format = claude_utils.format_messages
|
|
98
|
+
|
|
99
|
+
def _patched_format_messages(messages, compress_tool_results=False):
|
|
100
|
+
chat_messages, system_message = _original_format(
|
|
101
|
+
messages, compress_tool_results=compress_tool_results
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if not chat_messages:
|
|
105
|
+
return chat_messages, system_message
|
|
106
|
+
|
|
107
|
+
# Add cache_control to last 2 messages
|
|
108
|
+
cache_marker = {"type": "ephemeral"}
|
|
109
|
+
marked = 0
|
|
110
|
+
for msg in reversed(chat_messages):
|
|
111
|
+
if marked >= 2:
|
|
112
|
+
break
|
|
113
|
+
content = msg.get("content")
|
|
114
|
+
if isinstance(content, list) and content:
|
|
115
|
+
last_item = content[-1]
|
|
116
|
+
if isinstance(last_item, dict):
|
|
117
|
+
last_item["cache_control"] = cache_marker
|
|
118
|
+
marked += 1
|
|
119
|
+
elif hasattr(last_item, "type"):
|
|
120
|
+
try:
|
|
121
|
+
as_dict = last_item.model_dump() if hasattr(last_item, "model_dump") else dict(last_item)
|
|
122
|
+
as_dict["cache_control"] = cache_marker
|
|
123
|
+
content[-1] = as_dict
|
|
124
|
+
marked += 1
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
elif isinstance(content, str):
|
|
128
|
+
msg["content"] = [{"type": "text", "text": content, "cache_control": cache_marker}]
|
|
129
|
+
marked += 1
|
|
130
|
+
|
|
131
|
+
return chat_messages, system_message
|
|
132
|
+
|
|
133
|
+
claude_utils.format_messages = _patched_format_messages
|
|
@@ -50,6 +50,7 @@ from aru.display import ( # noqa: F401
|
|
|
50
50
|
from aru.completers import ( # noqa: F401
|
|
51
51
|
AruCompleter,
|
|
52
52
|
FileMentionCompleter,
|
|
53
|
+
MentionResult,
|
|
53
54
|
PasteState,
|
|
54
55
|
SlashCommandCompleter,
|
|
55
56
|
TIPS,
|
|
@@ -110,6 +111,11 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
110
111
|
from aru.permissions import parse_permission_config, reset_session as perm_reset_session
|
|
111
112
|
from aru.tools.codebase import cleanup_processes
|
|
112
113
|
|
|
114
|
+
# Inject cache breakpoints into Agno's Claude API calls — reduces token
|
|
115
|
+
# consumption by ~40% on multi-tool-call interactions via prompt caching.
|
|
116
|
+
from aru.cache_patch import apply_cache_patch
|
|
117
|
+
apply_cache_patch()
|
|
118
|
+
|
|
113
119
|
ctx = init_ctx(console=console, skip_permissions=skip_permissions)
|
|
114
120
|
|
|
115
121
|
store = SessionStore()
|
|
@@ -253,16 +259,19 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
253
259
|
|
|
254
260
|
# Resolve @file mentions (skip known agent names)
|
|
255
261
|
_agent_names = set(config.custom_agents.keys()) if config.custom_agents else set()
|
|
256
|
-
|
|
257
|
-
|
|
262
|
+
mention_result = _resolve_mentions(user_input, os.getcwd(), _agent_names)
|
|
263
|
+
attached_images = mention_result.images
|
|
264
|
+
# File contents go into history as separate prunable messages (not inline)
|
|
265
|
+
mention_file_msgs = mention_result.file_messages
|
|
266
|
+
if mention_result.count > 0:
|
|
258
267
|
parts = []
|
|
259
|
-
text_count =
|
|
268
|
+
text_count = mention_result.count - len(attached_images)
|
|
260
269
|
if text_count > 0:
|
|
261
270
|
parts.append(f"{text_count} file(s)")
|
|
262
271
|
if attached_images:
|
|
263
272
|
parts.append(f"{len(attached_images)} image(s)")
|
|
264
273
|
console.print(f"[dim]Attached {', '.join(parts)} from @ mentions[/dim]")
|
|
265
|
-
user_input =
|
|
274
|
+
user_input = mention_result.text
|
|
266
275
|
|
|
267
276
|
if paste_state.pasted_content and user_text:
|
|
268
277
|
console.print(
|
|
@@ -276,6 +285,14 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
276
285
|
if not user_input:
|
|
277
286
|
continue
|
|
278
287
|
|
|
288
|
+
# Inject @file contents as prunable history entries BEFORE the user message.
|
|
289
|
+
# These look like simulated read_file tool calls and can be pruned/compacted
|
|
290
|
+
# normally, unlike inline content which bloats the user message permanently.
|
|
291
|
+
if mention_file_msgs:
|
|
292
|
+
for msg in mention_file_msgs:
|
|
293
|
+
session.add_message(msg["role"], msg["content"])
|
|
294
|
+
mention_file_msgs = [] # consumed
|
|
295
|
+
|
|
279
296
|
# Reset "allow all" approvals for each new user message
|
|
280
297
|
perm_reset_session()
|
|
281
298
|
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
6
|
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
7
8
|
|
|
8
9
|
from prompt_toolkit import PromptSession
|
|
9
10
|
from prompt_toolkit.completion import Completer, Completion
|
|
@@ -18,24 +19,36 @@ from aru.commands import SLASH_COMMANDS
|
|
|
18
19
|
from aru.config import AgentConfig
|
|
19
20
|
|
|
20
21
|
_MENTION_RE = re.compile(r'(?<!\S)@([a-zA-Z0-9_./\\:-]+)')
|
|
21
|
-
_MENTION_MAX_SIZE =
|
|
22
|
+
_MENTION_MAX_SIZE = 10_000 # bytes — smaller to protect context (model uses read_file for large files)
|
|
22
23
|
_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
|
|
23
24
|
_IMAGE_MAX_SIZE = 20 * 1024 * 1024 # 20MB
|
|
24
25
|
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
@dataclass
|
|
28
|
+
class MentionResult:
|
|
29
|
+
"""Result of resolving @file mentions."""
|
|
30
|
+
text: str # User text (without file contents)
|
|
31
|
+
file_messages: list[dict[str, str]] # Simulated tool-call pairs for history
|
|
32
|
+
images: list[Image]
|
|
33
|
+
count: int # Total attached (files + images)
|
|
28
34
|
|
|
29
|
-
|
|
35
|
+
|
|
36
|
+
def _resolve_mentions(text: str, cwd: str, agent_names: set[str] | None = None) -> MentionResult:
|
|
37
|
+
"""Resolve @file mentions as simulated read_file tool calls.
|
|
38
|
+
|
|
39
|
+
Instead of inlining file contents into the user message (which bloats
|
|
40
|
+
history and can't be pruned), we return separate assistant+tool_result
|
|
41
|
+
message pairs that the session can prune/compact like normal tool outputs.
|
|
42
|
+
|
|
43
|
+
Image files are returned as Image objects.
|
|
30
44
|
Skips @mentions that match known agent names.
|
|
31
|
-
Returns (resolved_text, number_of_files_attached, images).
|
|
32
45
|
"""
|
|
33
46
|
agent_names = agent_names or set()
|
|
34
47
|
matches = list(_MENTION_RE.finditer(text))
|
|
35
48
|
if not matches:
|
|
36
|
-
return text,
|
|
49
|
+
return MentionResult(text=text, file_messages=[], images=[], count=0)
|
|
37
50
|
|
|
38
|
-
|
|
51
|
+
file_messages: list[dict[str, str]] = []
|
|
39
52
|
images: list[Image] = []
|
|
40
53
|
seen = set()
|
|
41
54
|
for m in matches:
|
|
@@ -64,21 +77,18 @@ def _resolve_mentions(text: str, cwd: str, agent_names: set[str] | None = None)
|
|
|
64
77
|
size = os.path.getsize(abs_path)
|
|
65
78
|
with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
|
|
66
79
|
content = f.read(_MENTION_MAX_SIZE)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
)
|
|
80
|
+
truncated = size > _MENTION_MAX_SIZE
|
|
81
|
+
label = f"[read_file: {rel_path}]"
|
|
82
|
+
if truncated:
|
|
83
|
+
label += f" (truncated to {_MENTION_MAX_SIZE // 1000}KB of {size // 1000}KB — use read_file for the rest)"
|
|
84
|
+
# Simulated tool call pair — can be pruned like normal tool outputs
|
|
85
|
+
file_messages.append({"role": "assistant", "content": label})
|
|
86
|
+
file_messages.append({"role": "user", "content": content})
|
|
75
87
|
except OSError:
|
|
76
88
|
continue
|
|
77
89
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return text + "".join(appendix_parts), attached, images
|
|
81
|
-
return text, attached, images
|
|
90
|
+
count = len(file_messages) // 2 + len(images)
|
|
91
|
+
return MentionResult(text=text, file_messages=file_messages, images=images, count=count)
|
|
82
92
|
|
|
83
93
|
|
|
84
94
|
def _extract_agent_mention(
|
|
@@ -11,15 +11,15 @@ 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 = 8_000 # ~2K tokens (was 12K — prune sooner)
|
|
15
15
|
# Placeholder that replaces evicted content
|
|
16
|
-
PRUNED_PLACEHOLDER = "[
|
|
16
|
+
PRUNED_PLACEHOLDER = "[cleared]"
|
|
17
17
|
# User messages larger than this threshold are truncated when outside protection window
|
|
18
|
-
PRUNE_USER_MSG_THRESHOLD =
|
|
18
|
+
PRUNE_USER_MSG_THRESHOLD = 1_200 # ~340 tokens (was 2K — catch file contents earlier)
|
|
19
19
|
# How many chars to keep from the start of a pruned user message
|
|
20
|
-
PRUNE_USER_MSG_KEEP =
|
|
20
|
+
PRUNE_USER_MSG_KEEP = 300 # ~85 tokens (was 500 — enough for the request intent)
|
|
21
21
|
# Minimum number of recent user turns always protected (regardless of char budget)
|
|
22
|
-
PRUNE_PROTECT_TURNS = 2
|
|
22
|
+
PRUNE_PROTECT_TURNS = 1 # was 2 — only protect the very last turn
|
|
23
23
|
# Tool result markers that should never be pruned (critical context)
|
|
24
24
|
PRUNE_PROTECTED_MARKERS = {"[SubAgent-", "delegate_task"}
|
|
25
25
|
# Tool names whose outputs should never be pruned (like OpenCode's PRUNE_PROTECTED_TOOLS)
|
|
@@ -27,16 +27,20 @@ PRUNE_PROTECTED_MARKERS = {"[SubAgent-", "delegate_task"}
|
|
|
27
27
|
PRUNE_PROTECTED_TOOLS = {"delegate_task"}
|
|
28
28
|
|
|
29
29
|
# Truncation: universal limits for any tool output
|
|
30
|
-
TRUNCATE_MAX_LINES = 300
|
|
31
|
-
TRUNCATE_MAX_BYTES =
|
|
32
|
-
TRUNCATE_KEEP_START =
|
|
33
|
-
TRUNCATE_KEEP_END =
|
|
34
|
-
TRUNCATE_MAX_LINE_LENGTH =
|
|
30
|
+
TRUNCATE_MAX_LINES = 200 # was 300 — tighter to save context
|
|
31
|
+
TRUNCATE_MAX_BYTES = 10 * 1024 # 10 KB (was 15KB — save full to disk instead)
|
|
32
|
+
TRUNCATE_KEEP_START = 150 # lines to keep from the start
|
|
33
|
+
TRUNCATE_KEEP_END = 30 # lines to keep from the end (was 60)
|
|
34
|
+
TRUNCATE_MAX_LINE_LENGTH = 1500 # chars per individual line (prevents minified files)
|
|
35
|
+
# Directory for saving full truncated outputs (like OpenCode pattern)
|
|
36
|
+
TRUNCATE_SAVE_DIR = ".aru/truncated"
|
|
35
37
|
|
|
36
38
|
# Compaction: trigger when per-run input tokens exceed this fraction of model limit
|
|
37
|
-
COMPACTION_THRESHOLD_RATIO = 0.
|
|
39
|
+
COMPACTION_THRESHOLD_RATIO = 0.50 # was 0.70 — compact much earlier to stay lean
|
|
38
40
|
# Compaction: target post-compaction size as fraction of model context limit
|
|
39
|
-
COMPACTION_TARGET_RATIO = 0.15
|
|
41
|
+
COMPACTION_TARGET_RATIO = 0.10 # was 0.15 — more aggressive compaction target
|
|
42
|
+
# Compaction: also trigger after this many user turns (regardless of token count)
|
|
43
|
+
COMPACTION_MAX_TURNS = 8
|
|
40
44
|
# Compaction: reserve buffer for the compaction process itself (like OpenCode's 20K)
|
|
41
45
|
COMPACTION_BUFFER_TOKENS = 20_000
|
|
42
46
|
# Default model context limits (input tokens)
|
|
@@ -111,10 +115,10 @@ def _get_prune_protect_chars(model_id: str = "default") -> int:
|
|
|
111
115
|
to prevent context overflow. Returns ~7% of the model's context in chars.
|
|
112
116
|
"""
|
|
113
117
|
limit = MODEL_CONTEXT_LIMITS.get(model_id, MODEL_CONTEXT_LIMITS["default"])
|
|
114
|
-
# ~4 chars per token, protect ~
|
|
115
|
-
protect = int(limit * 0.
|
|
116
|
-
# Clamp between
|
|
117
|
-
return max(
|
|
118
|
+
# ~4 chars per token, protect ~5% of context (was 7% — tighter budget)
|
|
119
|
+
protect = int(limit * 0.05 * 4)
|
|
120
|
+
# Clamp between 10K (minimum usable) and 40K (diminishing returns)
|
|
121
|
+
return max(10_000, min(protect, 40_000))
|
|
118
122
|
|
|
119
123
|
|
|
120
124
|
def prune_history(
|
|
@@ -214,42 +218,50 @@ def _truncate_long_lines(lines: list[str]) -> list[str]:
|
|
|
214
218
|
return result
|
|
215
219
|
|
|
216
220
|
|
|
221
|
+
def _save_truncated_output(text: str) -> str | None:
|
|
222
|
+
"""Save full truncated output to disk and return the file path.
|
|
223
|
+
|
|
224
|
+
Returns None if saving fails (non-fatal — hint will omit path).
|
|
225
|
+
"""
|
|
226
|
+
import os
|
|
227
|
+
import time
|
|
228
|
+
|
|
229
|
+
save_dir = os.path.join(os.getcwd(), TRUNCATE_SAVE_DIR)
|
|
230
|
+
try:
|
|
231
|
+
os.makedirs(save_dir, exist_ok=True)
|
|
232
|
+
filename = f"output_{int(time.time() * 1000)}.txt"
|
|
233
|
+
filepath = os.path.join(save_dir, filename)
|
|
234
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
235
|
+
f.write(text)
|
|
236
|
+
return filepath
|
|
237
|
+
except OSError:
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
217
241
|
def _build_truncation_hint(
|
|
218
242
|
source_file: str = "",
|
|
219
243
|
source_tool: str = "",
|
|
220
244
|
lines_shown: int = 0,
|
|
245
|
+
saved_path: str | None = None,
|
|
221
246
|
) -> str:
|
|
222
|
-
"""Build a context-aware truncation hint
|
|
247
|
+
"""Build a context-aware truncation hint.
|
|
223
248
|
|
|
224
|
-
When
|
|
225
|
-
the
|
|
226
|
-
Always suggests delegate_task for large exploration work.
|
|
249
|
+
When output was saved to disk, points to the saved file.
|
|
250
|
+
When the source file is known, provides a direct read_file reference.
|
|
227
251
|
"""
|
|
228
|
-
parts = ["
|
|
252
|
+
parts = ["[Truncated."]
|
|
229
253
|
|
|
230
|
-
if
|
|
231
|
-
|
|
254
|
+
if saved_path:
|
|
255
|
+
parts.append(f" Full output saved to: {saved_path}")
|
|
256
|
+
parts.append(" Use grep_search or read_file with start_line/end_line to inspect.")
|
|
257
|
+
elif source_file:
|
|
232
258
|
next_line = lines_shown + 1 if lines_shown else 1
|
|
233
|
-
parts.append(
|
|
234
|
-
f' To see more: read_file("{source_file}", start_line={next_line}).'
|
|
235
|
-
f" Use grep_search to find specific content instead of reading everything."
|
|
236
|
-
)
|
|
237
|
-
elif source_tool == "bash":
|
|
238
|
-
parts.append(
|
|
239
|
-
" Use grep_search to find specific content in project files."
|
|
240
|
-
" Do NOT re-run the command to get full output."
|
|
241
|
-
)
|
|
259
|
+
parts.append(f' read_file("{source_file}", start_line={next_line}) for more.')
|
|
242
260
|
else:
|
|
243
|
-
parts.append(
|
|
244
|
-
" Use grep_search to find specific content, or read_file with"
|
|
245
|
-
" start_line/end_line for incremental reading."
|
|
246
|
-
)
|
|
261
|
+
parts.append(" Use grep_search to find specific content.")
|
|
247
262
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
" For large exploration tasks, use delegate_task to keep your context clean.]"
|
|
251
|
-
)
|
|
252
|
-
return "".join(parts)
|
|
263
|
+
parts.append("]")
|
|
264
|
+
return " ".join(parts)
|
|
253
265
|
|
|
254
266
|
|
|
255
267
|
def truncate_output(
|
|
@@ -282,17 +294,18 @@ def truncate_output(
|
|
|
282
294
|
if byte_len <= TRUNCATE_MAX_BYTES and line_count <= TRUNCATE_MAX_LINES:
|
|
283
295
|
return "".join(lines)
|
|
284
296
|
|
|
297
|
+
# Save full output to disk before truncating (like OpenCode)
|
|
298
|
+
saved_path = _save_truncated_output(text)
|
|
299
|
+
|
|
285
300
|
# Truncate by lines
|
|
286
301
|
if line_count > TRUNCATE_MAX_LINES:
|
|
287
302
|
head = lines[:TRUNCATE_KEEP_START]
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
hint = _build_truncation_hint(source_file, source_tool, TRUNCATE_KEEP_START)
|
|
303
|
+
omitted = line_count - TRUNCATE_KEEP_START
|
|
304
|
+
hint = _build_truncation_hint(source_file, source_tool, TRUNCATE_KEEP_START, saved_path)
|
|
291
305
|
return (
|
|
292
306
|
"".join(head)
|
|
293
|
-
+ f"\n\n[... {omitted:,} lines omitted ({line_count:,} total)]"
|
|
294
|
-
+ hint + "\n
|
|
295
|
-
+ "".join(tail)
|
|
307
|
+
+ f"\n\n[... {omitted:,} lines omitted ({line_count:,} total)]\n"
|
|
308
|
+
+ hint + "\n"
|
|
296
309
|
)
|
|
297
310
|
|
|
298
311
|
# Truncate by bytes (lines fit but total bytes too large)
|
|
@@ -306,11 +319,11 @@ def truncate_output(
|
|
|
306
319
|
total += line_bytes
|
|
307
320
|
|
|
308
321
|
remaining = line_count - len(kept_lines)
|
|
309
|
-
hint = _build_truncation_hint(source_file, source_tool, len(kept_lines))
|
|
322
|
+
hint = _build_truncation_hint(source_file, source_tool, len(kept_lines), saved_path)
|
|
310
323
|
return (
|
|
311
324
|
"".join(kept_lines)
|
|
312
325
|
+ f"\n\n[... truncated at ~{TRUNCATE_MAX_BYTES // 1024}KB — "
|
|
313
|
-
f"{remaining:,} more lines]"
|
|
326
|
+
f"{remaining:,} more lines]\n"
|
|
314
327
|
+ hint + "\n"
|
|
315
328
|
)
|
|
316
329
|
|
|
@@ -329,16 +342,22 @@ def should_compact(
|
|
|
329
342
|
) -> bool:
|
|
330
343
|
"""Check if the conversation should be compacted.
|
|
331
344
|
|
|
332
|
-
|
|
333
|
-
|
|
345
|
+
Triggers on EITHER condition:
|
|
346
|
+
1. Token-based: tokens >= usable_context * threshold_ratio
|
|
347
|
+
2. Turn-based: user turns >= COMPACTION_MAX_TURNS (prevents slow token creep)
|
|
334
348
|
|
|
335
|
-
Accepts either an estimated token count (int) or the history list
|
|
336
|
-
(from which tokens are estimated via char count).
|
|
349
|
+
Accepts either an estimated token count (int) or the history list.
|
|
337
350
|
"""
|
|
338
351
|
if isinstance(history_or_tokens, list):
|
|
339
|
-
|
|
352
|
+
history = history_or_tokens
|
|
353
|
+
tokens = estimate_history_tokens(history)
|
|
354
|
+
# Turn-based trigger: count user messages
|
|
355
|
+
user_turns = sum(1 for m in history if m["role"] == "user")
|
|
356
|
+
if user_turns >= COMPACTION_MAX_TURNS:
|
|
357
|
+
return True
|
|
340
358
|
else:
|
|
341
359
|
tokens = history_or_tokens
|
|
360
|
+
|
|
342
361
|
limit = MODEL_CONTEXT_LIMITS.get(model_id, MODEL_CONTEXT_LIMITS["default"])
|
|
343
362
|
usable = limit - COMPACTION_BUFFER_TOKENS
|
|
344
363
|
threshold = int(usable * COMPACTION_THRESHOLD_RATIO)
|
|
@@ -145,6 +145,8 @@ class Session:
|
|
|
145
145
|
self._cached_tree: str | None = None
|
|
146
146
|
self._cached_git_status: str | None = None
|
|
147
147
|
self._context_dirty: bool = True
|
|
148
|
+
# Track whether AGENTS.md/extra instructions were already sent (skip on subsequent turns)
|
|
149
|
+
self.extra_instructions_sent: bool = False
|
|
148
150
|
# Tree depth for env context (configurable via aru.json "tree_depth")
|
|
149
151
|
self._tree_max_depth: int = 2
|
|
150
152
|
# Token budget (0 = unlimited)
|
|
@@ -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 (~7K tokens). Even max_size=0 respects this per chunk.
|
|
58
|
+
_READ_HARD_CAP = 25_000 # bytes (was 40K — each tool result re-sent on next API call)
|
|
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 = 8_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 8KB.
|
|
73
|
+
Set to 0 to read the full file in chunks — each chunk up to ~25KB.
|
|
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
|
"""
|
|
@@ -505,15 +505,15 @@ def glob_search(pattern: str, directory: str = ".") -> str:
|
|
|
505
505
|
return "\n".join(matches)
|
|
506
506
|
|
|
507
507
|
|
|
508
|
-
def grep_search(pattern: str, directory: str = ".", file_glob: str = "", context_lines: int =
|
|
508
|
+
def grep_search(pattern: str, directory: str = ".", file_glob: str = "", context_lines: int = 5) -> str:
|
|
509
509
|
"""Search for a regex pattern in file contents.
|
|
510
510
|
|
|
511
511
|
Args:
|
|
512
512
|
pattern: Regular expression pattern to search for.
|
|
513
513
|
directory: Directory to search in. Defaults to current directory.
|
|
514
514
|
file_glob: Optional glob to filter which files to search (e.g. '*.py').
|
|
515
|
-
context_lines: Lines of context before and after each match (like grep -C). Default
|
|
516
|
-
Use 0 for file-level matches only. Use
|
|
515
|
+
context_lines: Lines of context before and after each match (like grep -C). Default 5.
|
|
516
|
+
Use 0 for file-level matches only. Use 20+ for full function bodies.
|
|
517
517
|
"""
|
|
518
518
|
import re
|
|
519
519
|
|
|
@@ -1158,7 +1158,7 @@ EXECUTOR_TOOLS = [
|
|
|
1158
1158
|
delegate_task,
|
|
1159
1159
|
]
|
|
1160
1160
|
|
|
1161
|
-
# General-purpose tools
|
|
1161
|
+
# General-purpose tools (full set — used as fallback)
|
|
1162
1162
|
GENERAL_TOOLS = [
|
|
1163
1163
|
read_file,
|
|
1164
1164
|
read_file_smart,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aru-code
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.16.0
|
|
4
4
|
Summary: A Claude Code clone built with Agno agents
|
|
5
5
|
Author-email: Estevao <estevaofon@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -56,7 +56,7 @@ An intelligent coding assistant for the terminal, powered by LLMs and [Agno](htt
|
|
|
56
56
|
- **Multi-Agent Architecture** — Specialized agents for planning, execution, and conversation
|
|
57
57
|
- **Interactive CLI** — Streaming responses, multi-line paste, session management
|
|
58
58
|
- **Image Support** — Attach images via `@` mentions for multimodal analysis (Claude, GPT-4o, Gemini)
|
|
59
|
-
- **
|
|
59
|
+
- **11 Integrated Tools** — File operations, code search, shell, web search, task delegation
|
|
60
60
|
- **Task Planning** — Break down complex tasks into steps with automatic execution
|
|
61
61
|
- **Multi-Provider** — Anthropic, OpenAI, Ollama, Groq, OpenRouter, DeepSeek, and others via custom configuration
|
|
62
62
|
- **Custom Commands, Skills, and Agents** — Extend aru via the `.agents/` directory
|
|
@@ -479,15 +479,14 @@ Aru can load tools from MCP servers. Configure in `.aru/mcp_config.json`:
|
|
|
479
479
|
|
|
480
480
|
### File Operations
|
|
481
481
|
- `read_file` — Reads files with line range support and binary detection
|
|
482
|
-
- `read_file_smart` —
|
|
483
|
-
- `write_file` — Writes files
|
|
484
|
-
- `edit_file` — Find-replace edits
|
|
482
|
+
- `read_file_smart` — Answers specific questions about a file without returning raw content
|
|
483
|
+
- `write_file` — Writes content to files, creating directories as needed
|
|
484
|
+
- `edit_file` — Find-and-replace edits on files
|
|
485
485
|
|
|
486
486
|
### Search & Discovery
|
|
487
487
|
- `glob_search` — Find files by pattern (respects .gitignore)
|
|
488
488
|
- `grep_search` — Content search with regex and file filtering
|
|
489
489
|
- `list_directory` — Directory listing with gitignore filtering
|
|
490
|
-
- `rank_files` — Multi-factor file relevance ranking (name, structure, recency)
|
|
491
490
|
|
|
492
491
|
### Shell & Web
|
|
493
492
|
- `bash` — Executes shell commands with permission gates
|
|
@@ -517,7 +516,7 @@ aru-code/
|
|
|
517
516
|
│ │ ├── planner.py # Planning agent
|
|
518
517
|
│ │ └── executor.py # Execution agent
|
|
519
518
|
│ └── tools/
|
|
520
|
-
│ ├── codebase.py #
|
|
519
|
+
│ ├── codebase.py # 11 core tools
|
|
521
520
|
│ ├── ast_tools.py # Tree-sitter code analysis
|
|
522
521
|
│ ├── ranker.py # File relevance ranking
|
|
523
522
|
│ ├── mcp_client.py # MCP client
|
|
@@ -46,35 +46,40 @@ class TestSanitizeInput:
|
|
|
46
46
|
|
|
47
47
|
class TestResolveMentions:
|
|
48
48
|
def test_no_mentions(self, tmp_path):
|
|
49
|
-
|
|
50
|
-
assert
|
|
51
|
-
assert count == 0
|
|
49
|
+
mr = _resolve_mentions("hello world", str(tmp_path))
|
|
50
|
+
assert mr.text == "hello world"
|
|
51
|
+
assert mr.count == 0
|
|
52
|
+
assert mr.file_messages == []
|
|
52
53
|
|
|
53
54
|
def test_resolves_file_mention(self, tmp_path):
|
|
54
55
|
(tmp_path / "config.py").write_text("DEBUG = True")
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
assert
|
|
58
|
-
assert
|
|
56
|
+
mr = _resolve_mentions("check @config.py", str(tmp_path))
|
|
57
|
+
# File content now goes into file_messages, not inline text
|
|
58
|
+
assert mr.count == 1
|
|
59
|
+
assert len(mr.file_messages) == 2 # assistant label + user content
|
|
60
|
+
assert "read_file: config.py" in mr.file_messages[0]["content"]
|
|
61
|
+
assert "DEBUG = True" in mr.file_messages[1]["content"]
|
|
59
62
|
|
|
60
63
|
def test_nonexistent_file_ignored(self, tmp_path):
|
|
61
|
-
|
|
62
|
-
assert
|
|
63
|
-
assert count == 0
|
|
64
|
+
mr = _resolve_mentions("check @missing.py", str(tmp_path))
|
|
65
|
+
assert mr.text == "check @missing.py"
|
|
66
|
+
assert mr.count == 0
|
|
64
67
|
|
|
65
68
|
def test_deduplicates_mentions(self, tmp_path):
|
|
66
69
|
(tmp_path / "file.py").write_text("code")
|
|
67
|
-
|
|
68
|
-
assert
|
|
69
|
-
assert
|
|
70
|
+
mr = _resolve_mentions("@file.py and @file.py", str(tmp_path))
|
|
71
|
+
assert mr.count == 1
|
|
72
|
+
assert len(mr.file_messages) == 2 # one pair
|
|
70
73
|
|
|
71
74
|
def test_multiple_files(self, tmp_path):
|
|
72
75
|
(tmp_path / "a.py").write_text("aaa")
|
|
73
76
|
(tmp_path / "b.py").write_text("bbb")
|
|
74
|
-
|
|
75
|
-
assert
|
|
76
|
-
assert
|
|
77
|
-
|
|
77
|
+
mr = _resolve_mentions("@a.py and @b.py", str(tmp_path))
|
|
78
|
+
assert mr.count == 2
|
|
79
|
+
assert len(mr.file_messages) == 4 # two pairs
|
|
80
|
+
all_content = " ".join(m["content"] for m in mr.file_messages)
|
|
81
|
+
assert "aaa" in all_content
|
|
82
|
+
assert "bbb" in all_content
|
|
78
83
|
|
|
79
84
|
def test_mention_regex_pattern(self):
|
|
80
85
|
matches = _MENTION_RE.findall("check @file.py now")
|
|
@@ -606,61 +606,53 @@ class TestExtractAgentMention:
|
|
|
606
606
|
class TestImageMentions:
|
|
607
607
|
"""Tests for image file detection in @mentions."""
|
|
608
608
|
|
|
609
|
-
def
|
|
610
|
-
|
|
611
|
-
assert
|
|
612
|
-
|
|
613
|
-
assert
|
|
614
|
-
assert
|
|
615
|
-
assert images == []
|
|
609
|
+
def test_resolve_mentions_returns_mention_result(self, tmp_path):
|
|
610
|
+
mr = _resolve_mentions("hello", str(tmp_path))
|
|
611
|
+
assert mr.text == "hello"
|
|
612
|
+
assert mr.count == 0
|
|
613
|
+
assert mr.images == []
|
|
614
|
+
assert mr.file_messages == []
|
|
616
615
|
|
|
617
616
|
def test_resolve_mentions_image_file(self, tmp_path):
|
|
618
617
|
img = tmp_path / "screenshot.png"
|
|
619
618
|
img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
|
|
620
619
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
)
|
|
624
|
-
assert
|
|
625
|
-
assert
|
|
626
|
-
|
|
627
|
-
assert
|
|
628
|
-
# Image content should NOT be appended as text
|
|
629
|
-
assert "```" not in text
|
|
620
|
+
mr = _resolve_mentions("analyze @screenshot.png", str(tmp_path))
|
|
621
|
+
assert mr.count == 1
|
|
622
|
+
assert len(mr.images) == 1
|
|
623
|
+
assert isinstance(mr.images[0], Image)
|
|
624
|
+
assert mr.images[0].id == "screenshot.png"
|
|
625
|
+
# Image content should NOT be in file_messages
|
|
626
|
+
assert len(mr.file_messages) == 0
|
|
630
627
|
|
|
631
628
|
def test_resolve_mentions_mixed_files_and_images(self, tmp_path):
|
|
632
629
|
(tmp_path / "code.py").write_text("print('hello')", encoding="utf-8")
|
|
633
630
|
(tmp_path / "diagram.jpg").write_bytes(b"\xff\xd8\xff" + b"\x00" * 100)
|
|
634
631
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
)
|
|
638
|
-
assert
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
assert "print('hello')" in text
|
|
632
|
+
mr = _resolve_mentions("review @code.py and @diagram.jpg", str(tmp_path))
|
|
633
|
+
assert mr.count == 2
|
|
634
|
+
assert len(mr.images) == 1
|
|
635
|
+
assert mr.images[0].id == "diagram.jpg"
|
|
636
|
+
# Text file content goes into file_messages
|
|
637
|
+
all_content = " ".join(m["content"] for m in mr.file_messages)
|
|
638
|
+
assert "print('hello')" in all_content
|
|
643
639
|
|
|
644
640
|
def test_resolve_mentions_multiple_images(self, tmp_path):
|
|
645
641
|
(tmp_path / "a.png").write_bytes(b"\x89PNG" + b"\x00" * 100)
|
|
646
642
|
(tmp_path / "b.webp").write_bytes(b"RIFF" + b"\x00" * 100)
|
|
647
643
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
)
|
|
651
|
-
assert count == 2
|
|
652
|
-
assert len(images) == 2
|
|
644
|
+
mr = _resolve_mentions("compare @a.png @b.webp", str(tmp_path))
|
|
645
|
+
assert mr.count == 2
|
|
646
|
+
assert len(mr.images) == 2
|
|
653
647
|
|
|
654
648
|
def test_resolve_mentions_image_too_large(self, tmp_path):
|
|
655
649
|
img = tmp_path / "huge.png"
|
|
656
650
|
# Write just over the 20MB limit header
|
|
657
651
|
img.write_bytes(b"\x89PNG" + b"\x00" * (20 * 1024 * 1024 + 1))
|
|
658
652
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
)
|
|
662
|
-
assert count == 0
|
|
663
|
-
assert len(images) == 0
|
|
653
|
+
mr = _resolve_mentions("analyze @huge.png", str(tmp_path))
|
|
654
|
+
assert mr.count == 0
|
|
655
|
+
assert len(mr.images) == 0
|
|
664
656
|
|
|
665
657
|
def test_resolve_mentions_all_image_extensions(self, tmp_path):
|
|
666
658
|
for ext in _IMAGE_EXTENSIONS:
|
|
@@ -668,8 +660,8 @@ class TestImageMentions:
|
|
|
668
660
|
(tmp_path / fname).write_bytes(b"\x00" * 100)
|
|
669
661
|
|
|
670
662
|
mentions = " ".join(f"@test{ext}" for ext in _IMAGE_EXTENSIONS)
|
|
671
|
-
|
|
672
|
-
assert len(images) == len(_IMAGE_EXTENSIONS)
|
|
663
|
+
mr = _resolve_mentions(mentions, str(tmp_path))
|
|
664
|
+
assert len(mr.images) == len(_IMAGE_EXTENSIONS)
|
|
673
665
|
|
|
674
666
|
def test_image_completer_shows_image_metadata(self, tmp_path):
|
|
675
667
|
(tmp_path / "photo.png").touch()
|
aru_code-0.15.0/aru/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.15.0"
|
|
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
|