aru-code 0.31.0__tar.gz → 0.32.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 (95) hide show
  1. {aru_code-0.31.0 → aru_code-0.32.0}/PKG-INFO +1 -1
  2. aru_code-0.32.0/aru/__init__.py +1 -0
  3. {aru_code-0.31.0 → aru_code-0.32.0}/aru/agent_factory.py +13 -2
  4. {aru_code-0.31.0 → aru_code-0.32.0}/aru/agents/catalog.py +2 -0
  5. {aru_code-0.31.0 → aru_code-0.32.0}/aru/cache_patch.py +279 -19
  6. {aru_code-0.31.0 → aru_code-0.32.0}/aru/cli.py +25 -1
  7. {aru_code-0.31.0 → aru_code-0.32.0}/aru/commands.py +1 -0
  8. {aru_code-0.31.0 → aru_code-0.32.0}/aru/context.py +24 -1
  9. {aru_code-0.31.0 → aru_code-0.32.0}/aru/providers.py +214 -3
  10. {aru_code-0.31.0 → aru_code-0.32.0}/aru/session.py +28 -0
  11. {aru_code-0.31.0 → aru_code-0.32.0}/aru_code.egg-info/PKG-INFO +1 -1
  12. {aru_code-0.31.0 → aru_code-0.32.0}/aru_code.egg-info/SOURCES.txt +2 -0
  13. {aru_code-0.31.0 → aru_code-0.32.0}/pyproject.toml +1 -1
  14. aru_code-0.32.0/tests/test_microcompact.py +277 -0
  15. aru_code-0.32.0/tests/test_reasoning.py +455 -0
  16. aru_code-0.31.0/aru/__init__.py +0 -1
  17. {aru_code-0.31.0 → aru_code-0.32.0}/LICENSE +0 -0
  18. {aru_code-0.31.0 → aru_code-0.32.0}/README.md +0 -0
  19. {aru_code-0.31.0 → aru_code-0.32.0}/aru/agents/__init__.py +0 -0
  20. {aru_code-0.31.0 → aru_code-0.32.0}/aru/agents/base.py +0 -0
  21. {aru_code-0.31.0 → aru_code-0.32.0}/aru/agents/planner.py +0 -0
  22. {aru_code-0.31.0 → aru_code-0.32.0}/aru/checkpoints.py +0 -0
  23. {aru_code-0.31.0 → aru_code-0.32.0}/aru/completers.py +0 -0
  24. {aru_code-0.31.0 → aru_code-0.32.0}/aru/config.py +0 -0
  25. {aru_code-0.31.0 → aru_code-0.32.0}/aru/display.py +0 -0
  26. {aru_code-0.31.0 → aru_code-0.32.0}/aru/history_blocks.py +0 -0
  27. {aru_code-0.31.0 → aru_code-0.32.0}/aru/permissions.py +0 -0
  28. {aru_code-0.31.0 → aru_code-0.32.0}/aru/plugin_cache.py +0 -0
  29. {aru_code-0.31.0 → aru_code-0.32.0}/aru/plugins/__init__.py +0 -0
  30. {aru_code-0.31.0 → aru_code-0.32.0}/aru/plugins/custom_tools.py +0 -0
  31. {aru_code-0.31.0 → aru_code-0.32.0}/aru/plugins/hooks.py +0 -0
  32. {aru_code-0.31.0 → aru_code-0.32.0}/aru/plugins/manager.py +0 -0
  33. {aru_code-0.31.0 → aru_code-0.32.0}/aru/plugins/tool_api.py +0 -0
  34. {aru_code-0.31.0 → aru_code-0.32.0}/aru/runner.py +0 -0
  35. {aru_code-0.31.0 → aru_code-0.32.0}/aru/runtime.py +0 -0
  36. {aru_code-0.31.0 → aru_code-0.32.0}/aru/select.py +0 -0
  37. {aru_code-0.31.0 → aru_code-0.32.0}/aru/tool_policy.py +0 -0
  38. {aru_code-0.31.0 → aru_code-0.32.0}/aru/tools/__init__.py +0 -0
  39. {aru_code-0.31.0 → aru_code-0.32.0}/aru/tools/_diff.py +0 -0
  40. {aru_code-0.31.0 → aru_code-0.32.0}/aru/tools/_shared.py +0 -0
  41. {aru_code-0.31.0 → aru_code-0.32.0}/aru/tools/ast_tools.py +0 -0
  42. {aru_code-0.31.0 → aru_code-0.32.0}/aru/tools/codebase.py +0 -0
  43. {aru_code-0.31.0 → aru_code-0.32.0}/aru/tools/delegate.py +0 -0
  44. {aru_code-0.31.0 → aru_code-0.32.0}/aru/tools/file_ops.py +0 -0
  45. {aru_code-0.31.0 → aru_code-0.32.0}/aru/tools/gitignore.py +0 -0
  46. {aru_code-0.31.0 → aru_code-0.32.0}/aru/tools/mcp_client.py +0 -0
  47. {aru_code-0.31.0 → aru_code-0.32.0}/aru/tools/plan_mode.py +0 -0
  48. {aru_code-0.31.0 → aru_code-0.32.0}/aru/tools/ranker.py +0 -0
  49. {aru_code-0.31.0 → aru_code-0.32.0}/aru/tools/registry.py +0 -0
  50. {aru_code-0.31.0 → aru_code-0.32.0}/aru/tools/search.py +0 -0
  51. {aru_code-0.31.0 → aru_code-0.32.0}/aru/tools/shell.py +0 -0
  52. {aru_code-0.31.0 → aru_code-0.32.0}/aru/tools/skill.py +0 -0
  53. {aru_code-0.31.0 → aru_code-0.32.0}/aru/tools/tasklist.py +0 -0
  54. {aru_code-0.31.0 → aru_code-0.32.0}/aru/tools/web.py +0 -0
  55. {aru_code-0.31.0 → aru_code-0.32.0}/aru_code.egg-info/dependency_links.txt +0 -0
  56. {aru_code-0.31.0 → aru_code-0.32.0}/aru_code.egg-info/entry_points.txt +0 -0
  57. {aru_code-0.31.0 → aru_code-0.32.0}/aru_code.egg-info/requires.txt +0 -0
  58. {aru_code-0.31.0 → aru_code-0.32.0}/aru_code.egg-info/top_level.txt +0 -0
  59. {aru_code-0.31.0 → aru_code-0.32.0}/setup.cfg +0 -0
  60. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_agents_base.py +0 -0
  61. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_agents_md_coverage.py +0 -0
  62. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_cache_patch_metrics.py +0 -0
  63. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_cache_patch_stop_reason.py +0 -0
  64. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_catalog.py +0 -0
  65. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_checkpoints.py +0 -0
  66. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_cli.py +0 -0
  67. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_cli_advanced.py +0 -0
  68. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_cli_base.py +0 -0
  69. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_cli_completers.py +0 -0
  70. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_cli_new.py +0 -0
  71. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_cli_run_cli.py +0 -0
  72. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_cli_session.py +0 -0
  73. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_cli_shell.py +0 -0
  74. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_codebase.py +0 -0
  75. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_confabulation_regression.py +0 -0
  76. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_config.py +0 -0
  77. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_context.py +0 -0
  78. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_gitignore.py +0 -0
  79. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_guardrails_scenarios.py +0 -0
  80. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_invoke_skill.py +0 -0
  81. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_invoked_skills.py +0 -0
  82. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_main.py +0 -0
  83. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_mcp_client.py +0 -0
  84. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_permissions.py +0 -0
  85. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_plan_mode_refactor.py +0 -0
  86. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_plugin_cache.py +0 -0
  87. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_plugins.py +0 -0
  88. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_providers.py +0 -0
  89. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_ranker.py +0 -0
  90. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_runner_recovery.py +0 -0
  91. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_runtime.py +0 -0
  92. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_select.py +0 -0
  93. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_skill_disallowed_tools.py +0 -0
  94. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_tasklist.py +0 -0
  95. {aru_code-0.31.0 → aru_code-0.32.0}/tests/test_tool_policy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.31.0
