aru-code 0.26.1__tar.gz → 0.27.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.26.1 → aru_code-0.27.0}/PKG-INFO +1 -1
- aru_code-0.27.0/aru/__init__.py +1 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/agent_factory.py +67 -80
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/agents/base.py +33 -6
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/agents/catalog.py +4 -4
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/cache_patch.py +40 -4
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/cli.py +27 -9
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/completers.py +10 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/permissions.py +92 -11
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/runner.py +117 -2
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/runtime.py +3 -0
- aru_code-0.27.0/aru/select.py +180 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/session.py +11 -0
- aru_code-0.27.0/aru/tools/_diff.py +150 -0
- aru_code-0.27.0/aru/tools/_shared.py +63 -0
- aru_code-0.27.0/aru/tools/codebase.py +143 -0
- aru_code-0.27.0/aru/tools/delegate.py +236 -0
- aru_code-0.27.0/aru/tools/file_ops.py +473 -0
- aru_code-0.27.0/aru/tools/plan_mode.py +226 -0
- aru_code-0.27.0/aru/tools/registry.py +197 -0
- aru_code-0.27.0/aru/tools/search.py +370 -0
- aru_code-0.27.0/aru/tools/shell.py +231 -0
- aru_code-0.27.0/aru/tools/web.py +259 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru_code.egg-info/PKG-INFO +1 -1
- {aru_code-0.26.1 → aru_code-0.27.0}/aru_code.egg-info/SOURCES.txt +14 -1
- {aru_code-0.26.1 → aru_code-0.27.0}/pyproject.toml +1 -1
- aru_code-0.27.0/tests/test_agents_md_coverage.py +52 -0
- aru_code-0.27.0/tests/test_cache_patch_metrics.py +180 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_cli_advanced.py +8 -8
- {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_permissions.py +46 -15
- aru_code-0.27.0/tests/test_plan_mode_refactor.py +501 -0
- aru_code-0.27.0/tests/test_select.py +202 -0
- aru_code-0.26.1/aru/__init__.py +0 -1
- aru_code-0.26.1/aru/tools/codebase.py +0 -2020
- aru_code-0.26.1/aru/tools/plan_mode.py +0 -169
- {aru_code-0.26.1 → aru_code-0.27.0}/LICENSE +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/README.md +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/agents/__init__.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/agents/planner.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/checkpoints.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/commands.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/config.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/context.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/display.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/history_blocks.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/plugins/__init__.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/plugins/custom_tools.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/plugins/hooks.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/plugins/manager.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/plugins/tool_api.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/providers.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/tools/__init__.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/tools/gitignore.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/tools/ranker.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru/tools/tasklist.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/setup.cfg +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_agents_base.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_catalog.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_checkpoints.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_cli.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_cli_base.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_cli_completers.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_cli_new.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_cli_run_cli.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_cli_session.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_cli_shell.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_codebase.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_confabulation_regression.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_config.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_context.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_gitignore.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_guardrails_scenarios.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_main.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_mcp_client.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_plugins.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_providers.py +0 -0
- {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_ranker.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.27.0"
|
|
@@ -15,40 +15,47 @@ from aru.session import Session
|
|
|
15
15
|
logger = logging.getLogger("aru.agent_factory")
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
async def _fire_hook(event_name: str, data: dict) -> dict:
|
|
19
|
+
"""Fire a plugin hook and return the (possibly mutated) event data."""
|
|
20
|
+
try:
|
|
21
|
+
from aru.runtime import get_ctx
|
|
22
|
+
ctx = get_ctx()
|
|
23
|
+
mgr = ctx.plugin_manager
|
|
24
|
+
if mgr is not None and mgr.loaded:
|
|
25
|
+
event = await mgr.fire(event_name, data)
|
|
26
|
+
return event.data
|
|
27
|
+
except (LookupError, AttributeError):
|
|
28
|
+
pass
|
|
29
|
+
return data
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Tools blocked while the session is in plan mode. Read-only tools (read,
|
|
33
|
+
# glob, grep, list_directory, web_search, web_fetch, etc.) are NOT in this
|
|
34
|
+
# set — the agent needs them to research and write the plan. Mutating or
|
|
35
|
+
# execution-capable tools are gated: the agent must call exit_plan_mode and
|
|
36
|
+
# get user approval before running any of these.
|
|
37
|
+
_PLAN_MODE_BLOCKED_TOOLS: frozenset[str] = frozenset({
|
|
38
|
+
"edit_file",
|
|
39
|
+
"edit_files",
|
|
40
|
+
"write_file",
|
|
41
|
+
"write_files",
|
|
42
|
+
"bash",
|
|
43
|
+
"delegate_task",
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
|
|
18
47
|
def _wrap_tools_with_hooks(tools: list) -> list:
|
|
19
48
|
"""Wrap tool functions to fire tool.execute.before/after plugin hooks.
|
|
20
49
|
|
|
21
50
|
Before hook can mutate args; after hook can mutate the result.
|
|
22
51
|
If a before hook raises, the tool is not executed and the error is returned.
|
|
23
|
-
"""
|
|
24
|
-
from aru.runtime import get_ctx
|
|
25
52
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return event.data
|
|
33
|
-
except (LookupError, AttributeError):
|
|
34
|
-
pass
|
|
35
|
-
return data
|
|
36
|
-
|
|
37
|
-
async def _fire_tool_definition(tool_name: str, description: str, parameters: dict) -> dict:
|
|
38
|
-
"""Fire tool.definition hook — plugins can modify tool desc/params."""
|
|
39
|
-
try:
|
|
40
|
-
ctx = get_ctx()
|
|
41
|
-
mgr = ctx.plugin_manager
|
|
42
|
-
if mgr is not None and mgr.loaded:
|
|
43
|
-
event = await mgr.fire("tool.definition", {
|
|
44
|
-
"tool_name": tool_name,
|
|
45
|
-
"description": description,
|
|
46
|
-
"parameters": parameters,
|
|
47
|
-
})
|
|
48
|
-
return event.data
|
|
49
|
-
except (LookupError, AttributeError):
|
|
50
|
-
pass
|
|
51
|
-
return {"tool_name": tool_name, "description": description, "parameters": parameters}
|
|
53
|
+
Also enforces the plan-mode gate: when `session.plan_mode` is True,
|
|
54
|
+
any tool in `_PLAN_MODE_BLOCKED_TOOLS` short-circuits with a structured
|
|
55
|
+
BLOCKED message telling the agent to call `exit_plan_mode` first. The
|
|
56
|
+
gate runs BEFORE plugin hooks so plan mode is the highest-priority
|
|
57
|
+
enforcement; plugins cannot accidentally bypass it.
|
|
58
|
+
"""
|
|
52
59
|
|
|
53
60
|
def _wrap_one(fn):
|
|
54
61
|
if not callable(fn) or getattr(fn, "_hook_wrapped", False):
|
|
@@ -57,9 +64,26 @@ def _wrap_tools_with_hooks(tools: list) -> list:
|
|
|
57
64
|
@functools.wraps(fn)
|
|
58
65
|
async def wrapper(**kwargs):
|
|
59
66
|
tool_name = fn.__name__
|
|
67
|
+
# Plan-mode gate — fires before any other logic so a mutating
|
|
68
|
+
# tool never reaches the permission layer or the actual executor.
|
|
69
|
+
if tool_name in _PLAN_MODE_BLOCKED_TOOLS:
|
|
70
|
+
try:
|
|
71
|
+
from aru.runtime import get_ctx
|
|
72
|
+
session = getattr(get_ctx(), "session", None)
|
|
73
|
+
except (LookupError, AttributeError):
|
|
74
|
+
session = None
|
|
75
|
+
if session is not None and getattr(session, "plan_mode", False):
|
|
76
|
+
return (
|
|
77
|
+
f"BLOCKED: plan mode is active. Mutating tools "
|
|
78
|
+
f"(edit/write/bash/delegate_task) are blocked until the "
|
|
79
|
+
f"user approves the plan. Finish writing the plan as "
|
|
80
|
+
f"your next assistant message, then call "
|
|
81
|
+
f"exit_plan_mode(plan=<full plan text>) to request "
|
|
82
|
+
f"approval. Do NOT retry {tool_name}."
|
|
83
|
+
)
|
|
60
84
|
# Before hook — plugins can mutate args or raise PermissionError to block
|
|
61
85
|
try:
|
|
62
|
-
before_data = await
|
|
86
|
+
before_data = await _fire_hook("tool.execute.before", {
|
|
63
87
|
"tool_name": tool_name,
|
|
64
88
|
"args": kwargs,
|
|
65
89
|
})
|
|
@@ -74,7 +98,7 @@ def _wrap_tools_with_hooks(tools: list) -> list:
|
|
|
74
98
|
result = fn(**kwargs)
|
|
75
99
|
|
|
76
100
|
# After hook — plugins can mutate the result
|
|
77
|
-
after_data = await
|
|
101
|
+
after_data = await _fire_hook("tool.execute.after", {
|
|
78
102
|
"tool_name": tool_name,
|
|
79
103
|
"args": kwargs,
|
|
80
104
|
"result": result,
|
|
@@ -87,58 +111,21 @@ def _wrap_tools_with_hooks(tools: list) -> list:
|
|
|
87
111
|
return [_wrap_one(t) for t in tools]
|
|
88
112
|
|
|
89
113
|
|
|
90
|
-
def
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
Agent creation happens in sync code, so we need a sync path.
|
|
94
|
-
"""
|
|
95
|
-
try:
|
|
96
|
-
from aru.runtime import get_ctx
|
|
97
|
-
ctx = get_ctx()
|
|
98
|
-
mgr = ctx.plugin_manager
|
|
99
|
-
if mgr is not None and mgr.loaded:
|
|
100
|
-
import asyncio
|
|
101
|
-
from aru.plugins.hooks import HookEvent
|
|
102
|
-
event = HookEvent(hook=event_name, data=data or {})
|
|
103
|
-
for hooks in mgr._hooks:
|
|
104
|
-
for handler in hooks.get_handlers(event_name):
|
|
105
|
-
try:
|
|
106
|
-
if asyncio.iscoroutinefunction(handler):
|
|
107
|
-
# Best-effort: try to run async handler
|
|
108
|
-
try:
|
|
109
|
-
loop = asyncio.get_running_loop()
|
|
110
|
-
except RuntimeError:
|
|
111
|
-
loop = None
|
|
112
|
-
if loop and loop.is_running():
|
|
113
|
-
# Can't await in sync context with running loop — skip
|
|
114
|
-
continue
|
|
115
|
-
else:
|
|
116
|
-
asyncio.run(handler(event))
|
|
117
|
-
else:
|
|
118
|
-
handler(event)
|
|
119
|
-
except Exception as e:
|
|
120
|
-
logger.warning("Hook handler error (%s): %s", event_name, e)
|
|
121
|
-
return event.data
|
|
122
|
-
except (LookupError, AttributeError):
|
|
123
|
-
pass
|
|
124
|
-
return data
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def _apply_chat_hooks(instructions: str, model_ref: str, agent_name: str,
|
|
128
|
-
max_tokens: int = 8192) -> tuple[str, str, int]:
|
|
114
|
+
async def _apply_chat_hooks(instructions: str, model_ref: str, agent_name: str,
|
|
115
|
+
max_tokens: int = 8192) -> tuple[str, str, int]:
|
|
129
116
|
"""Apply chat.system.transform and chat.params hooks to agent creation params.
|
|
130
117
|
|
|
131
118
|
Returns (instructions, model_ref, max_tokens) — possibly modified by plugins.
|
|
132
119
|
"""
|
|
133
120
|
# chat.system.transform — plugins can modify the system prompt
|
|
134
|
-
data =
|
|
121
|
+
data = await _fire_hook("chat.system.transform", {
|
|
135
122
|
"system_prompt": instructions,
|
|
136
123
|
"agent": agent_name,
|
|
137
124
|
})
|
|
138
125
|
instructions = data.get("system_prompt", instructions)
|
|
139
126
|
|
|
140
127
|
# chat.params — plugins can modify LLM parameters
|
|
141
|
-
data =
|
|
128
|
+
data = await _fire_hook("chat.params", {
|
|
142
129
|
"model": model_ref,
|
|
143
130
|
"max_tokens": max_tokens,
|
|
144
131
|
"temperature": None, # let plugin set if desired
|
|
@@ -149,7 +136,7 @@ def _apply_chat_hooks(instructions: str, model_ref: str, agent_name: str,
|
|
|
149
136
|
return instructions, model_ref, max_tokens
|
|
150
137
|
|
|
151
138
|
|
|
152
|
-
def create_agent_from_spec(
|
|
139
|
+
async def create_agent_from_spec(
|
|
153
140
|
spec: AgentSpec,
|
|
154
141
|
session: Session | None = None,
|
|
155
142
|
model_ref: str | None = None,
|
|
@@ -178,7 +165,7 @@ def create_agent_from_spec(
|
|
|
178
165
|
tools = _wrap_tools_with_hooks(spec.tools_factory())
|
|
179
166
|
instructions = _build_instructions(spec.role, extra_instructions)
|
|
180
167
|
|
|
181
|
-
instructions, resolved_model, max_tokens = _apply_chat_hooks(
|
|
168
|
+
instructions, resolved_model, max_tokens = await _apply_chat_hooks(
|
|
182
169
|
instructions, resolved_model, spec.name, max_tokens=spec.max_tokens,
|
|
183
170
|
)
|
|
184
171
|
|
|
@@ -192,7 +179,7 @@ def create_agent_from_spec(
|
|
|
192
179
|
)
|
|
193
180
|
|
|
194
181
|
|
|
195
|
-
def create_general_agent(
|
|
182
|
+
async def create_general_agent(
|
|
196
183
|
session: Session,
|
|
197
184
|
config: AgentConfig | None = None,
|
|
198
185
|
model_override: str | None = None,
|
|
@@ -202,7 +189,7 @@ def create_general_agent(
|
|
|
202
189
|
extra = config.get_extra_instructions() if config else ""
|
|
203
190
|
if env_context:
|
|
204
191
|
extra = f"{extra}\n\n{env_context}" if extra else env_context
|
|
205
|
-
return create_agent_from_spec(
|
|
192
|
+
return await create_agent_from_spec(
|
|
206
193
|
AGENTS["build"],
|
|
207
194
|
session,
|
|
208
195
|
model_ref=model_override or session.model_ref,
|
|
@@ -210,13 +197,13 @@ def create_general_agent(
|
|
|
210
197
|
)
|
|
211
198
|
|
|
212
199
|
|
|
213
|
-
def create_custom_agent_instance(agent_def: CustomAgent, session: Session,
|
|
214
|
-
|
|
215
|
-
|
|
200
|
+
async def create_custom_agent_instance(agent_def: CustomAgent, session: Session,
|
|
201
|
+
config: AgentConfig | None = None,
|
|
202
|
+
env_context: str = ""):
|
|
216
203
|
"""Create an Agno Agent from a CustomAgent definition."""
|
|
217
204
|
from agno.agent import Agent
|
|
218
205
|
from aru.agents.base import BASE_INSTRUCTIONS
|
|
219
|
-
from aru.tools.
|
|
206
|
+
from aru.tools.registry import resolve_tools
|
|
220
207
|
|
|
221
208
|
model_ref = agent_def.model or session.model_ref
|
|
222
209
|
tools = _wrap_tools_with_hooks(resolve_tools(agent_def.tools))
|
|
@@ -230,7 +217,7 @@ def create_custom_agent_instance(agent_def: CustomAgent, session: Session,
|
|
|
230
217
|
instructions = "\n\n".join(parts)
|
|
231
218
|
|
|
232
219
|
# Apply chat hooks (system.transform + params)
|
|
233
|
-
instructions, model_ref, max_tokens = _apply_chat_hooks(
|
|
220
|
+
instructions, model_ref, max_tokens = await _apply_chat_hooks(
|
|
234
221
|
instructions, model_ref, agent_def.name, max_tokens=8192,
|
|
235
222
|
)
|
|
236
223
|
|
|
@@ -283,12 +283,39 @@ your summary, not the raw explorer output.
|
|
|
283
283
|
|
|
284
284
|
## Planning
|
|
285
285
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
286
|
+
When the user asks you to "plan", "planeje", "propose", "think through", or \
|
|
287
|
+
when a task requires 3+ coordinated changes across files, your FIRST action \
|
|
288
|
+
MUST be `enter_plan_mode()` — before any read or other tool call.
|
|
289
|
+
|
|
290
|
+
Plan mode is a session flag that blocks mutating tools (edit_file, write_file, \
|
|
291
|
+
bash, delegate_task) until the user approves. The workflow is:
|
|
292
|
+
|
|
293
|
+
1. Call `enter_plan_mode()` as the very first tool call in the turn.
|
|
294
|
+
2. Optionally use read-only tools (read_file, grep_search, glob_search, \
|
|
295
|
+
list_directory, web_search, web_fetch) to research what the plan needs.
|
|
296
|
+
3. Write the full plan as your next assistant message — structured with \
|
|
297
|
+
## Goal, ## Steps (numbered), and ## Files sections.
|
|
298
|
+
4. **ALWAYS END YOUR TURN BY CALLING `exit_plan_mode(plan=<full plan text>)`.** \
|
|
299
|
+
This is not optional. The user only sees the approval prompt when you call \
|
|
300
|
+
`exit_plan_mode` — if you write the plan as text and stop without calling it, \
|
|
301
|
+
the user cannot approve and execution stalls. The runner has a safety net that \
|
|
302
|
+
auto-triggers approval at turn end, but you should not rely on it; call \
|
|
303
|
+
`exit_plan_mode` explicitly as the last tool call of the turn.
|
|
304
|
+
5. If approved, plan mode clears and the next turn executes the steps. If \
|
|
305
|
+
rejected, plan mode stays ON and the user's feedback will appear in a \
|
|
306
|
+
system-reminder on the next turn — revise the plan and call `exit_plan_mode` \
|
|
307
|
+
again with the revised plan.
|
|
308
|
+
|
|
309
|
+
CRITICAL — plan mode is a **pre-execution gate**, NOT a post-hoc summary. \
|
|
310
|
+
Do NOT call `enter_plan_mode()` after you have already made changes in the \
|
|
311
|
+
turn. If you already edited files, describe what you did as normal text.
|
|
312
|
+
|
|
313
|
+
If you try to call edit_file, write_file, bash, or delegate_task while in \
|
|
314
|
+
plan mode, they return a "BLOCKED: plan mode is active" error. Do NOT retry \
|
|
315
|
+
those tools — finish the plan and call exit_plan_mode instead.
|
|
316
|
+
|
|
317
|
+
For simple tasks (1-2 file changes) where the user did NOT ask for a plan, \
|
|
318
|
+
execute directly without entering plan mode.
|
|
292
319
|
|
|
293
320
|
## Plan execution
|
|
294
321
|
|
|
@@ -32,22 +32,22 @@ class AgentSpec:
|
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
def _build_tools() -> list:
|
|
35
|
-
from aru.tools.
|
|
35
|
+
from aru.tools.registry import GENERAL_TOOLS
|
|
36
36
|
return GENERAL_TOOLS
|
|
37
37
|
|
|
38
38
|
|
|
39
39
|
def _plan_tools() -> list:
|
|
40
|
-
from aru.tools.
|
|
40
|
+
from aru.tools.registry import PLANNER_TOOLS
|
|
41
41
|
return PLANNER_TOOLS
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
def _exec_tools() -> list:
|
|
45
|
-
from aru.tools.
|
|
45
|
+
from aru.tools.registry import EXECUTOR_TOOLS
|
|
46
46
|
return EXECUTOR_TOOLS
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
def _explore_tools() -> list:
|
|
50
|
-
from aru.tools.
|
|
50
|
+
from aru.tools.registry import EXPLORER_TOOLS
|
|
51
51
|
return EXPLORER_TOOLS
|
|
52
52
|
|
|
53
53
|
|
|
@@ -175,6 +175,26 @@ def _patch_per_call_metrics():
|
|
|
175
175
|
After each internal API call, Agno calls this function to sum tokens
|
|
176
176
|
into RunMetrics. We intercept it to snapshot the last call's tokens,
|
|
177
177
|
giving us the actual context window size (comparable to OpenCode/Claude Code).
|
|
178
|
+
|
|
179
|
+
Provider semantics differ and must be normalized:
|
|
180
|
+
|
|
181
|
+
- **Anthropic** reports `input_tokens` as *non-cached* only, with
|
|
182
|
+
`cache_read_input_tokens` and `cache_creation_input_tokens` as
|
|
183
|
+
separate, non-overlapping buckets. Total prompt =
|
|
184
|
+
``input + cache_read + cache_write``.
|
|
185
|
+
- **OpenAI-compatible** (OpenAI, Qwen/Alibaba, DeepSeek, Groq, etc.)
|
|
186
|
+
report `prompt_tokens` as the *total* prompt, with
|
|
187
|
+
`prompt_tokens_details.cached_tokens` being a *subset* of that total.
|
|
188
|
+
Total prompt = ``input`` alone; ``cache_read`` is already inside it.
|
|
189
|
+
|
|
190
|
+
Agno's adapters populate `metrics.input_tokens` from each provider's
|
|
191
|
+
native field without normalizing, so the same name means different
|
|
192
|
+
things. That would double-count cached tokens for OpenAI-style providers
|
|
193
|
+
in any formula that does ``input + cache_read``. To keep the rest of
|
|
194
|
+
Aru provider-agnostic, normalize here: subtract `cache_read` from
|
|
195
|
+
`input_tokens` whenever the provider overlaps them, so downstream code
|
|
196
|
+
can always treat `(input, cache_read, cache_write)` as non-overlapping
|
|
197
|
+
and sum them safely.
|
|
178
198
|
"""
|
|
179
199
|
from agno.metrics import accumulate_model_metrics as _original_accumulate
|
|
180
200
|
|
|
@@ -185,10 +205,26 @@ def _patch_per_call_metrics():
|
|
|
185
205
|
global _last_call_cache_read, _last_call_cache_write
|
|
186
206
|
usage = getattr(model_response, "response_usage", None)
|
|
187
207
|
if usage is not None:
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
208
|
+
input_tokens = getattr(usage, "input_tokens", 0) or 0
|
|
209
|
+
output_tokens = getattr(usage, "output_tokens", 0) or 0
|
|
210
|
+
cache_read = getattr(usage, "cache_read_tokens", 0) or 0
|
|
211
|
+
cache_write = getattr(usage, "cache_write_tokens", 0) or 0
|
|
212
|
+
|
|
213
|
+
# For non-Anthropic providers, `input_tokens` already includes
|
|
214
|
+
# the cached portion, so subtract it to match Anthropic's
|
|
215
|
+
# non-overlapping semantics. See docstring above.
|
|
216
|
+
try:
|
|
217
|
+
provider_name = model.get_provider() if hasattr(model, "get_provider") else ""
|
|
218
|
+
except Exception:
|
|
219
|
+
provider_name = ""
|
|
220
|
+
is_anthropic = "anthropic" in (provider_name or "").lower()
|
|
221
|
+
if not is_anthropic and cache_read and input_tokens >= cache_read:
|
|
222
|
+
input_tokens -= cache_read
|
|
223
|
+
|
|
224
|
+
_last_call_input_tokens = input_tokens
|
|
225
|
+
_last_call_output_tokens = output_tokens
|
|
226
|
+
_last_call_cache_read = cache_read
|
|
227
|
+
_last_call_cache_write = cache_write
|
|
192
228
|
return _original_accumulate(model_response, model, model_type, run_metrics)
|
|
193
229
|
|
|
194
230
|
_metrics_module.accumulate_model_metrics = _patched_accumulate
|
|
@@ -283,12 +283,24 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
283
283
|
f' <style fg="ansigray">│</style>'
|
|
284
284
|
f' <style fg="ansigray">{ctx.mcp_loaded_msg}</style>'
|
|
285
285
|
)
|
|
286
|
+
if ctx.permission_mode == "acceptEdits":
|
|
287
|
+
mode_part = (
|
|
288
|
+
f' <style fg="ansigray">│</style>'
|
|
289
|
+
f' <b><style fg="ansigreen">⏵⏵ auto-accept edits on</style></b>'
|
|
290
|
+
f' <style fg="ansigray">(shift+tab to toggle)</style>'
|
|
291
|
+
)
|
|
292
|
+
else:
|
|
293
|
+
mode_part = (
|
|
294
|
+
f' <style fg="ansigray">│</style>'
|
|
295
|
+
f' <style fg="ansigray">shift+tab auto-accept</style>'
|
|
296
|
+
)
|
|
286
297
|
return HTML(
|
|
287
298
|
f' <style fg="ansigray">{model_tb}</style>'
|
|
288
299
|
f' <style fg="ansigray">│</style>'
|
|
289
300
|
f' <style fg="ansigray">/help</style>'
|
|
290
301
|
f' <style fg="ansigray">│</style>'
|
|
291
302
|
f' <style fg="ansigray">Esc+Enter newline</style>'
|
|
303
|
+
f'{mode_part}'
|
|
292
304
|
f'{mcp_part}'
|
|
293
305
|
)
|
|
294
306
|
|
|
@@ -390,6 +402,12 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
390
402
|
if choice in ("b", "v"):
|
|
391
403
|
# Remove last turn from conversation
|
|
392
404
|
msgs_removed = session.undo_last_turn()
|
|
405
|
+
# Conversation restore also reverts plan-mode state — the
|
|
406
|
+
# undone turn may have entered plan mode, and leaving the
|
|
407
|
+
# flag on would block the next turn's mutating tools.
|
|
408
|
+
if session.plan_mode:
|
|
409
|
+
session.plan_mode = False
|
|
410
|
+
session.clear_plan()
|
|
393
411
|
|
|
394
412
|
parts = []
|
|
395
413
|
if restored_files:
|
|
@@ -623,14 +641,14 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
623
641
|
env_ctx = _build_env_ctx()
|
|
624
642
|
if cmd_def.agent and cmd_def.agent in config.custom_agents:
|
|
625
643
|
agent_def = config.custom_agents[cmd_def.agent]
|
|
626
|
-
agent = create_custom_agent_instance(agent_def, session, config, env_context=env_ctx)
|
|
644
|
+
agent = await create_custom_agent_instance(agent_def, session, config, env_context=env_ctx)
|
|
627
645
|
elif cmd_def.agent:
|
|
628
646
|
console.print(f"[yellow]Warning: agent '{cmd_def.agent}' not found, using default[/yellow]")
|
|
629
|
-
agent = create_general_agent(session, config, model_override=cmd_def.model, env_context=env_ctx)
|
|
647
|
+
agent = await create_general_agent(session, config, model_override=cmd_def.model, env_context=env_ctx)
|
|
630
648
|
elif cmd_def.model:
|
|
631
|
-
agent = create_general_agent(session, config, model_override=cmd_def.model, env_context=env_ctx)
|
|
649
|
+
agent = await create_general_agent(session, config, model_override=cmd_def.model, env_context=env_ctx)
|
|
632
650
|
else:
|
|
633
|
-
agent = create_general_agent(session, config, env_context=env_ctx)
|
|
651
|
+
agent = await create_general_agent(session, config, env_context=env_ctx)
|
|
634
652
|
session.add_message("user", user_input)
|
|
635
653
|
await run_agent_capture(agent, prompt, session, images=attached_images or None)
|
|
636
654
|
elif cmd_name in config.skills:
|
|
@@ -641,7 +659,7 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
641
659
|
prompt = render_skill_template(skill.content, cmd_args)
|
|
642
660
|
console.print(f"[bold magenta]Running skill /{cmd_name}...[/bold magenta]")
|
|
643
661
|
|
|
644
|
-
agent = create_general_agent(session, config, env_context=_build_env_ctx())
|
|
662
|
+
agent = await create_general_agent(session, config, env_context=_build_env_ctx())
|
|
645
663
|
session.add_message("user", user_input)
|
|
646
664
|
await run_agent_capture(agent, prompt, session, images=attached_images or None)
|
|
647
665
|
elif cmd_name in config.custom_agents:
|
|
@@ -651,7 +669,7 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
651
669
|
else:
|
|
652
670
|
from aru.permissions import permission_scope
|
|
653
671
|
console.print(f"[bold magenta]Running agent /{cmd_name}...[/bold magenta]")
|
|
654
|
-
agent = create_custom_agent_instance(agent_def, session, config, env_context=_build_env_ctx())
|
|
672
|
+
agent = await create_custom_agent_instance(agent_def, session, config, env_context=_build_env_ctx())
|
|
655
673
|
session.add_message("user", user_input)
|
|
656
674
|
with permission_scope(agent_def.permission):
|
|
657
675
|
await run_agent_capture(agent, cmd_args or user_input, session, images=attached_images or None)
|
|
@@ -677,12 +695,12 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
677
695
|
agent_def = config.custom_agents[agent_name]
|
|
678
696
|
from aru.permissions import permission_scope
|
|
679
697
|
console.print(f"[bold magenta]Routing to @{agent_name}...[/bold magenta]")
|
|
680
|
-
agent = create_custom_agent_instance(agent_def, session, config, env_context=_build_env_ctx())
|
|
698
|
+
agent = await create_custom_agent_instance(agent_def, session, config, env_context=_build_env_ctx())
|
|
681
699
|
session.add_message("user", user_input)
|
|
682
700
|
with permission_scope(agent_def.permission):
|
|
683
701
|
await run_agent_capture(agent, message_text, session, images=attached_images or None)
|
|
684
702
|
else:
|
|
685
|
-
agent = create_general_agent(session, config, env_context=_build_env_ctx())
|
|
703
|
+
agent = await create_general_agent(session, config, env_context=_build_env_ctx())
|
|
686
704
|
session.add_message("user", user_input)
|
|
687
705
|
await run_agent_capture(agent, user_input, session, images=attached_images or None)
|
|
688
706
|
|
|
@@ -771,7 +789,7 @@ async def run_oneshot(prompt: str, print_only: bool = False, skip_permissions: b
|
|
|
771
789
|
# Full mode with tools
|
|
772
790
|
from aru.runner import build_env_context
|
|
773
791
|
env_ctx = build_env_context(session)
|
|
774
|
-
agent = create_general_agent(session, config, env_context=env_ctx)
|
|
792
|
+
agent = await create_general_agent(session, config, env_context=env_ctx)
|
|
775
793
|
session.add_message("user", prompt)
|
|
776
794
|
await run_agent_capture(agent, prompt, session)
|
|
777
795
|
|
|
@@ -340,6 +340,16 @@ def _create_prompt_session(paste_state: PasteState, config: AgentConfig | None =
|
|
|
340
340
|
"""Escape+Enter inserts a newline for manual multi-line editing."""
|
|
341
341
|
event.current_buffer.insert_text("\n")
|
|
342
342
|
|
|
343
|
+
@bindings.add(Keys.BackTab)
|
|
344
|
+
def _cycle_permission_mode(event):
|
|
345
|
+
"""Shift+Tab cycles the permission mode (default ↔ auto-accept edits)."""
|
|
346
|
+
from aru.permissions import cycle_permission_mode
|
|
347
|
+
try:
|
|
348
|
+
cycle_permission_mode()
|
|
349
|
+
except LookupError:
|
|
350
|
+
pass
|
|
351
|
+
event.app.invalidate()
|
|
352
|
+
|
|
343
353
|
custom_cmds = config.commands if config else {}
|
|
344
354
|
skills = config.skills if config else {}
|
|
345
355
|
custom_agents = config.custom_agents if config else {}
|
|
@@ -28,6 +28,7 @@ from rich.panel import Panel
|
|
|
28
28
|
from rich.text import Text
|
|
29
29
|
|
|
30
30
|
from aru.runtime import get_ctx
|
|
31
|
+
from aru.select import select_option
|
|
31
32
|
|
|
32
33
|
PermissionAction = Literal["allow", "ask", "deny"]
|
|
33
34
|
|
|
@@ -114,7 +115,49 @@ def get_skip_permissions() -> bool:
|
|
|
114
115
|
|
|
115
116
|
def reset_session() -> None:
|
|
116
117
|
"""Reset session-level permission state (call between conversations)."""
|
|
117
|
-
get_ctx()
|
|
118
|
+
ctx = get_ctx()
|
|
119
|
+
ctx.session_allowed.clear()
|
|
120
|
+
ctx.last_rejection_feedback = ""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# Modes the user can cycle between with shift+tab in the REPL.
|
|
124
|
+
_MODE_CYCLE: tuple[str, ...] = ("default", "acceptEdits")
|
|
125
|
+
|
|
126
|
+
MODE_LABELS: dict[str, str] = {
|
|
127
|
+
"default": "manually accept edits",
|
|
128
|
+
"acceptEdits": "auto-accept edits",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_permission_mode() -> str:
|
|
133
|
+
return get_ctx().permission_mode
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def set_permission_mode(mode: str) -> str:
|
|
137
|
+
ctx = get_ctx()
|
|
138
|
+
if mode not in _MODE_CYCLE:
|
|
139
|
+
mode = "default"
|
|
140
|
+
ctx.permission_mode = mode
|
|
141
|
+
return mode
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def cycle_permission_mode() -> str:
|
|
145
|
+
"""Advance to the next mode and return it."""
|
|
146
|
+
ctx = get_ctx()
|
|
147
|
+
try:
|
|
148
|
+
idx = _MODE_CYCLE.index(ctx.permission_mode)
|
|
149
|
+
except ValueError:
|
|
150
|
+
idx = 0
|
|
151
|
+
ctx.permission_mode = _MODE_CYCLE[(idx + 1) % len(_MODE_CYCLE)]
|
|
152
|
+
return ctx.permission_mode
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def consume_rejection_feedback() -> str:
|
|
156
|
+
"""Return and clear the most recent user-supplied rejection feedback."""
|
|
157
|
+
ctx = get_ctx()
|
|
158
|
+
fb = ctx.last_rejection_feedback
|
|
159
|
+
ctx.last_rejection_feedback = ""
|
|
160
|
+
return fb
|
|
118
161
|
|
|
119
162
|
|
|
120
163
|
def merge_configs(base: PermissionConfig, overlay: PermissionConfig) -> PermissionConfig:
|
|
@@ -387,6 +430,10 @@ def resolve_permission(
|
|
|
387
430
|
if ctx.skip_permissions:
|
|
388
431
|
return ("allow", "*")
|
|
389
432
|
|
|
433
|
+
# "Accept edits" mode auto-allows edit/write categories for the session.
|
|
434
|
+
if ctx.permission_mode == "acceptEdits" and category in ("edit", "write"):
|
|
435
|
+
return ("allow", "*")
|
|
436
|
+
|
|
390
437
|
# Check session memory
|
|
391
438
|
for cat, pattern in ctx.session_allowed:
|
|
392
439
|
if cat == category and _match_rule(pattern, subject):
|
|
@@ -501,16 +548,50 @@ def check_permission(
|
|
|
501
548
|
border_style="yellow",
|
|
502
549
|
expand=False,
|
|
503
550
|
))
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
551
|
+
|
|
552
|
+
is_edit = category in ("edit", "write")
|
|
553
|
+
if is_edit:
|
|
554
|
+
options = [
|
|
555
|
+
"Yes",
|
|
556
|
+
"Yes, and auto-accept edits (shift+tab)",
|
|
557
|
+
"No, and tell Aru what to do differently",
|
|
558
|
+
]
|
|
559
|
+
reject_index = 2 # "No" option
|
|
560
|
+
else:
|
|
561
|
+
options = [
|
|
562
|
+
"Yes",
|
|
563
|
+
"No, and tell Aru what to do differently",
|
|
564
|
+
]
|
|
565
|
+
reject_index = 1
|
|
566
|
+
|
|
567
|
+
# Arrow-key menu — pauses stdin during render, returns the chosen
|
|
568
|
+
# index (or reject_index on cancel so Esc/Ctrl+C behaves like "No").
|
|
569
|
+
choice = select_option(
|
|
570
|
+
options,
|
|
571
|
+
title="Choose an option (↑↓ to move, Enter to confirm):",
|
|
572
|
+
default=0,
|
|
573
|
+
cancel_value=reject_index,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
if choice == 0:
|
|
577
|
+
allowed = True
|
|
578
|
+
elif is_edit and choice == 1:
|
|
579
|
+
ctx.permission_mode = "acceptEdits"
|
|
580
|
+
ctx.console.print(
|
|
581
|
+
"[dim]Auto-accept edits enabled for this session (shift+tab to toggle).[/dim]"
|
|
582
|
+
)
|
|
583
|
+
allowed = True
|
|
584
|
+
else:
|
|
585
|
+
# Rejection path — optionally collect feedback for the model.
|
|
586
|
+
# Catch BaseException so tests and Ctrl+C during feedback don't crash.
|
|
587
|
+
try:
|
|
588
|
+
feedback = ctx.console.input(
|
|
589
|
+
"[bold yellow]Tell Aru what to do differently (enter to skip):[/bold yellow] "
|
|
590
|
+
).strip()
|
|
591
|
+
except BaseException:
|
|
592
|
+
feedback = ""
|
|
593
|
+
if feedback:
|
|
594
|
+
ctx.last_rejection_feedback = feedback
|
|
514
595
|
allowed = False
|
|
515
596
|
|
|
516
597
|
# Resume Live display
|