aru-code 0.27.0__tar.gz → 0.30.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.27.0/aru_code.egg-info → aru_code-0.30.0}/PKG-INFO +1 -1
- aru_code-0.30.0/aru/__init__.py +1 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/agent_factory.py +30 -3
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/agents/catalog.py +12 -4
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/cache_patch.py +122 -1
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/cli.py +68 -3
- aru_code-0.30.0/aru/commands.py +245 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/config.py +27 -1
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/context.py +130 -3
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/display.py +1 -1
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/permissions.py +7 -3
- aru_code-0.30.0/aru/plugin_cache.py +618 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/plugins/custom_tools.py +9 -1
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/plugins/manager.py +9 -1
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/providers.py +47 -12
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/runner.py +258 -126
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/runtime.py +5 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/session.py +82 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/registry.py +7 -1
- aru_code-0.30.0/aru/tools/skill.py +166 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/tasklist.py +13 -8
- {aru_code-0.27.0 → aru_code-0.30.0/aru_code.egg-info}/PKG-INFO +1 -1
- {aru_code-0.27.0 → aru_code-0.30.0}/aru_code.egg-info/SOURCES.txt +10 -1
- {aru_code-0.27.0 → aru_code-0.30.0}/pyproject.toml +1 -1
- aru_code-0.30.0/tests/test_cache_patch_stop_reason.py +108 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_catalog.py +6 -3
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_config.py +16 -0
- aru_code-0.30.0/tests/test_invoke_skill.py +354 -0
- aru_code-0.30.0/tests/test_invoked_skills.py +321 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_permissions.py +74 -0
- aru_code-0.30.0/tests/test_plugin_cache.py +354 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_providers.py +19 -1
- aru_code-0.30.0/tests/test_runner_recovery.py +132 -0
- aru_code-0.30.0/tests/test_skill_disallowed_tools.py +78 -0
- aru_code-0.30.0/tests/test_tasklist.py +117 -0
- aru_code-0.27.0/aru/__init__.py +0 -1
- aru_code-0.27.0/aru/commands.py +0 -105
- {aru_code-0.27.0 → aru_code-0.30.0}/LICENSE +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/README.md +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/agents/__init__.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/agents/base.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/agents/planner.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/checkpoints.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/completers.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/history_blocks.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/plugins/__init__.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/plugins/hooks.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/plugins/tool_api.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/select.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/__init__.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/_diff.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/_shared.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/codebase.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/delegate.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/file_ops.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/gitignore.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/plan_mode.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/ranker.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/search.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/shell.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru/tools/web.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/setup.cfg +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_agents_base.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_agents_md_coverage.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_cache_patch_metrics.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_checkpoints.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_cli.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_cli_advanced.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_cli_base.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_cli_completers.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_cli_new.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_cli_run_cli.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_cli_session.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_cli_shell.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_codebase.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_confabulation_regression.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_context.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_gitignore.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_guardrails_scenarios.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_main.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_mcp_client.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_plan_mode_refactor.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_plugins.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_ranker.py +0 -0
- {aru_code-0.27.0 → aru_code-0.30.0}/tests/test_select.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.30.0"
|
|
@@ -81,6 +81,32 @@ def _wrap_tools_with_hooks(tools: list) -> list:
|
|
|
81
81
|
f"exit_plan_mode(plan=<full plan text>) to request "
|
|
82
82
|
f"approval. Do NOT retry {tool_name}."
|
|
83
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
|
+
)
|
|
84
110
|
# Before hook — plugins can mutate args or raise PermissionError to block
|
|
85
111
|
try:
|
|
86
112
|
before_data = await _fire_hook("tool.execute.before", {
|
|
@@ -112,10 +138,11 @@ def _wrap_tools_with_hooks(tools: list) -> list:
|
|
|
112
138
|
|
|
113
139
|
|
|
114
140
|
async def _apply_chat_hooks(instructions: str, model_ref: str, agent_name: str,
|
|
115
|
-
max_tokens: int =
|
|
141
|
+
max_tokens: int | None = None) -> tuple[str, str, int | None]:
|
|
116
142
|
"""Apply chat.system.transform and chat.params hooks to agent creation params.
|
|
117
143
|
|
|
118
144
|
Returns (instructions, model_ref, max_tokens) — possibly modified by plugins.
|
|
145
|
+
When max_tokens is None, providers.create_model will use the model's full cap.
|
|
119
146
|
"""
|
|
120
147
|
# chat.system.transform — plugins can modify the system prompt
|
|
121
148
|
data = await _fire_hook("chat.system.transform", {
|
|
@@ -216,9 +243,9 @@ async def create_custom_agent_instance(agent_def: CustomAgent, session: Session,
|
|
|
216
243
|
parts.append(extra)
|
|
217
244
|
instructions = "\n\n".join(parts)
|
|
218
245
|
|
|
219
|
-
# Apply chat hooks (system.transform + params)
|
|
246
|
+
# Apply chat hooks (system.transform + params). max_tokens=None → provider cap.
|
|
220
247
|
instructions, model_ref, max_tokens = await _apply_chat_hooks(
|
|
221
|
-
instructions, model_ref, agent_def.name, max_tokens=
|
|
248
|
+
instructions, model_ref, agent_def.name, max_tokens=None,
|
|
222
249
|
)
|
|
223
250
|
|
|
224
251
|
return Agent(
|
|
@@ -21,13 +21,18 @@ class AgentSpec:
|
|
|
21
21
|
|
|
22
22
|
The tools_factory is a lazy callable so module load order does not force
|
|
23
23
|
aru.tools.codebase to be imported before this module.
|
|
24
|
+
|
|
25
|
+
`max_tokens=None` means "use the model's full cap" (see providers.py).
|
|
26
|
+
An explicit int caps the agent below that ceiling — providers.py always
|
|
27
|
+
clamps the final value to min(requested, model_cap) so specs can never
|
|
28
|
+
ask for more than the model supports.
|
|
24
29
|
"""
|
|
25
30
|
|
|
26
31
|
name: str # display name passed to Agno
|
|
27
32
|
role: str # key into build_instructions(role, ...)
|
|
28
33
|
mode: Literal["primary", "subagent"]
|
|
29
34
|
tools_factory: Callable[[], list] # lazy resolver — invoked at agent creation
|
|
30
|
-
max_tokens: int
|
|
35
|
+
max_tokens: int | None
|
|
31
36
|
small_model: bool = False # if True, factory uses ctx.small_model_ref
|
|
32
37
|
|
|
33
38
|
|
|
@@ -52,12 +57,15 @@ def _explore_tools() -> list:
|
|
|
52
57
|
|
|
53
58
|
|
|
54
59
|
AGENTS: dict[str, AgentSpec] = {
|
|
60
|
+
# Primary agents default to the model's full output cap (clamped by
|
|
61
|
+
# providers.create_model). Subagents keep a tight budget so a runaway
|
|
62
|
+
# explorer can't blow through the whole turn.
|
|
55
63
|
"build": AgentSpec(
|
|
56
64
|
name="Aru",
|
|
57
65
|
role="general",
|
|
58
66
|
mode="primary",
|
|
59
67
|
tools_factory=_build_tools,
|
|
60
|
-
max_tokens=
|
|
68
|
+
max_tokens=None,
|
|
61
69
|
),
|
|
62
70
|
"plan": AgentSpec(
|
|
63
71
|
name="Planner",
|
|
@@ -71,14 +79,14 @@ AGENTS: dict[str, AgentSpec] = {
|
|
|
71
79
|
role="executor",
|
|
72
80
|
mode="primary",
|
|
73
81
|
tools_factory=_exec_tools,
|
|
74
|
-
max_tokens=
|
|
82
|
+
max_tokens=None,
|
|
75
83
|
),
|
|
76
84
|
"explorer": AgentSpec(
|
|
77
85
|
name="Explorer",
|
|
78
86
|
role="explorer",
|
|
79
87
|
mode="subagent",
|
|
80
88
|
tools_factory=_explore_tools,
|
|
81
|
-
max_tokens=
|
|
89
|
+
max_tokens=8192,
|
|
82
90
|
small_model=True,
|
|
83
91
|
),
|
|
84
92
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Monkey-patch Agno's model layer to reduce token consumption.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Four optimizations:
|
|
4
4
|
|
|
5
5
|
1. **Tool result pruning** (ALL providers): After each tool execution, old tool
|
|
6
6
|
results in the message list are truncated to a short summary. This prevents
|
|
@@ -12,6 +12,11 @@ Three optimizations:
|
|
|
12
12
|
3. **Per-call metrics** (ALL providers): Captures input/output tokens of the
|
|
13
13
|
last API call (context window size), exposed via get_last_call_metrics().
|
|
14
14
|
|
|
15
|
+
4. **Stop-reason capture** (Anthropic + OpenAI-compatible): Captures the
|
|
16
|
+
`stop_reason` / `finish_reason` from the final message of the last API call,
|
|
17
|
+
exposed via get_last_stop_reason(). Lets the runner detect `max_tokens`
|
|
18
|
+
truncation and trigger the recovery loop.
|
|
19
|
+
|
|
15
20
|
These patches intercept Agno's internal loop so they work transparently
|
|
16
21
|
regardless of which provider is used.
|
|
17
22
|
"""
|
|
@@ -33,12 +38,36 @@ _last_call_output_tokens: int = 0
|
|
|
33
38
|
_last_call_cache_read: int = 0
|
|
34
39
|
_last_call_cache_write: int = 0
|
|
35
40
|
|
|
41
|
+
# Last API call stop reason (Anthropic uses "end_turn"/"tool_use"/"max_tokens"/
|
|
42
|
+
# "stop_sequence"/"pause_turn"; OpenAI uses "stop"/"length"/"tool_calls").
|
|
43
|
+
# We normalize "length" → "max_tokens" so callers can check a single value.
|
|
44
|
+
_last_call_stop_reason: str | None = None
|
|
45
|
+
|
|
36
46
|
|
|
37
47
|
def get_last_call_metrics() -> tuple[int, int, int, int]:
|
|
38
48
|
"""Return (input, output, cache_read, cache_write) from the most recent API call."""
|
|
39
49
|
return _last_call_input_tokens, _last_call_output_tokens, _last_call_cache_read, _last_call_cache_write
|
|
40
50
|
|
|
41
51
|
|
|
52
|
+
def get_last_stop_reason() -> str | None:
|
|
53
|
+
"""Return the stop reason from the most recent API call, normalized.
|
|
54
|
+
|
|
55
|
+
Returns one of: `end_turn`, `tool_use`, `max_tokens`, `stop_sequence`,
|
|
56
|
+
`pause_turn`, or None if no call has happened yet / the provider did not
|
|
57
|
+
expose one. OpenAI's `length` is mapped to `max_tokens` and `stop` to
|
|
58
|
+
`end_turn` so callers have a single vocabulary.
|
|
59
|
+
"""
|
|
60
|
+
return _last_call_stop_reason
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def reset_last_stop_reason() -> None:
|
|
64
|
+
"""Clear the cached stop reason — call before starting a new turn so a
|
|
65
|
+
stale value from a prior turn never leaks into the next one.
|
|
66
|
+
"""
|
|
67
|
+
global _last_call_stop_reason
|
|
68
|
+
_last_call_stop_reason = None
|
|
69
|
+
|
|
70
|
+
|
|
42
71
|
def _prune_tool_messages(messages):
|
|
43
72
|
"""Clear old tool result content using a token-budget approach.
|
|
44
73
|
|
|
@@ -97,6 +126,7 @@ def apply_cache_patch():
|
|
|
97
126
|
_patch_tool_result_pruning()
|
|
98
127
|
_patch_claude_cache_breakpoints()
|
|
99
128
|
_patch_per_call_metrics()
|
|
129
|
+
_patch_stop_reason_capture()
|
|
100
130
|
|
|
101
131
|
|
|
102
132
|
def _patch_tool_result_pruning():
|
|
@@ -235,3 +265,94 @@ def _patch_per_call_metrics():
|
|
|
235
265
|
_base_module.accumulate_model_metrics = _patched_accumulate
|
|
236
266
|
except (ImportError, AttributeError):
|
|
237
267
|
pass
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# OpenAI "length" and Anthropic "max_tokens" mean the same thing; normalize so
|
|
271
|
+
# runner logic can check a single value.
|
|
272
|
+
_STOP_REASON_NORMALIZE = {
|
|
273
|
+
"length": "max_tokens", # OpenAI
|
|
274
|
+
"stop": "end_turn", # OpenAI
|
|
275
|
+
"tool_calls": "tool_use", # OpenAI
|
|
276
|
+
"function_call": "tool_use", # legacy OpenAI
|
|
277
|
+
"MAX_TOKENS": "max_tokens", # Gemini (all-caps)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _record_stop_reason(raw: str | None) -> None:
|
|
282
|
+
"""Normalize and cache the provider's stop reason."""
|
|
283
|
+
global _last_call_stop_reason
|
|
284
|
+
if raw is None or raw == "":
|
|
285
|
+
return
|
|
286
|
+
_last_call_stop_reason = _STOP_REASON_NORMALIZE.get(raw, raw)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _patch_stop_reason_capture():
|
|
290
|
+
"""Forward `stop_reason` from Agno's provider parsers into a module-level
|
|
291
|
+
slot readable via `get_last_stop_reason()`.
|
|
292
|
+
|
|
293
|
+
Agno's Anthropic adapter sees `response.stop_reason` (non-streaming) and
|
|
294
|
+
`response.message.stop_reason` (streaming MessageStopEvent), but discards
|
|
295
|
+
both before anything downstream can observe them. We wrap the two parsers
|
|
296
|
+
and record the value as a side effect. The OpenAI-compatible adapter
|
|
297
|
+
already exposes `response.choices[0].finish_reason`, so we hook that too
|
|
298
|
+
for completeness (Qwen, DeepSeek, Groq, OpenRouter).
|
|
299
|
+
"""
|
|
300
|
+
# Anthropic (native + streaming)
|
|
301
|
+
try:
|
|
302
|
+
from agno.models.anthropic import claude as _claude_mod
|
|
303
|
+
|
|
304
|
+
_original_parse = _claude_mod.Claude._parse_provider_response
|
|
305
|
+
_original_parse_delta = _claude_mod.Claude._parse_provider_response_delta
|
|
306
|
+
|
|
307
|
+
def _patched_parse(self, response, *args, **kwargs):
|
|
308
|
+
result = _original_parse(self, response, *args, **kwargs)
|
|
309
|
+
_record_stop_reason(getattr(response, "stop_reason", None))
|
|
310
|
+
return result
|
|
311
|
+
|
|
312
|
+
def _patched_parse_delta(self, response, *args, **kwargs):
|
|
313
|
+
result = _original_parse_delta(self, response, *args, **kwargs)
|
|
314
|
+
# MessageStopEvent / ParsedBetaMessageStopEvent carry the final
|
|
315
|
+
# stop_reason on their nested `message` object.
|
|
316
|
+
msg = getattr(response, "message", None)
|
|
317
|
+
if msg is not None:
|
|
318
|
+
_record_stop_reason(getattr(msg, "stop_reason", None))
|
|
319
|
+
return result
|
|
320
|
+
|
|
321
|
+
_claude_mod.Claude._parse_provider_response = _patched_parse
|
|
322
|
+
_claude_mod.Claude._parse_provider_response_delta = _patched_parse_delta
|
|
323
|
+
except (ImportError, AttributeError):
|
|
324
|
+
pass
|
|
325
|
+
|
|
326
|
+
# OpenAI-compatible (OpenAI, Qwen/DashScope, DeepSeek, Groq, OpenRouter)
|
|
327
|
+
try:
|
|
328
|
+
from agno.models.openai import chat as _openai_chat
|
|
329
|
+
|
|
330
|
+
_original_openai_parse = _openai_chat.OpenAIChat._parse_provider_response
|
|
331
|
+
|
|
332
|
+
def _patched_openai_parse(self, response, *args, **kwargs):
|
|
333
|
+
result = _original_openai_parse(self, response, *args, **kwargs)
|
|
334
|
+
try:
|
|
335
|
+
choice = response.choices[0]
|
|
336
|
+
_record_stop_reason(getattr(choice, "finish_reason", None))
|
|
337
|
+
except (AttributeError, IndexError, TypeError):
|
|
338
|
+
pass
|
|
339
|
+
return result
|
|
340
|
+
|
|
341
|
+
_openai_chat.OpenAIChat._parse_provider_response = _patched_openai_parse
|
|
342
|
+
|
|
343
|
+
if hasattr(_openai_chat.OpenAIChat, "_parse_provider_response_delta"):
|
|
344
|
+
_original_openai_delta = _openai_chat.OpenAIChat._parse_provider_response_delta
|
|
345
|
+
|
|
346
|
+
def _patched_openai_delta(self, response, *args, **kwargs):
|
|
347
|
+
result = _original_openai_delta(self, response, *args, **kwargs)
|
|
348
|
+
try:
|
|
349
|
+
choice = response.choices[0]
|
|
350
|
+
# Only the final chunk sets finish_reason.
|
|
351
|
+
_record_stop_reason(getattr(choice, "finish_reason", None))
|
|
352
|
+
except (AttributeError, IndexError, TypeError):
|
|
353
|
+
pass
|
|
354
|
+
return result
|
|
355
|
+
|
|
356
|
+
_openai_chat.OpenAIChat._parse_provider_response_delta = _patched_openai_delta
|
|
357
|
+
except (ImportError, AttributeError):
|
|
358
|
+
pass
|
|
@@ -15,6 +15,7 @@ import sys
|
|
|
15
15
|
|
|
16
16
|
from rich.markdown import Markdown
|
|
17
17
|
from rich.panel import Panel
|
|
18
|
+
from rich.text import Text
|
|
18
19
|
|
|
19
20
|
# ── Re-exports for backward compatibility ─────────────────────────────
|
|
20
21
|
# Tests and external code import these from aru.cli; keep them accessible.
|
|
@@ -92,7 +93,7 @@ _logging.getLogger("agno").setLevel(_logging.WARNING)
|
|
|
92
93
|
|
|
93
94
|
from aru.agents.planner import review_plan
|
|
94
95
|
from aru.config import load_config, render_command_template, render_skill_template
|
|
95
|
-
from aru.permissions import get_skip_permissions
|
|
96
|
+
from aru.permissions import get_skip_permissions, set_permission_mode
|
|
96
97
|
from aru.providers import (
|
|
97
98
|
MODEL_ALIASES,
|
|
98
99
|
list_providers,
|
|
@@ -100,6 +101,39 @@ from aru.providers import (
|
|
|
100
101
|
)
|
|
101
102
|
|
|
102
103
|
|
|
104
|
+
def _toggle_yolo_mode(ctx) -> None:
|
|
105
|
+
"""Toggle YOLO (dangerously-skip-permissions) mode from the REPL.
|
|
106
|
+
|
|
107
|
+
Turning YOLO *off* is unconditional — safety is not at risk.
|
|
108
|
+
Turning YOLO *on* requires an explicit y/n confirmation with a red warning panel.
|
|
109
|
+
"""
|
|
110
|
+
if ctx.permission_mode == "yolo":
|
|
111
|
+
set_permission_mode("default")
|
|
112
|
+
console.print("[bold green]✔ YOLO disabled — safe mode restored.[/bold green]")
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
warning = Text.from_markup(
|
|
116
|
+
"[bold red]⚠ DANGEROUSLY SKIP PERMISSIONS (YOLO)[/bold red]\n\n"
|
|
117
|
+
"[red]All permission prompts will be bypassed for this session, including:[/red]\n"
|
|
118
|
+
" • Reading/writing [bold].env[/bold] files and other sensitive paths\n"
|
|
119
|
+
" • Arbitrary shell commands ([bold]rm -rf[/bold], package installs, network calls)\n"
|
|
120
|
+
" • Edits outside the working directory\n"
|
|
121
|
+
" • All sub-agents delegated during this session\n\n"
|
|
122
|
+
"[dim]Toggle off anytime with /yolo or shift+tab.[/dim]"
|
|
123
|
+
)
|
|
124
|
+
console.print(Panel(
|
|
125
|
+
warning,
|
|
126
|
+
title="[bold red]Enable YOLO mode?[/bold red]",
|
|
127
|
+
border_style="red",
|
|
128
|
+
padding=(1, 2),
|
|
129
|
+
))
|
|
130
|
+
if ask_yes_no("Confirm enabling YOLO mode"):
|
|
131
|
+
set_permission_mode("yolo")
|
|
132
|
+
console.print("[bold red]🔥 YOLO MODE ACTIVE — all permissions bypassed.[/bold red]")
|
|
133
|
+
else:
|
|
134
|
+
console.print("[dim]Cancelled. Remaining in safe mode.[/dim]")
|
|
135
|
+
|
|
136
|
+
|
|
103
137
|
# ── Main REPL ──────────────────────────────────────────────────────────
|
|
104
138
|
|
|
105
139
|
async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
@@ -136,6 +170,11 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
136
170
|
|
|
137
171
|
# Load project configuration
|
|
138
172
|
config = load_config()
|
|
173
|
+
ctx.config = config
|
|
174
|
+
# Populate invoke_skill's dynamic docstring so the LLM-facing schema lists
|
|
175
|
+
# the skills actually available on this machine.
|
|
176
|
+
from aru.tools.skill import _update_invoke_skill_docstring
|
|
177
|
+
_update_invoke_skill_docstring(config.skills)
|
|
139
178
|
if config.agents_md:
|
|
140
179
|
console.print("[dim]Loaded AGENTS.md[/dim]")
|
|
141
180
|
if config.commands:
|
|
@@ -283,7 +322,13 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
283
322
|
f' <style fg="ansigray">│</style>'
|
|
284
323
|
f' <style fg="ansigray">{ctx.mcp_loaded_msg}</style>'
|
|
285
324
|
)
|
|
286
|
-
if ctx.permission_mode == "
|
|
325
|
+
if ctx.permission_mode == "yolo":
|
|
326
|
+
mode_part = (
|
|
327
|
+
f' <style fg="ansigray">│</style>'
|
|
328
|
+
f' <b><style fg="ansired">🔥 YOLO — permissions bypassed</style></b>'
|
|
329
|
+
f' <style fg="ansigray">(/yolo to toggle)</style>'
|
|
330
|
+
)
|
|
331
|
+
elif ctx.permission_mode == "acceptEdits":
|
|
287
332
|
mode_part = (
|
|
288
333
|
f' <style fg="ansigray">│</style>'
|
|
289
334
|
f' <b><style fg="ansigreen">⏵⏵ auto-accept edits on</style></b>'
|
|
@@ -546,6 +591,12 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
546
591
|
console.print(f" [bold cyan]{entry.name}[/bold cyan] [dim]{entry.description}[/dim]")
|
|
547
592
|
continue
|
|
548
593
|
|
|
594
|
+
if user_input.lower() == "/plugin" or user_input.lower().startswith("/plugin "):
|
|
595
|
+
from aru.commands import handle_plugin_command
|
|
596
|
+
rest = user_input[len("/plugin"):].strip()
|
|
597
|
+
handle_plugin_command(rest)
|
|
598
|
+
continue
|
|
599
|
+
|
|
549
600
|
if user_input.lower() == "/help":
|
|
550
601
|
_show_help(config)
|
|
551
602
|
continue
|
|
@@ -559,6 +610,10 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
559
610
|
))
|
|
560
611
|
continue
|
|
561
612
|
|
|
613
|
+
if user_input.lower() in ("/yolo", "/unsafe"):
|
|
614
|
+
_toggle_yolo_mode(ctx)
|
|
615
|
+
continue
|
|
616
|
+
|
|
562
617
|
# Begin a new checkpoint turn for undo support
|
|
563
618
|
_turn_counter += 1
|
|
564
619
|
ctx.checkpoint_manager.begin_turn(_turn_counter)
|
|
@@ -656,7 +711,13 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
656
711
|
if not skill.user_invocable:
|
|
657
712
|
console.print(f"[yellow]Skill '{cmd_name}' is not user-invocable[/yellow]")
|
|
658
713
|
else:
|
|
714
|
+
session.active_skill = cmd_name
|
|
659
715
|
prompt = render_skill_template(skill.content, cmd_args)
|
|
716
|
+
# Record so the skill body survives compaction — mirror of
|
|
717
|
+
# claude-code's addInvokedSkill. Store the rendered content
|
|
718
|
+
# (post-argument substitution) so post-compact restoration
|
|
719
|
+
# matches what the model initially read.
|
|
720
|
+
session.record_invoked_skill(cmd_name, prompt, skill.source_path)
|
|
660
721
|
console.print(f"[bold magenta]Running skill /{cmd_name}...[/bold magenta]")
|
|
661
722
|
|
|
662
723
|
agent = await create_general_agent(session, config, env_context=_build_env_ctx())
|
|
@@ -746,6 +807,10 @@ async def run_oneshot(prompt: str, print_only: bool = False, skip_permissions: b
|
|
|
746
807
|
ctx = init_ctx(console=console, skip_permissions=skip_permissions)
|
|
747
808
|
|
|
748
809
|
config = load_config()
|
|
810
|
+
ctx.config = config
|
|
811
|
+
# Populate invoke_skill's dynamic docstring (same as interactive path)
|
|
812
|
+
from aru.tools.skill import _update_invoke_skill_docstring
|
|
813
|
+
_update_invoke_skill_docstring(config.skills)
|
|
749
814
|
session = Session()
|
|
750
815
|
if config.default_model:
|
|
751
816
|
session.model_ref = config.default_model
|
|
@@ -776,7 +841,7 @@ async def run_oneshot(prompt: str, print_only: bool = False, skip_permissions: b
|
|
|
776
841
|
|
|
777
842
|
agent = Agent(
|
|
778
843
|
name="Aru",
|
|
779
|
-
model=create_model(session.model_ref,
|
|
844
|
+
model=create_model(session.model_ref), # None → provider cap
|
|
780
845
|
tools=[],
|
|
781
846
|
instructions=build_instructions("general", extra_instructions),
|
|
782
847
|
markdown=True,
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Slash command definitions, help display, shell execution, and user prompts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.syntax import Syntax
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from aru.display import console
|
|
14
|
+
|
|
15
|
+
SLASH_COMMANDS = [
|
|
16
|
+
("/help", "Show help and available commands", "/help"),
|
|
17
|
+
("/plan", "Create an implementation plan", "/plan <task>"),
|
|
18
|
+
("/model", "Switch model/provider", "/model [provider/model]"),
|
|
19
|
+
("/sessions", "List recent sessions", "/sessions"),
|
|
20
|
+
("/commands", "List custom commands", "/commands"),
|
|
21
|
+
("/skills", "List available skills", "/skills"),
|
|
22
|
+
("/agents", "List custom agents", "/agents"),
|
|
23
|
+
("/mcp", "List loaded MCP tools", "/mcp"),
|
|
24
|
+
("/plugin", "Manage cached plugins (install/list/remove/update)", "/plugin <subcommand>"),
|
|
25
|
+
("/undo", "Undo last turn — restore files and/or conversation", "/undo"),
|
|
26
|
+
("/cost", "Show detailed token usage and cost", "/cost"),
|
|
27
|
+
("/yolo", "Toggle DANGEROUSLY skip all permissions (YOLO mode)", "/yolo"),
|
|
28
|
+
("/quit", "Exit aru", "/quit"),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_shell(command: str):
|
|
33
|
+
"""Run a shell command directly, streaming output to the terminal."""
|
|
34
|
+
console.print()
|
|
35
|
+
console.print(Panel(
|
|
36
|
+
Syntax(command, "bash", theme="monokai"),
|
|
37
|
+
title="[bold]Shell[/bold]",
|
|
38
|
+
border_style="dim",
|
|
39
|
+
expand=False,
|
|
40
|
+
))
|
|
41
|
+
try:
|
|
42
|
+
process = subprocess.Popen(
|
|
43
|
+
command,
|
|
44
|
+
shell=True,
|
|
45
|
+
stdout=subprocess.PIPE,
|
|
46
|
+
stderr=subprocess.STDOUT,
|
|
47
|
+
text=True,
|
|
48
|
+
cwd=os.getcwd(),
|
|
49
|
+
bufsize=1,
|
|
50
|
+
)
|
|
51
|
+
for line in process.stdout:
|
|
52
|
+
console.print(Text(line.rstrip()))
|
|
53
|
+
process.wait()
|
|
54
|
+
if process.returncode != 0:
|
|
55
|
+
console.print(f"[red]Exit code: {process.returncode}[/red]")
|
|
56
|
+
except KeyboardInterrupt:
|
|
57
|
+
process.kill()
|
|
58
|
+
console.print("\n[yellow]Interrupted.[/yellow]")
|
|
59
|
+
except Exception as e:
|
|
60
|
+
from rich.markup import escape
|
|
61
|
+
console.print(f"[red]Error: {escape(str(e))}[/red]")
|
|
62
|
+
console.print()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def ask_yes_no(prompt: str) -> bool:
|
|
66
|
+
"""Ask the user a yes/no question."""
|
|
67
|
+
try:
|
|
68
|
+
answer = console.input(f"[bold yellow]{prompt} (y/n):[/bold yellow] ").strip().lower()
|
|
69
|
+
return answer in ("y", "yes", "s", "sim")
|
|
70
|
+
except (EOFError, KeyboardInterrupt):
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def handle_plugin_command(args: str) -> None:
|
|
75
|
+
"""Handle /plugin <subcommand> [args] — install/list/remove/update/info."""
|
|
76
|
+
from rich.table import Table
|
|
77
|
+
from rich.markup import escape
|
|
78
|
+
|
|
79
|
+
parts = args.strip().split(None, 2)
|
|
80
|
+
if not parts:
|
|
81
|
+
_show_plugin_help()
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
subcmd = parts[0].lower()
|
|
85
|
+
|
|
86
|
+
if subcmd == "list":
|
|
87
|
+
from aru.plugin_cache import list_installed
|
|
88
|
+
entries = list_installed()
|
|
89
|
+
if not entries:
|
|
90
|
+
console.print("[dim]No plugins installed. Use /plugin install <spec> to add one.[/dim]")
|
|
91
|
+
return
|
|
92
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
|
|
93
|
+
table.add_column("Name", style="cyan")
|
|
94
|
+
table.add_column("Version", style="green")
|
|
95
|
+
table.add_column("Source")
|
|
96
|
+
table.add_column("Spec", style="dim")
|
|
97
|
+
for e in entries:
|
|
98
|
+
table.add_row(e.id, e.version or "-", e.source, e.spec)
|
|
99
|
+
console.print(table)
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
if subcmd == "install":
|
|
103
|
+
if len(parts) < 2:
|
|
104
|
+
console.print("[yellow]Usage: /plugin install <spec> [name][/yellow]")
|
|
105
|
+
return
|
|
106
|
+
spec = parts[1]
|
|
107
|
+
name = parts[2] if len(parts) >= 3 else None
|
|
108
|
+
from aru.plugin_cache import install
|
|
109
|
+
console.print(f"[dim]Installing {escape(spec)}...[/dim]")
|
|
110
|
+
result = install(spec, name=name)
|
|
111
|
+
if not result.ok:
|
|
112
|
+
console.print(f"[red]Install failed: {escape(result.error or 'unknown error')}[/red]")
|
|
113
|
+
return
|
|
114
|
+
provides = result.provides
|
|
115
|
+
provides_str = ", ".join(f"{c} {k}" for k, c in provides.items()) or "no resources"
|
|
116
|
+
console.print(
|
|
117
|
+
f"[green]Installed {escape(result.name or '')}"
|
|
118
|
+
f"{f'@{result.version}' if result.version else ''}[/green] "
|
|
119
|
+
f"([dim]{result.state}[/dim]) -> {escape(str(result.target))}"
|
|
120
|
+
)
|
|
121
|
+
console.print(f"[dim]Provides: {provides_str}[/dim]")
|
|
122
|
+
console.print(
|
|
123
|
+
"[dim]Discovery refreshes on next aru restart. "
|
|
124
|
+
"Skills/agents/tools from the plugin will then be available.[/dim]"
|
|
125
|
+
)
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
if subcmd == "remove":
|
|
129
|
+
if len(parts) < 2:
|
|
130
|
+
console.print("[yellow]Usage: /plugin remove <name>[/yellow]")
|
|
131
|
+
return
|
|
132
|
+
name = parts[1]
|
|
133
|
+
from aru.plugin_cache import remove
|
|
134
|
+
if remove(name):
|
|
135
|
+
console.print(f"[green]Removed plugin: {escape(name)}[/green]")
|
|
136
|
+
else:
|
|
137
|
+
console.print(f"[yellow]Plugin not found: {escape(name)}[/yellow]")
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
if subcmd == "update":
|
|
141
|
+
if len(parts) < 2:
|
|
142
|
+
console.print("[yellow]Usage: /plugin update <name>[/yellow]")
|
|
143
|
+
return
|
|
144
|
+
name = parts[1]
|
|
145
|
+
from aru.plugin_cache import update
|
|
146
|
+
console.print(f"[dim]Updating {escape(name)}...[/dim]")
|
|
147
|
+
result = update(name)
|
|
148
|
+
if not result.ok:
|
|
149
|
+
console.print(f"[red]Update failed: {escape(result.error or 'unknown error')}[/red]")
|
|
150
|
+
return
|
|
151
|
+
console.print(
|
|
152
|
+
f"[green]Updated {escape(result.name or '')}"
|
|
153
|
+
f"{f'@{result.version}' if result.version else ''}[/green] "
|
|
154
|
+
f"([dim]{result.state}[/dim])"
|
|
155
|
+
)
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
if subcmd == "info":
|
|
159
|
+
if len(parts) < 2:
|
|
160
|
+
console.print("[yellow]Usage: /plugin info <name>[/yellow]")
|
|
161
|
+
return
|
|
162
|
+
name = parts[1]
|
|
163
|
+
from aru.plugin_cache import list_installed, read_manifest
|
|
164
|
+
from pathlib import Path
|
|
165
|
+
entries = {e.id: e for e in list_installed()}
|
|
166
|
+
entry = entries.get(name)
|
|
167
|
+
if entry is None:
|
|
168
|
+
console.print(f"[yellow]Plugin not found: {escape(name)}[/yellow]")
|
|
169
|
+
return
|
|
170
|
+
manifest = read_manifest(Path(entry.target))
|
|
171
|
+
console.print(f"[bold cyan]{escape(entry.id)}[/bold cyan]")
|
|
172
|
+
console.print(f" [dim]version:[/dim] {entry.version or '-'}")
|
|
173
|
+
console.print(f" [dim]source:[/dim] {entry.source}")
|
|
174
|
+
console.print(f" [dim]spec:[/dim] {escape(entry.spec)}")
|
|
175
|
+
console.print(f" [dim]target:[/dim] {escape(entry.target)}")
|
|
176
|
+
console.print(f" [dim]fingerprint:[/dim] {entry.fingerprint}")
|
|
177
|
+
console.print(f" [dim]first_time:[/dim] {entry.first_time}")
|
|
178
|
+
console.print(f" [dim]last_time:[/dim] {entry.last_time}")
|
|
179
|
+
if manifest:
|
|
180
|
+
desc = manifest.get("description")
|
|
181
|
+
if desc:
|
|
182
|
+
console.print(f" [dim]description:[/dim] {escape(str(desc))}")
|
|
183
|
+
engines = manifest.get("engines") or {}
|
|
184
|
+
if isinstance(engines, dict) and engines.get("aru"):
|
|
185
|
+
console.print(f" [dim]engines.aru:[/dim] {escape(str(engines['aru']))}")
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
_show_plugin_help()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _show_plugin_help() -> None:
|
|
192
|
+
"""Print /plugin command usage."""
|
|
193
|
+
from rich.table import Table
|
|
194
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
|
|
195
|
+
table.add_column("Subcommand", style="cyan")
|
|
196
|
+
table.add_column("Description", style="dim")
|
|
197
|
+
table.add_row("/plugin install <spec> [name]", "Install a plugin from git or local path")
|
|
198
|
+
table.add_row("/plugin list", "List installed plugins")
|
|
199
|
+
table.add_row("/plugin remove <name>", "Uninstall a plugin")
|
|
200
|
+
table.add_row("/plugin update <name>", "Update a plugin (git pull)")
|
|
201
|
+
table.add_row("/plugin info <name>", "Show plugin metadata")
|
|
202
|
+
console.print(table)
|
|
203
|
+
console.print()
|
|
204
|
+
console.print("[dim]Spec formats:[/dim]")
|
|
205
|
+
console.print("[dim] github:user/repo — shorthand for GitHub[/dim]")
|
|
206
|
+
console.print("[dim] github:user/repo@v1.0.0 — pin to tag/branch[/dim]")
|
|
207
|
+
console.print("[dim] git+https://host/path.git — any git URL[/dim]")
|
|
208
|
+
console.print("[dim] file:///abs/path or ./rel — local directory[/dim]")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _show_help(config) -> None:
|
|
212
|
+
"""Display help with available commands."""
|
|
213
|
+
from rich.table import Table
|
|
214
|
+
|
|
215
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
|
|
216
|
+
table.add_column("Command", style="cyan")
|
|
217
|
+
table.add_column("Description", style="dim")
|
|
218
|
+
|
|
219
|
+
table.add_row("/plan <task>", "Create detailed implementation plan")
|
|
220
|
+
table.add_row("/model [provider/model]", "Switch models (e.g., ollama/llama3.1, openai/gpt-4o)")
|
|
221
|
+
table.add_row("/sessions", "List recent sessions")
|
|
222
|
+
table.add_row("/commands", "List custom commands")
|
|
223
|
+
table.add_row("/skills", "List available skills")
|
|
224
|
+
table.add_row("/agents", "List custom agents")
|
|
225
|
+
table.add_row("/mcp", "List loaded MCP tools")
|
|
226
|
+
table.add_row("/plugin <subcmd>", "Manage plugins (install/list/remove/update/info)")
|
|
227
|
+
table.add_row("/undo", "Undo last turn (restore files and/or conversation)")
|
|
228
|
+
table.add_row("/help", "Show this help")
|
|
229
|
+
table.add_row("/quit", "Exit aru")
|
|
230
|
+
table.add_row("! <cmd>", "Run shell command")
|
|
231
|
+
|
|
232
|
+
if config and config.commands:
|
|
233
|
+
table.add_row("", "")
|
|
234
|
+
for name, cmd_def in config.commands.items():
|
|
235
|
+
table.add_row(f"/{name}", cmd_def.description)
|
|
236
|
+
|
|
237
|
+
if config and config.custom_agents:
|
|
238
|
+
primary = {k: v for k, v in config.custom_agents.items() if v.mode == "primary"}
|
|
239
|
+
if primary:
|
|
240
|
+
table.add_row("", "")
|
|
241
|
+
for name, agent_def in primary.items():
|
|
242
|
+
table.add_row(f"/{name}", f"[agent] {agent_def.description}")
|
|
243
|
+
|
|
244
|
+
console.print(table)
|
|
245
|
+
console.print()
|