3
+ Version: 0.32.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.32.0"
@@ -156,9 +156,16 @@ async def create_agent_from_spec(
156
156
  instructions, resolved_model, spec.name, max_tokens=spec.max_tokens,
157
157
  )
158
158
 
159
+ reasoning_override = session.reasoning_override if session is not None else None
160
+
159
161
  return Agent(
160
162
  name=spec.name,
161
- model=create_model(resolved_model, max_tokens=max_tokens),
163
+ model=create_model(
164
+ resolved_model,
165
+ max_tokens=max_tokens,
166
+ use_reasoning=spec.use_reasoning,
167
+ reasoning_override=reasoning_override,
168
+ ),
162
169
  tools=tools,
163
170
  instructions=instructions,
164
171
  markdown=True,
@@ -210,7 +217,11 @@ async def create_custom_agent_instance(agent_def: CustomAgent, session: Session,
210
217
 
211
218
  return Agent(
212
219
  name=agent_def.name,
213
- model=create_model(model_ref, max_tokens=max_tokens),
220
+ model=create_model(
221
+ model_ref,
222
+ max_tokens=max_tokens,
223
+ reasoning_override=session.reasoning_override,
224
+ ),
214
225
  tools=tools,
215
226
  instructions=instructions,
216
227
  markdown=True,
@@ -34,6 +34,7 @@ class AgentSpec:
34
34
  tools_factory: Callable[[], list] # lazy resolver — invoked at agent creation
35
35
  max_tokens: int | None
36
36
  small_model: bool = False # if True, factory uses ctx.small_model_ref
37
+ use_reasoning: bool = True # False skips thinking params (e.g. explorer)
37
38
 
38
39
 
39
40
  def _build_tools() -> list:
@@ -88,5 +89,6 @@ AGENTS: dict[str, AgentSpec] = {
88
89
  tools_factory=_explore_tools,
89
90
  max_tokens=8192,
90
91
  small_model=True,
92
+ use_reasoning=False, # fast read-only subagent — no thinking overhead
91
93
  ),
92
94
  }
@@ -43,6 +43,43 @@ _last_call_cache_write: int = 0
43
43
  # We normalize "length" → "max_tokens" so callers can check a single value.
44
44
  _last_call_stop_reason: str | None = None
45
45
 
46
+ # Micro-compaction metrics (process-wide, reset by tests via
47
+ # reset_microcompact_stats()). Recorded by _prune_tool_messages every time it
48
+ # fires from the format_function_call_results patch. Surfaced in /cost so
49
+ # users can see what the pre-API-call prune is actually doing — the basis
50
+ # for any future calibration of count/time-based triggers (Passos 3/4 of the
51
+ # plan, deferred until we have data here to justify them).
52
+ _microcompact_invocations: int = 0 # times _prune_tool_messages was called
53
+ _microcompact_clear_passes: int = 0 # times the prune actually cleared anything
54
+ _microcompact_results_cleared: int = 0 # cumulative tool_result blocks cleared
55
+
56
+ # Reactive overflow recovery: counts API calls where the provider rejected the
57
+ # request as too long and we wiped older tool_results then retried. Surfaced
58
+ # in /cost so users can tell when the recovery path is masking a chronically
59
+ # oversized context (suggests prune thresholds or model choice need attention).
60
+ _microcompact_overflow_recoveries: int = 0
61
+ # Aggressive prune keeps only the last N compactable tool_results, no matter
62
+ # the budget. Picked low because by definition we got here AFTER the regular
63
+ # prune (160K protect) failed to keep the context within model limits.
64
+ _OVERFLOW_RECOVERY_KEEP_RECENT = 3
65
+ # Substrings (case-insensitive) that mark a provider error as a context-too-long
66
+ # rejection. Anthropic / OpenAI / DashScope / DeepSeek / Groq all phrase it
67
+ # slightly differently; the union below covers the seen variants. Match is
68
+ # substring against str(exc) — wider than ideal, but the fallback path (no
69
+ # recovery) only kicks in when wrong, and a false positive at worst replays
70
+ # the same call after a no-op prune.
71
+ _OVERFLOW_ERROR_SIGNATURES = (
72
+ "prompt is too long",
73
+ "context length",
74
+ "context_length_exceeded",
75
+ "maximum context",
76
+ "exceeds the maximum",
77
+ "exceeds context",
78
+ "input is too long",
79
+ "too many tokens",
80
+ "request too large",
81
+ )
82
+
46
83
 
47
84
  def get_last_call_metrics() -> tuple[int, int, int, int]:
48
85
  """Return (input, output, cache_read, cache_write) from the most recent API call."""
@@ -68,6 +105,130 @@ def reset_last_stop_reason() -> None:
68
105
  _last_call_stop_reason = None
69
106
 
70
107
 
108
+ def get_microcompact_stats() -> dict:
109
+ """Return process-wide micro-compaction metrics.
110
+
111
+ Keys:
112
+ - invocations: total times _prune_tool_messages ran
113
+ - clear_passes: subset that actually cleared something
114
+ - results_cleared: cumulative tool_result blocks wiped
115
+
116
+ Used by /cost and tests. The ratio results_cleared/invocations is the
117
+ natural calibration signal for whether the budget-based trigger fires
118
+ often enough — if it's near zero across long sessions, the threshold
119
+ is too lax (or the protect window too generous).
120
+ """
121
+ return {
122
+ "invocations": _microcompact_invocations,
123
+ "clear_passes": _microcompact_clear_passes,
124
+ "results_cleared": _microcompact_results_cleared,
125
+ "overflow_recoveries": _microcompact_overflow_recoveries,
126
+ }
127
+
128
+
129
+ def reset_microcompact_stats() -> None:
130
+ """Zero the micro-compaction counters. Test-only helper."""
131
+ global _microcompact_invocations, _microcompact_clear_passes, _microcompact_results_cleared
132
+ global _microcompact_overflow_recoveries
133
+ _microcompact_invocations = 0
134
+ _microcompact_clear_passes = 0
135
+ _microcompact_results_cleared = 0
136
+ _microcompact_overflow_recoveries = 0
137
+
138
+
139
+ def _is_context_overflow_error(exc) -> bool:
140
+ """Return True iff `exc` looks like a provider context-too-long rejection.
141
+
142
+ Substring match (case-insensitive) against the str of the exception and any
143
+ nested `original_error` attribute. Wider than ideal but cheap; the recovery
144
+ path that consumes this is itself idempotent (re-running with no changes
145
+ after a no-op prune just hits the same error again and propagates).
146
+ """
147
+ msgs: list[str] = []
148
+ try:
149
+ msgs.append(str(exc))
150
+ except Exception:
151
+ pass
152
+ inner = getattr(exc, "original_error", None) or getattr(exc, "__cause__", None)
153
+ if inner is not None:
154
+ try:
155
+ msgs.append(str(inner))
156
+ except Exception:
157
+ pass
158
+ blob = " ".join(m.lower() for m in msgs if m)
159
+ return any(sig in blob for sig in _OVERFLOW_ERROR_SIGNATURES)
160
+
161
+
162
+ def _aggressive_prune(messages, keep_recent: int = _OVERFLOW_RECOVERY_KEEP_RECENT) -> int:
163
+ """Wipe content of all but the last `keep_recent` compactable tool_results.
164
+
165
+ Used reactively after a provider rejects a request as too long. Ignores the
166
+ budget walk entirely — by the time we get here, the budget-based prune
167
+ already failed to keep us under the model's context limit, so its answer
168
+ is wrong for this request.
169
+
170
+ Non-compactable tool_results (delegate_task etc.) are still preserved.
171
+ Returns the number of results actually cleared.
172
+ """
173
+ from aru.context import COMPACTABLE_TOOLS
174
+
175
+ id_to_name = _build_tool_id_to_name_map(messages)
176
+
177
+ # Collect compactable tool_result indices in encounter order.
178
+ compactable_indices: list[int] = []
179
+ for i, msg in enumerate(messages):
180
+ if getattr(msg, "role", None) != "tool":
181
+ continue
182
+ tc_id = getattr(msg, "tool_call_id", None)
183
+ tool_name = id_to_name.get(tc_id) if tc_id else None
184
+ if tool_name in COMPACTABLE_TOOLS:
185
+ compactable_indices.append(i)
186
+
187
+ if len(compactable_indices) <= keep_recent:
188
+ return 0
189
+
190
+ to_clear = compactable_indices[:-keep_recent] if keep_recent > 0 else compactable_indices
191
+ cleared = 0
192
+ for idx in to_clear:
193
+ msg = messages[idx]
194
+ content = getattr(msg, "content", None)
195
+ if content is None or str(content) == _PRUNED_PLACEHOLDER:
196
+ continue
197
+ try:
198
+ msg.content = _PRUNED_PLACEHOLDER
199
+ if hasattr(msg, "compressed_content"):
200
+ msg.compressed_content = None
201
+ cleared += 1
202
+ except (AttributeError, TypeError):
203
+ pass
204
+ return cleared
205
+
206
+
207
+ def _build_tool_id_to_name_map(messages) -> dict:
208
+ """Walk assistant messages forward, building tool_call_id → tool_name map.
209
+
210
+ Required because Agno's `role="tool"` Message carries `tool_call_id` but
211
+ not the originating tool name — the name lives on the matching
212
+ `assistant.tool_calls[i].function.name` in a previous message.
213
+ """
214
+ id_to_name: dict = {}
215
+ for msg in messages:
216
+ if getattr(msg, "role", None) != "assistant":
217
+ continue
218
+ tool_calls = getattr(msg, "tool_calls", None)
219
+ if not tool_calls:
220
+ continue
221
+ for tc in tool_calls:
222
+ tc_id = tc.get("id") if isinstance(tc, dict) else None
223
+ if not tc_id:
224
+ continue
225
+ fn = tc.get("function") if isinstance(tc, dict) else None
226
+ tc_name = fn.get("name") if isinstance(fn, dict) else None
227
+ if tc_name:
228
+ id_to_name[tc_id] = tc_name
229
+ return id_to_name
230
+
231
+
71
232
  def _prune_tool_messages(messages):
72
233
  """Clear old tool result content using a token-budget approach.
73
234
 
@@ -77,49 +238,81 @@ def _prune_tool_messages(messages):
77
238
  PRUNE_MINIMUM_CHARS (avoids unnecessary churn on small conversations).
78
239
 
79
240
  Aligned with OpenCode's strategy: budget-based, not fixed-N.
241
+
242
+ **Tool allowlist**: only outputs of tools in `COMPACTABLE_TOOLS` are
243
+ eligible for clearing. Non-compactable tools (delegate_task, invoke_skill,
244
+ tasklist mutators) still consume the protection budget but are never
245
+ pruned — their content is semantically load-bearing. The id→name map is
246
+ built from prior assistant `tool_calls` since `role="tool"` Messages carry
247
+ only the call id, not the tool name. Single source of truth lives in
248
+ `aru.context.COMPACTABLE_TOOLS`.
249
+
250
+ Returns the number of tool results actually cleared (0 if none) for
251
+ metrics consumption by `_microcompact_stats`.
80
252
  """
81
- # Collect tool message indices and their content sizes
82
- tool_indices = []
83
- for i, msg in enumerate(messages):
84
- if getattr(msg, "role", None) == "tool":
85
- content = getattr(msg, "content", None)
86
- content_len = len(str(content)) if content is not None else 0
87
- tool_indices.append((i, content_len))
253
+ from aru.context import COMPACTABLE_TOOLS
88
254
 
89
- if not tool_indices:
90
- return
255
+ global _microcompact_invocations, _microcompact_clear_passes, _microcompact_results_cleared
256
+ _microcompact_invocations += 1
91
257
 
92
- # Walk backwards, accumulating protected chars
93
- protected_chars = 0
94
- prune_candidates = [] # (index, content_len) of messages outside protection
258
+ id_to_name = _build_tool_id_to_name_map(messages)
95
259
 
96
- for idx, content_len in reversed(tool_indices):
97
- if protected_chars + content_len <= _PRUNE_PROTECT_CHARS:
98
- protected_chars += content_len
99
- else:
260
+ # Collect tool message indices, their content sizes, and compactability.
261
+ tool_entries = [] # (index, content_len, is_compactable)
262
+ for i, msg in enumerate(messages):
263
+ if getattr(msg, "role", None) != "tool":
264
+ continue
265
+ content = getattr(msg, "content", None)
266
+ content_len = len(str(content)) if content is not None else 0
267
+ tc_id = getattr(msg, "tool_call_id", None)
268
+ tool_name = id_to_name.get(tc_id) if tc_id else None
269
+ # Defensive: if we can't resolve the name, treat as non-compactable.
270
+ # Better to leak budget than wipe a delegate_task result by mistake.
271
+ is_compactable = tool_name in COMPACTABLE_TOOLS if tool_name else False
272
+ tool_entries.append((i, content_len, is_compactable))
273
+
274
+ if not tool_entries:
275
+ return 0
276
+
277
+ # Walk backwards. ALL tool content (compactable or not) consumes the
278
+ # protection budget — the prompt carries it either way. Once the budget
279
+ # is exhausted, older entries are prune candidates ONLY if compactable;
280
+ # non-compactable old entries (delegate_task etc.) stay untouched.
281
+ running_total = 0
282
+ prune_candidates = [] # (index, content_len) of compactable messages outside protection
283
+
284
+ for idx, content_len, is_compactable in reversed(tool_entries):
285
+ in_recent_window = (running_total + content_len) <= _PRUNE_PROTECT_CHARS
286
+ running_total += content_len
287
+ if not in_recent_window and is_compactable:
100
288
  prune_candidates.append((idx, content_len))
101
289
 
102
290
  # Only prune if there's enough to free
103
291
  freeable = sum(cl for _, cl in prune_candidates)
104
292
  if freeable < _PRUNE_MINIMUM_CHARS:
105
- return
293
+ return 0
106
294
 
107
- # Replace old tool results with placeholder
295
+ cleared = 0
108
296
  for idx, _ in prune_candidates:
109
297
  msg = messages[idx]
110
298
  content = getattr(msg, "content", None)
111
299
  if content is None:
112
300
  continue
113
- # Skip if already pruned
114
301
  if str(content) == _PRUNED_PLACEHOLDER:
115
302
  continue
116
303
  try:
117
304
  msg.content = _PRUNED_PLACEHOLDER
118
305
  if hasattr(msg, "compressed_content"):
119
306
  msg.compressed_content = None
307
+ cleared += 1
120
308
  except (AttributeError, TypeError):
121
309
  pass
122
310
 
311
+ if cleared:
312
+ _microcompact_clear_passes += 1
313
+ _microcompact_results_cleared += cleared
314
+ return cleared
315
+
123
316
 
124
317
  def apply_cache_patch():
125
318
  """Apply all patches to reduce Agno's token consumption."""
@@ -127,6 +320,73 @@ def apply_cache_patch():
127
320
  _patch_claude_cache_breakpoints()
128
321
  _patch_per_call_metrics()
129
322
  _patch_stop_reason_capture()
323
+ _patch_overflow_recovery()
324
+
325
+
326
+ def _patch_overflow_recovery():
327
+ """Wrap Agno's retry loops to handle context-overflow rejections.
328
+
329
+ When the provider rejects a request as too long (after the regular pre-call
330
+ prune was insufficient), wipe content of all but the last
331
+ `_OVERFLOW_RECOVERY_KEEP_RECENT` compactable tool_results in the message
332
+ list and re-raise. Agno's existing retry loop in `_a*invoke_with_retry`
333
+ will retry once with the now-shorter messages.
334
+
335
+ Patches both `_ainvoke_with_retry` (non-stream) and
336
+ `_ainvoke_stream_with_retry` (stream — what Aru's runner uses). Each is
337
+ wrapped to call `_aggressive_prune` once per turn before the underlying
338
+ retry fires; subsequent overflow errors propagate normally so we never
339
+ loop forever wiping the same messages.
340
+
341
+ A turn-scoped flag (`_overflow_recovery_done` set on the Model instance)
342
+ ensures we only attempt recovery once per call site — if even the
343
+ aggressive prune doesn't shrink the prompt enough, the error propagates
344
+ and the user sees it instead of a silent retry storm.
345
+ """
346
+ from agno.models.base import Model
347
+ from agno.exceptions import ModelProviderError
348
+
349
+ _orig_ainvoke = Model._ainvoke_with_retry
350
+ _orig_ainvoke_stream = Model._ainvoke_stream_with_retry
351
+
352
+ async def _patched_ainvoke_with_retry(self, **kwargs):
353
+ global _microcompact_overflow_recoveries
354
+ try:
355
+ return await _orig_ainvoke(self, **kwargs)
356
+ except ModelProviderError as e:
357
+ if not _is_context_overflow_error(e):
358
+ raise
359
+ messages = kwargs.get("messages")
360
+ if messages is None:
361
+ raise
362
+ cleared = _aggressive_prune(messages)
363
+ if cleared == 0:
364
+ raise
365
+ _microcompact_overflow_recoveries += 1
366
+ return await _orig_ainvoke(self, **kwargs)
367
+
368
+ async def _patched_ainvoke_stream_with_retry(self, **kwargs):
369
+ global _microcompact_overflow_recoveries
370
+ try:
371
+ async for response in _orig_ainvoke_stream(self, **kwargs):
372
+ yield response
373
+ return
374
+ except ModelProviderError as e:
375
+ if not _is_context_overflow_error(e):
376
+ raise
377
+ messages = kwargs.get("messages")
378
+ if messages is None:
379
+ raise
380
+ cleared = _aggressive_prune(messages)
381
+ if cleared == 0:
382
+ raise
383
+ _microcompact_overflow_recoveries += 1
384
+ # Retry once with the now-pruned messages. A second overflow propagates.
385
+ async for response in _orig_ainvoke_stream(self, **kwargs):
386
+ yield response
387
+
388
+ Model._ainvoke_with_retry = _patched_ainvoke_with_retry
389
+ Model._ainvoke_stream_with_retry = _patched_ainvoke_stream_with_retry
130
390
 
131
391
 
132
392
  def _patch_tool_result_pruning():
@@ -529,6 +529,30 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
529
529
  console.print(f"[yellow]Error: {e}[/yellow]")
530
530
  continue
531
531
 
532
+ if user_input == "/reasoning" or user_input.startswith("/reasoning "):
533
+ arg = user_input[len("/reasoning"):].strip().lower()
534
+ valid_efforts = {"low", "medium", "high", "max"}
535
+ if not arg:
536
+ current = session.reasoning_override or "[dim](config default)[/dim]"
537
+ console.print(f"[bold]Reasoning effort:[/bold] {current}")
538
+ console.print()
539
+ console.print("[dim]Usage:[/dim]")
540
+ console.print("[dim] /reasoning <low|medium|high|max> — override effort for this session[/dim]")
541
+ console.print("[dim] /reasoning off — disable thinking entirely[/dim]")
542
+ console.print("[dim] /reasoning clear — revert to provider/model config[/dim]")
543
+ elif arg in ("clear", "default", "none"):
544
+ session.reasoning_override = None
545
+ console.print("[bold green]Reasoning override cleared[/bold green] — using provider/model config.")
546
+ elif arg == "off":
547
+ session.reasoning_override = "off"
548
+ console.print("[bold yellow]Reasoning disabled[/bold yellow] for this session.")
549
+ elif arg in valid_efforts:
550
+ session.reasoning_override = arg
551
+ console.print(f"[bold green]Reasoning effort set to '{arg}'[/bold green] for this session.")
552
+ else:
553
+ console.print(f"[yellow]Unknown value '{arg}'. Use low/medium/high/max/off/clear.[/yellow]")
554
+ continue
555
+
532
556
  if user_input.lower() in ("/sessions", "/list"):
533
557
  sessions = store.list_sessions()
534
558
  if not sessions:
@@ -844,7 +868,7 @@ async def run_oneshot(prompt: str, print_only: bool = False, skip_permissions: b
844
868
 
845
869
  agent = Agent(
846
870
  name="Aru",
847
- model=create_model(session.model_ref), # None → provider cap
871
+ model=create_model(session.model_ref, reasoning_override=session.reasoning_override),
848
872
  tools=[],
849
873
  instructions=build_instructions("general", extra_instructions),
850
874
  markdown=True,
@@ -16,6 +16,7 @@ SLASH_COMMANDS = [
16
16
  ("/help", "Show help and available commands", "/help"),
17
17
  ("/plan", "Create an implementation plan", "/plan <task>"),
18
18
  ("/model", "Switch model/provider", "/model [provider/model]"),
19
+ ("/reasoning", "Set reasoning effort for this session", "/reasoning [low|medium|high|max|off|clear]"),
19
20
  ("/sessions", "List recent sessions", "/sessions"),
20
21
  ("/commands", "List custom commands", "/commands"),
21
22
  ("/skills", "List available skills", "/skills"),
@@ -38,6 +38,28 @@ PRUNE_PROTECTED_MARKERS = {"[SubAgent-", "delegate_task"}
38
38
  # These are checked as substrings in message content (tool results include the tool name)
39
39
  PRUNE_PROTECTED_TOOLS = {"delegate_task"}
40
40
 
41
+ # Tools whose outputs are safe to clear via mid-turn micro-compaction.
42
+ # Mirrors claude-code's COMPACTABLE_TOOLS in microCompact.ts: only outputs that
43
+ # the model can re-derive (file reads, shell commands, searches, fetches) or
44
+ # that have no semantic value once consumed (edit/write confirmations) are
45
+ # allowed to be wiped. Anything stateful or hard to reproduce —
46
+ # `delegate_task` (subagent reasoning), `invoke_skill` (skill body), tasklist
47
+ # tools (mutate session state), plan_mode toggles — must NOT appear here.
48
+ #
49
+ # Single source of truth: cache_patch._prune_tool_messages reads this list to
50
+ # decide which tool_result blocks are eligible for content-clearing during the
51
+ # pre-API-call prune pass.
52
+ COMPACTABLE_TOOLS = frozenset({
53
+ "read_file", "read_files",
54
+ "write_file", "write_files",
55
+ "edit_file", "edit_files",
56
+ "glob_search", "grep_search", "list_directory",
57
+ "bash", "run_command",
58
+ "web_search", "web_fetch",
59
+ "rank_files",
60
+ "get_project_tree",
61
+ })
62
+
41
63
  # Truncation: universal limits for any tool output
42
64
  TRUNCATE_MAX_LINES = 300
43
65
  TRUNCATE_MAX_BYTES = 15 * 1024 # 15 KB
@@ -889,7 +911,8 @@ async def compact_conversation(
889
911
  "if a file was central to the work (being debugged, actively edited, or referenced "
890
912
  "in a decision), include the critical lines verbatim; if a file was only briefly "
891
913
  "read for context, just list the path. Do not mechanically copy everything. "
892
- "Drop: greetings, reasoning chains, redundant tool output, transient status messages."
914
+ "Drop: greetings, reasoning chains from older turns, redundant tool output, transient status messages. "
915
+ "Preserve thinking/reasoning content from the most recent assistant turn if present."
893
916
  ),
894
917
  markdown=True,
895
918
  )