aru-code 0.15.0__tar.gz → 0.17.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.17.0}/PKG-INFO +6 -7
- {aru_code-0.15.0 → aru_code-0.17.0}/README.md +5 -6
- aru_code-0.17.0/aru/__init__.py +1 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/aru/agent_factory.py +2 -17
- {aru_code-0.15.0 → aru_code-0.17.0}/aru/agents/base.py +5 -4
- aru_code-0.17.0/aru/cache_patch.py +201 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/aru/cli.py +32 -5
- {aru_code-0.15.0 → aru_code-0.17.0}/aru/commands.py +1 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/aru/completers.py +29 -19
- {aru_code-0.15.0 → aru_code-0.17.0}/aru/context.py +68 -49
- {aru_code-0.15.0 → aru_code-0.17.0}/aru/runner.py +13 -18
- {aru_code-0.15.0 → aru_code-0.17.0}/aru/session.py +131 -6
- {aru_code-0.15.0 → aru_code-0.17.0}/aru/tools/codebase.py +9 -9
- {aru_code-0.15.0 → aru_code-0.17.0/aru_code.egg-info}/PKG-INFO +6 -7
- {aru_code-0.15.0 → aru_code-0.17.0}/aru_code.egg-info/SOURCES.txt +1 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/pyproject.toml +1 -1
- {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_cli.py +42 -21
- {aru_code-0.15.0 → aru_code-0.17.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.17.0}/LICENSE +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/aru/agents/__init__.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/aru/agents/executor.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/aru/agents/planner.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/aru/config.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/aru/display.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/aru/permissions.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/aru/providers.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/aru/runtime.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/aru/tools/__init__.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/aru/tools/gitignore.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/aru/tools/ranker.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/aru/tools/tasklist.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/setup.cfg +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_agents_base.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_cli_advanced.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_cli_base.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_cli_new.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_cli_run_cli.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_cli_session.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_cli_shell.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_codebase.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_config.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_context.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_executor.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_gitignore.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_main.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_mcp_client.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_permissions.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_planner.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.0}/tests/test_providers.py +0 -0
- {aru_code-0.15.0 → aru_code-0.17.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.17.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.17.0"
|
|
@@ -21,10 +21,9 @@ 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
|
extra = config.get_extra_instructions() if config else ""
|
|
30
29
|
if env_context:
|
|
@@ -34,15 +33,9 @@ def create_general_agent(
|
|
|
34
33
|
return Agent(
|
|
35
34
|
name="Aru",
|
|
36
35
|
model=create_model(model_ref, max_tokens=8192),
|
|
37
|
-
tools=
|
|
36
|
+
tools=tools,
|
|
38
37
|
instructions=_build_instructions("general", extra),
|
|
39
38
|
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
39
|
tool_call_limit=20,
|
|
47
40
|
)
|
|
48
41
|
|
|
@@ -52,10 +45,8 @@ def create_custom_agent_instance(agent_def: CustomAgent, session: Session,
|
|
|
52
45
|
env_context: str = ""):
|
|
53
46
|
"""Create an Agno Agent from a CustomAgent definition."""
|
|
54
47
|
from agno.agent import Agent
|
|
55
|
-
from agno.compression.manager import CompressionManager
|
|
56
48
|
from aru.agents.base import BASE_INSTRUCTIONS
|
|
57
49
|
from aru.tools.codebase import resolve_tools
|
|
58
|
-
from aru.runtime import get_ctx
|
|
59
50
|
|
|
60
51
|
model_ref = agent_def.model or session.model_ref
|
|
61
52
|
tools = resolve_tools(agent_def.tools)
|
|
@@ -74,11 +65,5 @@ def create_custom_agent_instance(agent_def: CustomAgent, session: Session,
|
|
|
74
65
|
tools=tools,
|
|
75
66
|
instructions=instructions,
|
|
76
67
|
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
68
|
tool_call_limit=agent_def.max_turns or 20,
|
|
84
69
|
)
|
|
@@ -48,12 +48,12 @@ Default shows 10 lines of context — use `context_lines=30` for full function b
|
|
|
48
48
|
2. **Understand a file** → `read_file_smart(path, query)` — returns a concise answer, not raw content
|
|
49
49
|
3. **Need raw content** → `read_file(path)` — returns first chunk + outline for large files
|
|
50
50
|
|
|
51
|
-
**Stop early**: Once you have enough information to write the plan, STOP making tool calls \
|
|
52
|
-
immediately. Do not exhaustively explore.
|
|
53
|
-
|
|
54
51
|
**Batch independent tool calls**: When you need answers from multiple independent sources, \
|
|
55
52
|
emit ALL those tool calls in a single response.
|
|
56
53
|
|
|
54
|
+
**Stop early**: Once you have enough information to write the plan, stop exploring and write it. \
|
|
55
|
+
Do not exhaustively read every file — batch what you need, then produce the plan.
|
|
56
|
+
|
|
57
57
|
## Output format — STRICT
|
|
58
58
|
|
|
59
59
|
Your ONLY output is the plan below. Do NOT write analysis, coverage reports, summaries of
|
|
@@ -182,7 +182,8 @@ Every tool call accumulates its result in your context window. Use the minimum n
|
|
|
182
182
|
|
|
183
183
|
**Batch independent tool calls**: emit ALL independent tool calls in a single response.
|
|
184
184
|
|
|
185
|
-
**Stop early**: Once you have enough information to
|
|
185
|
+
**Stop early**: Once you have enough information to act, stop exploring and start working. \
|
|
186
|
+
Batch what you need upfront, then execute.
|
|
186
187
|
|
|
187
188
|
**When adding or modifying unit tests, ALWAYS run them to verify they pass before finishing.**
|
|
188
189
|
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Monkey-patch Agno's model layer to reduce token consumption.
|
|
2
|
+
|
|
3
|
+
Three 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
|
+
3. **Per-call metrics** (ALL providers): Captures input/output tokens of the
|
|
13
|
+
last API call (context window size), exposed via get_last_call_metrics().
|
|
14
|
+
|
|
15
|
+
These patches intercept Agno's internal loop so they work transparently
|
|
16
|
+
regardless of which provider is used.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
# Token-budget pruning (aligned with OpenCode's strategy):
|
|
22
|
+
# - Protect recent tool results within a token budget
|
|
23
|
+
# - Only prune if there's enough to free (avoid churn)
|
|
24
|
+
# - Walk backwards, protecting recent content first
|
|
25
|
+
# OpenCode uses 40K protect / 20K minimum; we use chars (~4 chars/token)
|
|
26
|
+
_PRUNE_PROTECT_CHARS = 160_000 # ~40K tokens — recent content always kept
|
|
27
|
+
_PRUNE_MINIMUM_CHARS = 80_000 # ~20K tokens — only prune if this much is freeable
|
|
28
|
+
_PRUNED_PLACEHOLDER = "[Old tool result cleared]"
|
|
29
|
+
|
|
30
|
+
# Last API call metrics (updated on every internal API call)
|
|
31
|
+
_last_call_input_tokens: int = 0
|
|
32
|
+
_last_call_output_tokens: int = 0
|
|
33
|
+
_last_call_cache_read: int = 0
|
|
34
|
+
_last_call_cache_write: int = 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_last_call_metrics() -> tuple[int, int, int, int]:
|
|
38
|
+
"""Return (input, output, cache_read, cache_write) from the most recent API call."""
|
|
39
|
+
return _last_call_input_tokens, _last_call_output_tokens, _last_call_cache_read, _last_call_cache_write
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _prune_tool_messages(messages):
|
|
43
|
+
"""Clear old tool result content using a token-budget approach.
|
|
44
|
+
|
|
45
|
+
Walks backwards through messages, protecting recent content up to
|
|
46
|
+
PRUNE_PROTECT_CHARS. Older tool results beyond that budget are replaced
|
|
47
|
+
with a short placeholder. Only prunes if total freeable chars exceed
|
|
48
|
+
PRUNE_MINIMUM_CHARS (avoids unnecessary churn on small conversations).
|
|
49
|
+
|
|
50
|
+
Aligned with OpenCode's strategy: budget-based, not fixed-N.
|
|
51
|
+
"""
|
|
52
|
+
# Collect tool message indices and their content sizes
|
|
53
|
+
tool_indices = []
|
|
54
|
+
for i, msg in enumerate(messages):
|
|
55
|
+
if getattr(msg, "role", None) == "tool":
|
|
56
|
+
content = getattr(msg, "content", None)
|
|
57
|
+
content_len = len(str(content)) if content is not None else 0
|
|
58
|
+
tool_indices.append((i, content_len))
|
|
59
|
+
|
|
60
|
+
if not tool_indices:
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
# Walk backwards, accumulating protected chars
|
|
64
|
+
protected_chars = 0
|
|
65
|
+
prune_candidates = [] # (index, content_len) of messages outside protection
|
|
66
|
+
|
|
67
|
+
for idx, content_len in reversed(tool_indices):
|
|
68
|
+
if protected_chars + content_len <= _PRUNE_PROTECT_CHARS:
|
|
69
|
+
protected_chars += content_len
|
|
70
|
+
else:
|
|
71
|
+
prune_candidates.append((idx, content_len))
|
|
72
|
+
|
|
73
|
+
# Only prune if there's enough to free
|
|
74
|
+
freeable = sum(cl for _, cl in prune_candidates)
|
|
75
|
+
if freeable < _PRUNE_MINIMUM_CHARS:
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
# Replace old tool results with placeholder
|
|
79
|
+
for idx, _ in prune_candidates:
|
|
80
|
+
msg = messages[idx]
|
|
81
|
+
content = getattr(msg, "content", None)
|
|
82
|
+
if content is None:
|
|
83
|
+
continue
|
|
84
|
+
# Skip if already pruned
|
|
85
|
+
if str(content) == _PRUNED_PLACEHOLDER:
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
msg.content = _PRUNED_PLACEHOLDER
|
|
89
|
+
if hasattr(msg, "compressed_content"):
|
|
90
|
+
msg.compressed_content = None
|
|
91
|
+
except (AttributeError, TypeError):
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def apply_cache_patch():
|
|
96
|
+
"""Apply all patches to reduce Agno's token consumption."""
|
|
97
|
+
_patch_tool_result_pruning()
|
|
98
|
+
_patch_claude_cache_breakpoints()
|
|
99
|
+
_patch_per_call_metrics()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _patch_tool_result_pruning():
|
|
103
|
+
"""Patch format_function_call_results to prune old tool results.
|
|
104
|
+
|
|
105
|
+
This is called after each tool execution, right before the next API call.
|
|
106
|
+
Works for ALL providers (Claude, OpenAI, Qwen, etc.) since it patches
|
|
107
|
+
the base Model class.
|
|
108
|
+
"""
|
|
109
|
+
from agno.models.base import Model
|
|
110
|
+
|
|
111
|
+
_original_format_results = Model.format_function_call_results
|
|
112
|
+
|
|
113
|
+
def _patched_format_results(self, messages, function_call_results, **kwargs):
|
|
114
|
+
# First: prune old tool results already in messages
|
|
115
|
+
_prune_tool_messages(messages)
|
|
116
|
+
# Then: add new results normally
|
|
117
|
+
return _original_format_results(self, messages, function_call_results, **kwargs)
|
|
118
|
+
|
|
119
|
+
Model.format_function_call_results = _patched_format_results
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _patch_claude_cache_breakpoints():
|
|
123
|
+
"""Patch Claude's format_messages to add cache breakpoints.
|
|
124
|
+
|
|
125
|
+
Marks the last 2 messages with cache_control for Anthropic's prompt
|
|
126
|
+
caching. Non-Anthropic providers ignore these fields.
|
|
127
|
+
"""
|
|
128
|
+
try:
|
|
129
|
+
import agno.utils.models.claude as claude_utils
|
|
130
|
+
except ImportError:
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
_original_format = claude_utils.format_messages
|
|
134
|
+
|
|
135
|
+
def _patched_format_messages(messages, compress_tool_results=False):
|
|
136
|
+
chat_messages, system_message = _original_format(
|
|
137
|
+
messages, compress_tool_results=compress_tool_results
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if not chat_messages:
|
|
141
|
+
return chat_messages, system_message
|
|
142
|
+
|
|
143
|
+
# Add cache_control to last 2 messages
|
|
144
|
+
cache_marker = {"type": "ephemeral"}
|
|
145
|
+
marked = 0
|
|
146
|
+
for msg in reversed(chat_messages):
|
|
147
|
+
if marked >= 2:
|
|
148
|
+
break
|
|
149
|
+
content = msg.get("content")
|
|
150
|
+
if isinstance(content, list) and content:
|
|
151
|
+
last_item = content[-1]
|
|
152
|
+
if isinstance(last_item, dict):
|
|
153
|
+
last_item["cache_control"] = cache_marker
|
|
154
|
+
marked += 1
|
|
155
|
+
elif hasattr(last_item, "type"):
|
|
156
|
+
try:
|
|
157
|
+
as_dict = last_item.model_dump() if hasattr(last_item, "model_dump") else dict(last_item)
|
|
158
|
+
as_dict["cache_control"] = cache_marker
|
|
159
|
+
content[-1] = as_dict
|
|
160
|
+
marked += 1
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
elif isinstance(content, str):
|
|
164
|
+
msg["content"] = [{"type": "text", "text": content, "cache_control": cache_marker}]
|
|
165
|
+
marked += 1
|
|
166
|
+
|
|
167
|
+
return chat_messages, system_message
|
|
168
|
+
|
|
169
|
+
claude_utils.format_messages = _patched_format_messages
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _patch_per_call_metrics():
|
|
173
|
+
"""Patch accumulate_model_metrics to capture per-API-call token counts.
|
|
174
|
+
|
|
175
|
+
After each internal API call, Agno calls this function to sum tokens
|
|
176
|
+
into RunMetrics. We intercept it to snapshot the last call's tokens,
|
|
177
|
+
giving us the actual context window size (comparable to OpenCode/Claude Code).
|
|
178
|
+
"""
|
|
179
|
+
from agno.metrics import accumulate_model_metrics as _original_accumulate
|
|
180
|
+
|
|
181
|
+
import agno.metrics as _metrics_module
|
|
182
|
+
|
|
183
|
+
def _patched_accumulate(model_response, model, model_type, run_metrics=None):
|
|
184
|
+
global _last_call_input_tokens, _last_call_output_tokens
|
|
185
|
+
global _last_call_cache_read, _last_call_cache_write
|
|
186
|
+
usage = getattr(model_response, "response_usage", None)
|
|
187
|
+
if usage is not None:
|
|
188
|
+
_last_call_input_tokens = getattr(usage, "input_tokens", 0) or 0
|
|
189
|
+
_last_call_output_tokens = getattr(usage, "output_tokens", 0) or 0
|
|
190
|
+
_last_call_cache_read = getattr(usage, "cache_read_tokens", 0) or 0
|
|
191
|
+
_last_call_cache_write = getattr(usage, "cache_write_tokens", 0) or 0
|
|
192
|
+
return _original_accumulate(model_response, model, model_type, run_metrics)
|
|
193
|
+
|
|
194
|
+
_metrics_module.accumulate_model_metrics = _patched_accumulate
|
|
195
|
+
|
|
196
|
+
# Also patch the reference in base.py since it may have imported directly
|
|
197
|
+
try:
|
|
198
|
+
import agno.models.base as _base_module
|
|
199
|
+
_base_module.accumulate_model_metrics = _patched_accumulate
|
|
200
|
+
except (ImportError, AttributeError):
|
|
201
|
+
pass
|
|
@@ -14,6 +14,7 @@ import os
|
|
|
14
14
|
import sys
|
|
15
15
|
|
|
16
16
|
from rich.markdown import Markdown
|
|
17
|
+
from rich.panel import Panel
|
|
17
18
|
|
|
18
19
|
# ── Re-exports for backward compatibility ─────────────────────────────
|
|
19
20
|
# Tests and external code import these from aru.cli; keep them accessible.
|
|
@@ -50,6 +51,7 @@ from aru.display import ( # noqa: F401
|
|
|
50
51
|
from aru.completers import ( # noqa: F401
|
|
51
52
|
AruCompleter,
|
|
52
53
|
FileMentionCompleter,
|
|
54
|
+
MentionResult,
|
|
53
55
|
PasteState,
|
|
54
56
|
SlashCommandCompleter,
|
|
55
57
|
TIPS,
|
|
@@ -110,6 +112,11 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
110
112
|
from aru.permissions import parse_permission_config, reset_session as perm_reset_session
|
|
111
113
|
from aru.tools.codebase import cleanup_processes
|
|
112
114
|
|
|
115
|
+
# Inject cache breakpoints into Agno's Claude API calls — reduces token
|
|
116
|
+
# consumption by ~40% on multi-tool-call interactions via prompt caching.
|
|
117
|
+
from aru.cache_patch import apply_cache_patch
|
|
118
|
+
apply_cache_patch()
|
|
119
|
+
|
|
113
120
|
ctx = init_ctx(console=console, skip_permissions=skip_permissions)
|
|
114
121
|
|
|
115
122
|
store = SessionStore()
|
|
@@ -253,16 +260,19 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
253
260
|
|
|
254
261
|
# Resolve @file mentions (skip known agent names)
|
|
255
262
|
_agent_names = set(config.custom_agents.keys()) if config.custom_agents else set()
|
|
256
|
-
|
|
257
|
-
|
|
263
|
+
mention_result = _resolve_mentions(user_input, os.getcwd(), _agent_names)
|
|
264
|
+
attached_images = mention_result.images
|
|
265
|
+
# File contents go into history as separate prunable messages (not inline)
|
|
266
|
+
mention_file_msgs = mention_result.file_messages
|
|
267
|
+
if mention_result.count > 0:
|
|
258
268
|
parts = []
|
|
259
|
-
text_count =
|
|
269
|
+
text_count = mention_result.count - len(attached_images)
|
|
260
270
|
if text_count > 0:
|
|
261
271
|
parts.append(f"{text_count} file(s)")
|
|
262
272
|
if attached_images:
|
|
263
273
|
parts.append(f"{len(attached_images)} image(s)")
|
|
264
274
|
console.print(f"[dim]Attached {', '.join(parts)} from @ mentions[/dim]")
|
|
265
|
-
user_input =
|
|
275
|
+
user_input = mention_result.text
|
|
266
276
|
|
|
267
277
|
if paste_state.pasted_content and user_text:
|
|
268
278
|
console.print(
|
|
@@ -276,6 +286,14 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
276
286
|
if not user_input:
|
|
277
287
|
continue
|
|
278
288
|
|
|
289
|
+
# Inject @file contents as prunable history entries BEFORE the user message.
|
|
290
|
+
# These look like simulated read_file tool calls and can be pruned/compacted
|
|
291
|
+
# normally, unlike inline content which bloats the user message permanently.
|
|
292
|
+
if mention_file_msgs:
|
|
293
|
+
for msg in mention_file_msgs:
|
|
294
|
+
session.add_message(msg["role"], msg["content"])
|
|
295
|
+
mention_file_msgs = [] # consumed
|
|
296
|
+
|
|
279
297
|
# Reset "allow all" approvals for each new user message
|
|
280
298
|
perm_reset_session()
|
|
281
299
|
|
|
@@ -396,6 +414,15 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
396
414
|
_show_help(config)
|
|
397
415
|
continue
|
|
398
416
|
|
|
417
|
+
if user_input.lower() == "/cost":
|
|
418
|
+
console.print(Panel(
|
|
419
|
+
session.cost_summary,
|
|
420
|
+
title="[bold]Token Usage & Cost[/bold]",
|
|
421
|
+
border_style="cyan",
|
|
422
|
+
padding=(1, 2),
|
|
423
|
+
))
|
|
424
|
+
continue
|
|
425
|
+
|
|
399
426
|
if user_input.startswith("! "):
|
|
400
427
|
cmd = user_input[2:].strip()
|
|
401
428
|
if not cmd:
|
|
@@ -501,7 +528,7 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
501
528
|
session.add_message("assistant", run_result.with_tools_summary())
|
|
502
529
|
else:
|
|
503
530
|
console.print(f"[yellow]Unknown command: /{cmd_name}[/yellow]")
|
|
504
|
-
console.print(f"[dim]Built-in: /plan, /model, /sessions, /commands, /skills, /agents, /quit[/dim]")
|
|
531
|
+
console.print(f"[dim]Built-in: /plan, /model, /sessions, /commands, /skills, /agents, /cost, /quit[/dim]")
|
|
505
532
|
if config.commands:
|
|
506
533
|
console.print(f"[dim]Custom: {', '.join(f'/{k}' for k in config.commands)}[/dim]")
|
|
507
534
|
if config.skills:
|
|
@@ -21,6 +21,7 @@ SLASH_COMMANDS = [
|
|
|
21
21
|
("/skills", "List available skills", "/skills"),
|
|
22
22
|
("/agents", "List custom agents", "/agents"),
|
|
23
23
|
("/mcp", "List loaded MCP tools", "/mcp"),
|
|
24
|
+
("/cost", "Show detailed token usage and cost", "/cost"),
|
|
24
25
|
("/quit", "Exit aru", "/quit"),
|
|
25
26
|
]
|
|
26
27
|
|
|
@@ -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(
|