aru-code 0.30.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.
- {aru_code-0.30.0/aru_code.egg-info → aru_code-0.32.0}/PKG-INFO +1 -1
- aru_code-0.32.0/aru/__init__.py +1 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/agent_factory.py +42 -71
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/agents/catalog.py +2 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/cache_patch.py +279 -19
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/cli.py +30 -3
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/commands.py +1 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/context.py +98 -6
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/permissions.py +55 -10
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/plugins/hooks.py +1 -1
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/providers.py +214 -3
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/runner.py +4 -1
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/runtime.py +19 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/session.py +147 -25
- aru_code-0.32.0/aru/tool_policy.py +196 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/tools/skill.py +10 -4
- {aru_code-0.30.0 → aru_code-0.32.0/aru_code.egg-info}/PKG-INFO +1 -1
- {aru_code-0.30.0 → aru_code-0.32.0}/aru_code.egg-info/SOURCES.txt +6 -1
- {aru_code-0.30.0 → aru_code-0.32.0}/pyproject.toml +1 -1
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_context.py +49 -2
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_invoked_skills.py +60 -8
- aru_code-0.32.0/tests/test_microcompact.py +277 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_permissions.py +52 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_plugins.py +48 -0
- aru_code-0.32.0/tests/test_reasoning.py +455 -0
- aru_code-0.32.0/tests/test_runtime.py +40 -0
- aru_code-0.32.0/tests/test_skill_disallowed_tools.py +150 -0
- aru_code-0.32.0/tests/test_tool_policy.py +146 -0
- aru_code-0.30.0/aru/__init__.py +0 -1
- aru_code-0.30.0/tests/test_skill_disallowed_tools.py +0 -78
- {aru_code-0.30.0 → aru_code-0.32.0}/LICENSE +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/README.md +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/agents/__init__.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/agents/base.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/agents/planner.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/checkpoints.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/completers.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/config.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/display.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/history_blocks.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/plugin_cache.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/plugins/__init__.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/plugins/custom_tools.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/plugins/manager.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/plugins/tool_api.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/select.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/tools/__init__.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/tools/_diff.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/tools/_shared.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/tools/codebase.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/tools/delegate.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/tools/file_ops.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/tools/gitignore.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/tools/plan_mode.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/tools/ranker.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/tools/registry.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/tools/search.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/tools/shell.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/tools/tasklist.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru/tools/web.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/setup.cfg +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_agents_base.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_agents_md_coverage.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_cache_patch_metrics.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_cache_patch_stop_reason.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_catalog.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_checkpoints.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_cli.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_cli_advanced.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_cli_base.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_cli_completers.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_cli_new.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_cli_run_cli.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_cli_session.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_cli_shell.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_codebase.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_confabulation_regression.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_config.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_gitignore.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_guardrails_scenarios.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_invoke_skill.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_main.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_mcp_client.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_plan_mode_refactor.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_plugin_cache.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_providers.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_ranker.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_runner_recovery.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_select.py +0 -0
- {aru_code-0.30.0 → aru_code-0.32.0}/tests/test_tasklist.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.32.0"
|
|
@@ -29,32 +29,26 @@ async def _fire_hook(event_name: str, data: dict) -> dict:
|
|
|
29
29
|
return data
|
|
30
30
|
|
|
31
31
|
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
|
|
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
|
-
})
|
|
32
|
+
# Backward-compat re-export. The canonical list now lives in
|
|
33
|
+
# aru.tool_policy.PLAN_MODE_BLOCKED_TOOLS; external callers (tests,
|
|
34
|
+
# docs) that import it from here keep working.
|
|
35
|
+
from aru.tool_policy import PLAN_MODE_BLOCKED_TOOLS as _PLAN_MODE_BLOCKED_TOOLS
|
|
45
36
|
|
|
46
37
|
|
|
47
38
|
def _wrap_tools_with_hooks(tools: list) -> list:
|
|
48
|
-
"""Wrap tool functions
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
39
|
+
"""Wrap tool functions with a single tool-policy gate and plugin hooks.
|
|
40
|
+
|
|
41
|
+
The policy gate (plan mode + active-skill disallowed_tools) is
|
|
42
|
+
evaluated by `aru.tool_policy.evaluate_tool_policy` — a single
|
|
43
|
+
decision function shared with `aru.permissions.resolve_permission`,
|
|
44
|
+
so both the wrapper and per-tool permission checks see the same
|
|
45
|
+
answer. When a tool is denied by multiple rules at once, the policy
|
|
46
|
+
layer returns one combined BLOCKED message rather than two
|
|
47
|
+
sequential contradictory ones (this is the scenario-1 fix of the
|
|
48
|
+
combinatorial gate audit).
|
|
49
|
+
|
|
50
|
+
Plugin hooks run AFTER the policy gate so a plugin's
|
|
51
|
+
tool.execute.before hook cannot bypass plan-mode / skill rules.
|
|
58
52
|
"""
|
|
59
53
|
|
|
60
54
|
def _wrap_one(fn):
|
|
@@ -64,49 +58,13 @@ def _wrap_tools_with_hooks(tools: list) -> list:
|
|
|
64
58
|
@functools.wraps(fn)
|
|
65
59
|
async def wrapper(**kwargs):
|
|
66
60
|
tool_name = fn.__name__
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
)
|
|
84
|
-
# Active-skill disallowed-tools gate — honors the `disallowed-tools`
|
|
85
|
-
# frontmatter field of the currently active skill. Mirrors the
|
|
86
|
-
# plan-mode gate pattern above; runs before plugin hooks so a skill
|
|
87
|
-
# can hard-block a tool regardless of permission/plugin state.
|
|
88
|
-
try:
|
|
89
|
-
from aru.runtime import get_ctx
|
|
90
|
-
ctx = get_ctx()
|
|
91
|
-
session = getattr(ctx, "session", None)
|
|
92
|
-
config = getattr(ctx, "config", None)
|
|
93
|
-
except (LookupError, AttributeError):
|
|
94
|
-
session = None
|
|
95
|
-
config = None
|
|
96
|
-
if session is not None and config is not None:
|
|
97
|
-
active = getattr(session, "active_skill", None)
|
|
98
|
-
skills = getattr(config, "skills", None) or {}
|
|
99
|
-
active_skill_obj = skills.get(active) if active else None
|
|
100
|
-
disallowed = getattr(active_skill_obj, "disallowed_tools", None) or []
|
|
101
|
-
if tool_name in disallowed:
|
|
102
|
-
return (
|
|
103
|
-
f"BLOCKED: tool `{tool_name}` is disallowed by the "
|
|
104
|
-
f"currently active skill `{active}`. Read the skill's "
|
|
105
|
-
f"SKILL.md for the prescribed path. Do NOT retry "
|
|
106
|
-
f"`{tool_name}`; use the alternative the skill specifies "
|
|
107
|
-
f"(commonly: write the output to a `.md` file via "
|
|
108
|
-
f"`write_file` instead of using in-session state)."
|
|
109
|
-
)
|
|
61
|
+
# Unified policy gate — one function, one decision, one
|
|
62
|
+
# message on denial (combines plan-mode + skill rules when
|
|
63
|
+
# both apply).
|
|
64
|
+
from aru.tool_policy import evaluate_tool_policy
|
|
65
|
+
decision = evaluate_tool_policy(tool_name)
|
|
66
|
+
if not decision.allowed:
|
|
67
|
+
return decision.message
|
|
110
68
|
# Before hook — plugins can mutate args or raise PermissionError to block
|
|
111
69
|
try:
|
|
112
70
|
before_data = await _fire_hook("tool.execute.before", {
|
|
@@ -151,14 +109,16 @@ async def _apply_chat_hooks(instructions: str, model_ref: str, agent_name: str,
|
|
|
151
109
|
})
|
|
152
110
|
instructions = data.get("system_prompt", instructions)
|
|
153
111
|
|
|
154
|
-
# chat.params — plugins can modify LLM parameters
|
|
112
|
+
# chat.params — plugins can modify LLM parameters. max_tokens is
|
|
113
|
+
# deliberately NOT exposed: it is coupled with the recovery loop in
|
|
114
|
+
# runner.py and mutating it from a plugin can break mid-thought
|
|
115
|
+
# recovery. Plugins that need to bound output should do so via model
|
|
116
|
+
# selection or temperature, not raw token limits.
|
|
155
117
|
data = await _fire_hook("chat.params", {
|
|
156
118
|
"model": model_ref,
|
|
157
|
-
"max_tokens": max_tokens,
|
|
158
119
|
"temperature": None, # let plugin set if desired
|
|
159
120
|
})
|
|
160
121
|
model_ref = data.get("model", model_ref)
|
|
161
|
-
max_tokens = data.get("max_tokens", max_tokens)
|
|
162
122
|
|
|
163
123
|
return instructions, model_ref, max_tokens
|
|
164
124
|
|
|
@@ -196,9 +156,16 @@ async def create_agent_from_spec(
|
|
|
196
156
|
instructions, resolved_model, spec.name, max_tokens=spec.max_tokens,
|
|
197
157
|
)
|
|
198
158
|
|
|
159
|
+
reasoning_override = session.reasoning_override if session is not None else None
|
|
160
|
+
|
|
199
161
|
return Agent(
|
|
200
162
|
name=spec.name,
|
|
201
|
-
model=create_model(
|
|
163
|
+
model=create_model(
|
|
164
|
+
resolved_model,
|
|
165
|
+
max_tokens=max_tokens,
|
|
166
|
+
use_reasoning=spec.use_reasoning,
|
|
167
|
+
reasoning_override=reasoning_override,
|
|
168
|
+
),
|
|
202
169
|
tools=tools,
|
|
203
170
|
instructions=instructions,
|
|
204
171
|
markdown=True,
|
|
@@ -250,7 +217,11 @@ async def create_custom_agent_instance(agent_def: CustomAgent, session: Session,
|
|
|
250
217
|
|
|
251
218
|
return Agent(
|
|
252
219
|
name=agent_def.name,
|
|
253
|
-
model=create_model(
|
|
220
|
+
model=create_model(
|
|
221
|
+
model_ref,
|
|
222
|
+
max_tokens=max_tokens,
|
|
223
|
+
reasoning_override=session.reasoning_override,
|
|
224
|
+
),
|
|
254
225
|
tools=tools,
|
|
255
226
|
instructions=instructions,
|
|
256
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
255
|
+
global _microcompact_invocations, _microcompact_clear_passes, _microcompact_results_cleared
|
|
256
|
+
_microcompact_invocations += 1
|
|
91
257
|
|
|
92
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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:
|
|
@@ -711,13 +735,16 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
711
735
|
if not skill.user_invocable:
|
|
712
736
|
console.print(f"[yellow]Skill '{cmd_name}' is not user-invocable[/yellow]")
|
|
713
737
|
else:
|
|
714
|
-
|
|
738
|
+
# Slash-invoked skills always run under the primary agent
|
|
739
|
+
# scope (agent_id=None). Subagents reach skills via the
|
|
740
|
+
# invoke_skill tool, which keys by ctx.agent_id instead.
|
|
741
|
+
session.set_active_skill(None, cmd_name)
|
|
715
742
|
prompt = render_skill_template(skill.content, cmd_args)
|
|
716
743
|
# Record so the skill body survives compaction — mirror of
|
|
717
744
|
# claude-code's addInvokedSkill. Store the rendered content
|
|
718
745
|
# (post-argument substitution) so post-compact restoration
|
|
719
746
|
# matches what the model initially read.
|
|
720
|
-
session.record_invoked_skill(cmd_name, prompt, skill.source_path)
|
|
747
|
+
session.record_invoked_skill(cmd_name, prompt, skill.source_path, agent_id=None)
|
|
721
748
|
console.print(f"[bold magenta]Running skill /{cmd_name}...[/bold magenta]")
|
|
722
749
|
|
|
723
750
|
agent = await create_general_agent(session, config, env_context=_build_env_ctx())
|
|
@@ -841,7 +868,7 @@ async def run_oneshot(prompt: str, print_only: bool = False, skip_permissions: b
|
|
|
841
868
|
|
|
842
869
|
agent = Agent(
|
|
843
870
|
name="Aru",
|
|
844
|
-
model=create_model(session.model_ref),
|
|
871
|
+
model=create_model(session.model_ref, reasoning_override=session.reasoning_override),
|
|
845
872
|
tools=[],
|
|
846
873
|
instructions=build_instructions("general", extra_instructions),
|
|
847
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"),
|