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.
Files changed (83) hide show
  1. {aru_code-0.26.1 → aru_code-0.27.0}/PKG-INFO +1 -1
  2. aru_code-0.27.0/aru/__init__.py +1 -0
  3. {aru_code-0.26.1 → aru_code-0.27.0}/aru/agent_factory.py +67 -80
  4. {aru_code-0.26.1 → aru_code-0.27.0}/aru/agents/base.py +33 -6
  5. {aru_code-0.26.1 → aru_code-0.27.0}/aru/agents/catalog.py +4 -4
  6. {aru_code-0.26.1 → aru_code-0.27.0}/aru/cache_patch.py +40 -4
  7. {aru_code-0.26.1 → aru_code-0.27.0}/aru/cli.py +27 -9
  8. {aru_code-0.26.1 → aru_code-0.27.0}/aru/completers.py +10 -0
  9. {aru_code-0.26.1 → aru_code-0.27.0}/aru/permissions.py +92 -11
  10. {aru_code-0.26.1 → aru_code-0.27.0}/aru/runner.py +117 -2
  11. {aru_code-0.26.1 → aru_code-0.27.0}/aru/runtime.py +3 -0
  12. aru_code-0.27.0/aru/select.py +180 -0
  13. {aru_code-0.26.1 → aru_code-0.27.0}/aru/session.py +11 -0
  14. aru_code-0.27.0/aru/tools/_diff.py +150 -0
  15. aru_code-0.27.0/aru/tools/_shared.py +63 -0
  16. aru_code-0.27.0/aru/tools/codebase.py +143 -0
  17. aru_code-0.27.0/aru/tools/delegate.py +236 -0
  18. aru_code-0.27.0/aru/tools/file_ops.py +473 -0
  19. aru_code-0.27.0/aru/tools/plan_mode.py +226 -0
  20. aru_code-0.27.0/aru/tools/registry.py +197 -0
  21. aru_code-0.27.0/aru/tools/search.py +370 -0
  22. aru_code-0.27.0/aru/tools/shell.py +231 -0
  23. aru_code-0.27.0/aru/tools/web.py +259 -0
  24. {aru_code-0.26.1 → aru_code-0.27.0}/aru_code.egg-info/PKG-INFO +1 -1
  25. {aru_code-0.26.1 → aru_code-0.27.0}/aru_code.egg-info/SOURCES.txt +14 -1
  26. {aru_code-0.26.1 → aru_code-0.27.0}/pyproject.toml +1 -1
  27. aru_code-0.27.0/tests/test_agents_md_coverage.py +52 -0
  28. aru_code-0.27.0/tests/test_cache_patch_metrics.py +180 -0
  29. {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_cli_advanced.py +8 -8
  30. {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_permissions.py +46 -15
  31. aru_code-0.27.0/tests/test_plan_mode_refactor.py +501 -0
  32. aru_code-0.27.0/tests/test_select.py +202 -0
  33. aru_code-0.26.1/aru/__init__.py +0 -1
  34. aru_code-0.26.1/aru/tools/codebase.py +0 -2020
  35. aru_code-0.26.1/aru/tools/plan_mode.py +0 -169
  36. {aru_code-0.26.1 → aru_code-0.27.0}/LICENSE +0 -0
  37. {aru_code-0.26.1 → aru_code-0.27.0}/README.md +0 -0
  38. {aru_code-0.26.1 → aru_code-0.27.0}/aru/agents/__init__.py +0 -0
  39. {aru_code-0.26.1 → aru_code-0.27.0}/aru/agents/planner.py +0 -0
  40. {aru_code-0.26.1 → aru_code-0.27.0}/aru/checkpoints.py +0 -0
  41. {aru_code-0.26.1 → aru_code-0.27.0}/aru/commands.py +0 -0
  42. {aru_code-0.26.1 → aru_code-0.27.0}/aru/config.py +0 -0
  43. {aru_code-0.26.1 → aru_code-0.27.0}/aru/context.py +0 -0
  44. {aru_code-0.26.1 → aru_code-0.27.0}/aru/display.py +0 -0
  45. {aru_code-0.26.1 → aru_code-0.27.0}/aru/history_blocks.py +0 -0
  46. {aru_code-0.26.1 → aru_code-0.27.0}/aru/plugins/__init__.py +0 -0
  47. {aru_code-0.26.1 → aru_code-0.27.0}/aru/plugins/custom_tools.py +0 -0
  48. {aru_code-0.26.1 → aru_code-0.27.0}/aru/plugins/hooks.py +0 -0
  49. {aru_code-0.26.1 → aru_code-0.27.0}/aru/plugins/manager.py +0 -0
  50. {aru_code-0.26.1 → aru_code-0.27.0}/aru/plugins/tool_api.py +0 -0
  51. {aru_code-0.26.1 → aru_code-0.27.0}/aru/providers.py +0 -0
  52. {aru_code-0.26.1 → aru_code-0.27.0}/aru/tools/__init__.py +0 -0
  53. {aru_code-0.26.1 → aru_code-0.27.0}/aru/tools/ast_tools.py +0 -0
  54. {aru_code-0.26.1 → aru_code-0.27.0}/aru/tools/gitignore.py +0 -0
  55. {aru_code-0.26.1 → aru_code-0.27.0}/aru/tools/mcp_client.py +0 -0
  56. {aru_code-0.26.1 → aru_code-0.27.0}/aru/tools/ranker.py +0 -0
  57. {aru_code-0.26.1 → aru_code-0.27.0}/aru/tools/tasklist.py +0 -0
  58. {aru_code-0.26.1 → aru_code-0.27.0}/aru_code.egg-info/dependency_links.txt +0 -0
  59. {aru_code-0.26.1 → aru_code-0.27.0}/aru_code.egg-info/entry_points.txt +0 -0
  60. {aru_code-0.26.1 → aru_code-0.27.0}/aru_code.egg-info/requires.txt +0 -0
  61. {aru_code-0.26.1 → aru_code-0.27.0}/aru_code.egg-info/top_level.txt +0 -0
  62. {aru_code-0.26.1 → aru_code-0.27.0}/setup.cfg +0 -0
  63. {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_agents_base.py +0 -0
  64. {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_catalog.py +0 -0
  65. {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_checkpoints.py +0 -0
  66. {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_cli.py +0 -0
  67. {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_cli_base.py +0 -0
  68. {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_cli_completers.py +0 -0
  69. {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_cli_new.py +0 -0
  70. {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_cli_run_cli.py +0 -0
  71. {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_cli_session.py +0 -0
  72. {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_cli_shell.py +0 -0
  73. {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_codebase.py +0 -0
  74. {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_confabulation_regression.py +0 -0
  75. {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_config.py +0 -0
  76. {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_context.py +0 -0
  77. {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_gitignore.py +0 -0
  78. {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_guardrails_scenarios.py +0 -0
  79. {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_main.py +0 -0
  80. {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_mcp_client.py +0 -0
  81. {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_plugins.py +0 -0
  82. {aru_code-0.26.1 → aru_code-0.27.0}/tests/test_providers.py +0 -0
  83. {aru_code-0.26.1 → aru_code-0.27.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.26.1
3
+ Version: 0.27.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
@@ -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
- async def _fire(event_name: str, data: dict) -> dict:
27
- try:
28
- ctx = get_ctx()
29
- mgr = ctx.plugin_manager
30
- if mgr is not None and mgr.loaded:
31
- event = await mgr.fire(event_name, data)
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 _fire("tool.execute.before", {
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 _fire("tool.execute.after", {
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 _fire_sync_hook(event_name: str, data: dict) -> dict:
91
- """Fire a plugin hook synchronously (for agent creation context).
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 = _fire_sync_hook("chat.system.transform", {
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 = _fire_sync_hook("chat.params", {
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
- config: AgentConfig | None = None,
215
- env_context: str = ""):
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.codebase import resolve_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
- For tasks requiring 3+ coordinated changes across multiple files, call \
287
- `enter_plan_mode(task)` BEFORE starting work. It generates a structured plan via \
288
- the planner agent and stores it in the session. After it returns, a PLAN ACTIVE \
289
- reminder will appear in your context — execute the steps in order.
290
-
291
- For simple tasks (1-2 file changes), execute directly without planning.
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.codebase import GENERAL_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.codebase import PLANNER_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.codebase import EXECUTOR_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.codebase import EXPLORER_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
- _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
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().session_allowed.clear()
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
- try:
505
- answer = ctx.console.input(
506
- "[bold yellow]Allow? (y)es once / (a)lways / (n)o:[/bold yellow] "
507
- ).strip().lower()
508
- if answer in ("a", "always", "all"):
509
- ctx.session_allowed.add((category, matched_pattern))
510
- allowed = True
511
- else:
512
- allowed = answer in ("y", "yes", "s", "sim")
513
- except (EOFError, KeyboardInterrupt):
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