aru-code 0.23.0__tar.gz → 0.24.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 (69) hide show
  1. {aru_code-0.23.0/aru_code.egg-info → aru_code-0.24.0}/PKG-INFO +1 -1
  2. aru_code-0.24.0/aru/__init__.py +1 -0
  3. {aru_code-0.23.0 → aru_code-0.24.0}/aru/agent_factory.py +92 -3
  4. {aru_code-0.23.0 → aru_code-0.24.0}/aru/agents/base.py +32 -2
  5. aru_code-0.24.0/aru/agents/explorer.py +91 -0
  6. {aru_code-0.23.0 → aru_code-0.24.0}/aru/cli.py +49 -0
  7. {aru_code-0.23.0 → aru_code-0.24.0}/aru/config.py +1 -1
  8. {aru_code-0.23.0 → aru_code-0.24.0}/aru/display.py +18 -0
  9. {aru_code-0.23.0 → aru_code-0.24.0}/aru/plugins/hooks.py +73 -3
  10. {aru_code-0.23.0 → aru_code-0.24.0}/aru/plugins/manager.py +61 -1
  11. {aru_code-0.23.0 → aru_code-0.24.0}/aru/runner.py +75 -0
  12. {aru_code-0.23.0 → aru_code-0.24.0}/aru/tools/codebase.py +26 -23
  13. {aru_code-0.23.0 → aru_code-0.24.0/aru_code.egg-info}/PKG-INFO +1 -1
  14. {aru_code-0.23.0 → aru_code-0.24.0}/aru_code.egg-info/SOURCES.txt +1 -0
  15. {aru_code-0.23.0 → aru_code-0.24.0}/pyproject.toml +1 -1
  16. {aru_code-0.23.0 → aru_code-0.24.0}/tests/test_codebase.py +1 -1
  17. aru_code-0.23.0/aru/__init__.py +0 -1
  18. {aru_code-0.23.0 → aru_code-0.24.0}/LICENSE +0 -0
  19. {aru_code-0.23.0 → aru_code-0.24.0}/README.md +0 -0
  20. {aru_code-0.23.0 → aru_code-0.24.0}/aru/agents/__init__.py +0 -0
  21. {aru_code-0.23.0 → aru_code-0.24.0}/aru/agents/executor.py +0 -0
  22. {aru_code-0.23.0 → aru_code-0.24.0}/aru/agents/planner.py +0 -0
  23. {aru_code-0.23.0 → aru_code-0.24.0}/aru/cache_patch.py +0 -0
  24. {aru_code-0.23.0 → aru_code-0.24.0}/aru/checkpoints.py +0 -0
  25. {aru_code-0.23.0 → aru_code-0.24.0}/aru/commands.py +0 -0
  26. {aru_code-0.23.0 → aru_code-0.24.0}/aru/completers.py +0 -0
  27. {aru_code-0.23.0 → aru_code-0.24.0}/aru/context.py +0 -0
  28. {aru_code-0.23.0 → aru_code-0.24.0}/aru/history_blocks.py +0 -0
  29. {aru_code-0.23.0 → aru_code-0.24.0}/aru/permissions.py +0 -0
  30. {aru_code-0.23.0 → aru_code-0.24.0}/aru/plugins/__init__.py +0 -0
  31. {aru_code-0.23.0 → aru_code-0.24.0}/aru/plugins/custom_tools.py +0 -0
  32. {aru_code-0.23.0 → aru_code-0.24.0}/aru/plugins/tool_api.py +0 -0
  33. {aru_code-0.23.0 → aru_code-0.24.0}/aru/providers.py +0 -0
  34. {aru_code-0.23.0 → aru_code-0.24.0}/aru/runtime.py +0 -0
  35. {aru_code-0.23.0 → aru_code-0.24.0}/aru/session.py +0 -0
  36. {aru_code-0.23.0 → aru_code-0.24.0}/aru/tools/__init__.py +0 -0
  37. {aru_code-0.23.0 → aru_code-0.24.0}/aru/tools/ast_tools.py +0 -0
  38. {aru_code-0.23.0 → aru_code-0.24.0}/aru/tools/gitignore.py +0 -0
  39. {aru_code-0.23.0 → aru_code-0.24.0}/aru/tools/mcp_client.py +0 -0
  40. {aru_code-0.23.0 → aru_code-0.24.0}/aru/tools/ranker.py +0 -0
  41. {aru_code-0.23.0 → aru_code-0.24.0}/aru/tools/tasklist.py +0 -0
  42. {aru_code-0.23.0 → aru_code-0.24.0}/aru_code.egg-info/dependency_links.txt +0 -0
  43. {aru_code-0.23.0 → aru_code-0.24.0}/aru_code.egg-info/entry_points.txt +0 -0
  44. {aru_code-0.23.0 → aru_code-0.24.0}/aru_code.egg-info/requires.txt +0 -0
  45. {aru_code-0.23.0 → aru_code-0.24.0}/aru_code.egg-info/top_level.txt +0 -0
  46. {aru_code-0.23.0 → aru_code-0.24.0}/setup.cfg +0 -0
  47. {aru_code-0.23.0 → aru_code-0.24.0}/tests/test_agents_base.py +0 -0
  48. {aru_code-0.23.0 → aru_code-0.24.0}/tests/test_checkpoints.py +0 -0
  49. {aru_code-0.23.0 → aru_code-0.24.0}/tests/test_cli.py +0 -0
  50. {aru_code-0.23.0 → aru_code-0.24.0}/tests/test_cli_advanced.py +0 -0
  51. {aru_code-0.23.0 → aru_code-0.24.0}/tests/test_cli_base.py +0 -0
  52. {aru_code-0.23.0 → aru_code-0.24.0}/tests/test_cli_completers.py +0 -0
  53. {aru_code-0.23.0 → aru_code-0.24.0}/tests/test_cli_new.py +0 -0
  54. {aru_code-0.23.0 → aru_code-0.24.0}/tests/test_cli_run_cli.py +0 -0
  55. {aru_code-0.23.0 → aru_code-0.24.0}/tests/test_cli_session.py +0 -0
  56. {aru_code-0.23.0 → aru_code-0.24.0}/tests/test_cli_shell.py +0 -0
  57. {aru_code-0.23.0 → aru_code-0.24.0}/tests/test_confabulation_regression.py +0 -0
  58. {aru_code-0.23.0 → aru_code-0.24.0}/tests/test_config.py +0 -0
  59. {aru_code-0.23.0 → aru_code-0.24.0}/tests/test_context.py +0 -0
  60. {aru_code-0.23.0 → aru_code-0.24.0}/tests/test_executor.py +0 -0
  61. {aru_code-0.23.0 → aru_code-0.24.0}/tests/test_gitignore.py +0 -0
  62. {aru_code-0.23.0 → aru_code-0.24.0}/tests/test_guardrails_scenarios.py +0 -0
  63. {aru_code-0.23.0 → aru_code-0.24.0}/tests/test_main.py +0 -0
  64. {aru_code-0.23.0 → aru_code-0.24.0}/tests/test_mcp_client.py +0 -0
  65. {aru_code-0.23.0 → aru_code-0.24.0}/tests/test_permissions.py +0 -0
  66. {aru_code-0.23.0 → aru_code-0.24.0}/tests/test_planner.py +0 -0
  67. {aru_code-0.23.0 → aru_code-0.24.0}/tests/test_plugins.py +0 -0
  68. {aru_code-0.23.0 → aru_code-0.24.0}/tests/test_providers.py +0 -0
  69. {aru_code-0.23.0 → aru_code-0.24.0}/tests/test_ranker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.23.0
3
+ Version: 0.24.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.24.0"
@@ -33,6 +33,22 @@ def _wrap_tools_with_hooks(tools: list) -> list:
33
33
  pass
34
34
  return data
35
35
 
36
+ async def _fire_tool_definition(tool_name: str, description: str, parameters: dict) -> dict:
37
+ """Fire tool.definition hook — plugins can modify tool desc/params."""
38
+ try:
39
+ ctx = get_ctx()
40
+ mgr = ctx.plugin_manager
41
+ if mgr is not None and mgr.loaded:
42
+ event = await mgr.fire("tool.definition", {
43
+ "tool_name": tool_name,
44
+ "description": description,
45
+ "parameters": parameters,
46
+ })
47
+ return event.data
48
+ except (LookupError, AttributeError):
49
+ pass
50
+ return {"tool_name": tool_name, "description": description, "parameters": parameters}
51
+
36
52
  def _wrap_one(fn):
37
53
  if not callable(fn) or getattr(fn, "_hook_wrapped", False):
38
54
  return fn
@@ -70,6 +86,68 @@ def _wrap_tools_with_hooks(tools: list) -> list:
70
86
  return [_wrap_one(t) for t in tools]
71
87
 
72
88
 
89
+ def _fire_sync_hook(event_name: str, data: dict) -> dict:
90
+ """Fire a plugin hook synchronously (for agent creation context).
91
+
92
+ Agent creation happens in sync code, so we need a sync path.
93
+ """
94
+ try:
95
+ from aru.runtime import get_ctx
96
+ ctx = get_ctx()
97
+ mgr = ctx.plugin_manager
98
+ if mgr is not None and mgr.loaded:
99
+ import asyncio
100
+ from aru.plugins.hooks import HookEvent
101
+ event = HookEvent(hook=event_name, data=data or {})
102
+ for hooks in mgr._hooks:
103
+ for handler in hooks.get_handlers(event_name):
104
+ try:
105
+ if asyncio.iscoroutinefunction(handler):
106
+ # Best-effort: try to run async handler
107
+ try:
108
+ loop = asyncio.get_running_loop()
109
+ except RuntimeError:
110
+ loop = None
111
+ if loop and loop.is_running():
112
+ # Can't await in sync context with running loop — skip
113
+ continue
114
+ else:
115
+ asyncio.run(handler(event))
116
+ else:
117
+ handler(event)
118
+ except Exception as e:
119
+ logger.warning("Hook handler error (%s): %s", event_name, e)
120
+ return event.data
121
+ except (LookupError, AttributeError):
122
+ pass
123
+ return data
124
+
125
+
126
+ def _apply_chat_hooks(instructions: str, model_ref: str, agent_name: str,
127
+ max_tokens: int = 8192) -> tuple[str, str, int]:
128
+ """Apply chat.system.transform and chat.params hooks to agent creation params.
129
+
130
+ Returns (instructions, model_ref, max_tokens) — possibly modified by plugins.
131
+ """
132
+ # chat.system.transform — plugins can modify the system prompt
133
+ data = _fire_sync_hook("chat.system.transform", {
134
+ "system_prompt": instructions,
135
+ "agent": agent_name,
136
+ })
137
+ instructions = data.get("system_prompt", instructions)
138
+
139
+ # chat.params — plugins can modify LLM parameters
140
+ data = _fire_sync_hook("chat.params", {
141
+ "model": model_ref,
142
+ "max_tokens": max_tokens,
143
+ "temperature": None, # let plugin set if desired
144
+ })
145
+ model_ref = data.get("model", model_ref)
146
+ max_tokens = data.get("max_tokens", max_tokens)
147
+
148
+ return instructions, model_ref, max_tokens
149
+
150
+
73
151
  def create_general_agent(
74
152
  session: Session,
75
153
  config: AgentConfig | None = None,
@@ -91,12 +169,18 @@ def create_general_agent(
91
169
  if env_context:
92
170
  extra = f"{extra}\n\n{env_context}" if extra else env_context
93
171
  model_ref = model_override or session.model_ref
172
+ instructions = _build_instructions("general", extra)
173
+
174
+ # Apply chat hooks (system.transform + params)
175
+ instructions, model_ref, max_tokens = _apply_chat_hooks(
176
+ instructions, model_ref, "Aru", max_tokens=8192,
177
+ )
94
178
 
95
179
  return Agent(
96
180
  name="Aru",
97
- model=create_model(model_ref, max_tokens=8192),
181
+ model=create_model(model_ref, max_tokens=max_tokens),
98
182
  tools=tools,
99
- instructions=_build_instructions("general", extra),
183
+ instructions=instructions,
100
184
  markdown=True,
101
185
  tool_call_limit=20,
102
186
  )
@@ -121,9 +205,14 @@ def create_custom_agent_instance(agent_def: CustomAgent, session: Session,
121
205
  parts.append(extra)
122
206
  instructions = "\n\n".join(parts)
123
207
 
208
+ # Apply chat hooks (system.transform + params)
209
+ instructions, model_ref, max_tokens = _apply_chat_hooks(
210
+ instructions, model_ref, agent_def.name, max_tokens=8192,
211
+ )
212
+
124
213
  return Agent(
125
214
  name=agent_def.name,
126
- model=create_model(model_ref, max_tokens=8192),
215
+ model=create_model(model_ref, max_tokens=max_tokens),
127
216
  tools=tools,
128
217
  instructions=instructions,
129
218
  markdown=True,
@@ -161,7 +161,10 @@ Use `context_lines=30` for full function bodies.
161
161
 
162
162
  **Batch independent tool calls**: emit ALL independent tool calls in a single response.
163
163
 
164
- Use delegate_task to split work into independent subtasks for parallel execution.
164
+ Use delegate_task to split work into independent subtasks for parallel execution. \
165
+ For broad codebase exploration (searching many files, finding patterns, understanding code), \
166
+ break the research into focused questions and spawn multiple \
167
+ `delegate_task(task="<specific search>", agent_name="explorer")` calls in parallel.
165
168
 
166
169
  When given a plan, execute it step by step. When given a direct task, figure out what needs to be done and do it.
167
170
  **ZERO narration between tool calls.** No "Now I have enough context...", \
@@ -208,7 +211,34 @@ Batch what you need upfront, then execute.
208
211
 
209
212
  **When adding or modifying unit tests, ALWAYS run them to verify they pass before finishing.**
210
213
 
211
- Use delegate_task to split work into independent subtasks for parallel execution.\
214
+ ## Delegation strategy CRITICAL for context efficiency
215
+
216
+ For simple, directed lookups (one known file, one specific symbol) use \
217
+ `grep_search` / `glob_search` / `read_file` directly.
218
+
219
+ For **anything broader** — understanding a system, researching before implementing, \
220
+ analyzing multiple files, writing specs or documentation — **always use explorer agents**. \
221
+ Every `read_file` / `read_file_smart` / `grep_search` result you call directly accumulates \
222
+ in YOUR context window and stays there forever. Explorer agents read files in their own \
223
+ isolated context and return only a concise summary. This is critical: \
224
+ **3 explorer summaries < 8 raw file reads** in context cost.
225
+
226
+ **Rule of thumb**: If you'd need to read or search more than 2-3 files, use explorers instead.
227
+
228
+ **Decompose, don't dump.** Never throw one vague task at one explorer. \
229
+ Break the work into **focused, independent search questions** and spawn one explorer \
230
+ per question — all in a single response so they run in parallel. Each explorer prompt \
231
+ should be specific enough that it can search and answer on its own.
232
+
233
+ Example — user asks "explain the authentication system":
234
+ ```
235
+ delegate_task(task="Find auth middleware: search for login/logout handlers, session management, token validation", agent_name="explorer")
236
+ delegate_task(task="Find auth configuration: search for auth-related config files, env vars, secrets setup", agent_name="explorer")
237
+ delegate_task(task="Find auth tests: search for test files covering authentication flows", agent_name="explorer")
238
+ ```
239
+
240
+ After all explorers return, **synthesize their findings yourself** — the user sees \
241
+ your summary, not the raw explorer output.\
212
242
  """
213
243
 
214
244
 
@@ -0,0 +1,91 @@
1
+ """Explorer agent — fast, read-only codebase exploration specialist."""
2
+
3
+ import os
4
+
5
+ from agno.agent import Agent
6
+
7
+ from aru.providers import create_model
8
+ from aru.runtime import get_ctx
9
+ from aru.tools.codebase import (
10
+ bash,
11
+ glob_search,
12
+ grep_search,
13
+ list_directory,
14
+ rank_files,
15
+ read_file,
16
+ read_file_smart,
17
+ )
18
+
19
+ # Read-only tools only — no write/edit/delegate (prevents recursion and mutations)
20
+ EXPLORER_TOOLS = [
21
+ read_file,
22
+ read_file_smart,
23
+ glob_search,
24
+ grep_search,
25
+ list_directory,
26
+ bash,
27
+ rank_files,
28
+ ]
29
+
30
+ EXPLORER_ROLE = """\
31
+ You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
32
+
33
+ === CRITICAL: READ-ONLY MODE — NO FILE MODIFICATIONS ===
34
+ This is a READ-ONLY exploration task. You are STRICTLY PROHIBITED from:
35
+ - Creating new files (no write_file, touch, or file creation of any kind)
36
+ - Modifying existing files (no edit_file operations)
37
+ - Deleting files (no rm or deletion)
38
+ - Moving or copying files (no mv or cp)
39
+ - Creating temporary files anywhere, including /tmp
40
+ - Using redirect operators (>, >>, |) or heredocs to write to files
41
+ - Running ANY commands that change system state
42
+
43
+ Your role is EXCLUSIVELY to search and analyze existing code. \
44
+ You do NOT have access to file editing tools — attempting to edit files will fail.
45
+
46
+ Your strengths:
47
+ - Rapidly finding files using glob patterns
48
+ - Searching code and text with powerful regex patterns
49
+ - Reading and analyzing file contents
50
+
51
+ Guidelines:
52
+ - Use glob_search for broad file pattern matching
53
+ - Use grep_search for searching file contents with regex
54
+ - Use read_file when you know the specific file path you need to read
55
+ - Use read_file_smart when you know the file and have a specific question about it
56
+ - Use bash ONLY for read-only operations (ls, git status, git log, git diff, find, cat, head, tail)
57
+ - NEVER use bash for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, \
58
+ or any file creation/modification
59
+ - Adapt your search approach based on the thoroughness level specified by the caller
60
+
61
+ NOTE: You are meant to be a FAST agent that returns output as quickly as possible. To achieve this:
62
+ - Make efficient use of tools: be smart about how you search for files and implementations
63
+ - Wherever possible, spawn MULTIPLE PARALLEL tool calls for grepping and reading files
64
+ - Do not read files you don't need — stop as soon as you have enough information
65
+
66
+ Complete the search request efficiently and report your findings clearly.\
67
+ """
68
+
69
+
70
+ def create_explorer(task: str, context: str = "") -> Agent:
71
+ """Create and return an explorer agent for a specific task.
72
+
73
+ Args:
74
+ task: The exploration task description.
75
+ context: Optional extra context (file paths, constraints).
76
+ """
77
+ cwd = os.getcwd()
78
+ small_model_ref = get_ctx().small_model_ref
79
+
80
+ instructions = EXPLORER_ROLE + f"\nThe current working directory is: {cwd}\n"
81
+ if context:
82
+ instructions += f"\nAdditional context:\n{context}\n"
83
+
84
+ return Agent(
85
+ name="Explorer",
86
+ model=create_model(small_model_ref, max_tokens=4096),
87
+ tools=EXPLORER_TOOLS,
88
+ instructions=instructions,
89
+ markdown=True,
90
+ tool_call_limit=15,
91
+ )
@@ -228,10 +228,20 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
228
228
  ctx.plugin_manager = _plugin_mgr
229
229
 
230
230
  try:
231
+ _config_dict = {
232
+ "default_model": config.default_model,
233
+ "model_aliases": config.model_aliases,
234
+ "permissions": config.permissions,
235
+ "plugin_specs": config.plugin_specs,
236
+ "disabled_tools": config.disabled_tools,
237
+ "plan_reviewer": config.plan_reviewer,
238
+ }
231
239
  _p_input = PluginInput(
232
240
  directory=os.getcwd(),
233
241
  config_path="aru.json" if os.path.isfile("aru.json") else "",
234
242
  model_ref=session.model_ref,
243
+ config=_config_dict,
244
+ session=session,
235
245
  )
236
246
  _plugin_specs = config.plugin_specs if hasattr(config, "plugin_specs") else []
237
247
  _plugin_count = await _plugin_mgr.load_all(_p_input, plugin_specs=_plugin_specs)
@@ -252,6 +262,17 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
252
262
 
253
263
  asyncio.create_task(_load_mcp_background())
254
264
 
265
+ # Event: session.start
266
+ if _plugin_mgr.loaded:
267
+ try:
268
+ await _plugin_mgr.publish("session.start", {
269
+ "session_id": getattr(session, "id", None),
270
+ "model_ref": session.model_ref,
271
+ "directory": os.getcwd(),
272
+ })
273
+ except Exception:
274
+ pass
275
+
255
276
  while True:
256
277
  try:
257
278
  paste_state.clear()
@@ -393,6 +414,14 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
393
414
  continue
394
415
 
395
416
  if user_input.lower() in ("/quit", "/exit", "quit", "exit"):
417
+ # Event: session.end
418
+ if _plugin_mgr.loaded:
419
+ try:
420
+ await _plugin_mgr.publish("session.end", {
421
+ "session_id": getattr(session, "id", None),
422
+ })
423
+ except Exception:
424
+ pass
396
425
  store.save(session)
397
426
  console.print(f"\n[dim]Session saved: {session.session_id}[/dim]")
398
427
  console.print(f"[dim]Resume with:[/dim] [bold cyan]aru --resume {session.session_id}[/bold cyan]")
@@ -579,6 +608,26 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
579
608
  cmd_name = parts[0].lower()
580
609
  cmd_args = parts[1] if len(parts) > 1 else ""
581
610
 
611
+ # Hook: command.execute.before — plugins can block or modify
612
+ _cmd_blocked = False
613
+ try:
614
+ _mgr = ctx.plugin_manager
615
+ if _mgr is not None and _mgr.loaded:
616
+ _cmd_event = await _mgr.fire("command.execute.before", {
617
+ "command": cmd_name,
618
+ "command_args": cmd_args,
619
+ "blocked": False,
620
+ })
621
+ if _cmd_event.data.get("blocked"):
622
+ console.print(f"[yellow]Command /{cmd_name} blocked by plugin.[/yellow]")
623
+ _cmd_blocked = True
624
+ else:
625
+ cmd_args = _cmd_event.data.get("command_args", cmd_args)
626
+ except Exception:
627
+ pass
628
+ if _cmd_blocked:
629
+ continue
630
+
582
631
  if cmd_name in config.commands:
583
632
  cmd_def = config.commands[cmd_name]
584
633
  prompt = render_command_template(cmd_def.template, cmd_args)
@@ -552,7 +552,7 @@ def load_config(cwd: str | None = None) -> AgentConfig:
552
552
  # Load config: global (~/.aru/config.json) first, then project-level on top.
553
553
  # Project values override global values via deep merge.
554
554
  home = Path.home()
555
- global_config_paths = [home / ".aru" / "config.json"]
555
+ global_config_paths = [home / ".aru" / "aru.json", home / ".aru" / "config.json"]
556
556
  project_config_paths = [root / "aru.json", root / ".aru" / "config.json"]
557
557
 
558
558
  merged_data: dict = {}
@@ -204,6 +204,7 @@ TOOL_DISPLAY_NAMES = {
204
204
  "list_directory": "List",
205
205
  "bash": "Bash",
206
206
  "rank_files": "Rank",
207
+ "delegate_task": "Agent",
207
208
  }
208
209
 
209
210
  TOOL_PRIMARY_ARG = {
@@ -216,6 +217,12 @@ TOOL_PRIMARY_ARG = {
216
217
  "list_directory": "directory",
217
218
  "bash": "command",
218
219
  "rank_files": "task",
220
+ "delegate_task": "task",
221
+ }
222
+
223
+ # Agent type display names for delegate_task
224
+ _AGENT_TYPE_LABELS = {
225
+ "explorer": "Explorer",
219
226
  }
220
227
 
221
228
 
@@ -225,6 +232,17 @@ def _format_tool_label(tool_name: str, tool_args: dict | None) -> str:
225
232
  if not tool_args:
226
233
  return display
227
234
 
235
+ # Special handling for delegate_task — show agent type and task summary
236
+ if tool_name == "delegate_task":
237
+ agent = str(tool_args.get("agent_name", "") or tool_args.get("agent", ""))
238
+ agent_label = _AGENT_TYPE_LABELS.get(agent, agent.title() if agent else "SubAgent")
239
+ task = str(tool_args.get("task", ""))
240
+ # Extract first meaningful line/sentence as summary
241
+ summary = task.split("\n")[0].strip()
242
+ if len(summary) > 60:
243
+ summary = summary[:57] + "..."
244
+ return f"{agent_label}({summary})" if summary else agent_label
245
+
228
246
  primary_key = TOOL_PRIMARY_ARG.get(tool_name)
229
247
  if primary_key and primary_key in tool_args:
230
248
  value = str(tool_args[primary_key])
@@ -29,14 +29,28 @@ logger = logging.getLogger("aru.plugins")
29
29
 
30
30
  # Valid hook names (mirrors relevant OpenCode hooks)
31
31
  VALID_HOOKS = frozenset({
32
+ # Lifecycle
32
33
  "config", # After config loaded
34
+ "event", # Subscribe to all bus events
35
+
36
+ # Tool lifecycle
33
37
  "tool.execute.before", # Before any tool runs
34
38
  "tool.execute.after", # After any tool runs
35
- "tool.definition", # When tools are resolved (can add/remove)
36
- "permission.ask", # Before permission prompt
39
+ "tool.definition", # When tools are resolved (can modify desc/params)
40
+
41
+ # Chat lifecycle
42
+ "chat.message", # Before user message is sent to LLM (can modify)
43
+ "chat.params", # Before LLM call (can modify temperature, max_tokens)
44
+ "chat.system.transform", # Before LLM call (can modify system prompt)
45
+ "chat.messages.transform", # Before LLM call (can modify message history)
46
+
47
+ # Command lifecycle
48
+ "command.execute.before", # Before slash command runs (can block/modify)
49
+
50
+ # Permission / shell
51
+ "permission.ask", # Before permission prompt (can auto-allow/deny)
37
52
  "shell.env", # Before bash subprocess
38
53
  "session.compact", # Before context compaction
39
- "event", # Subscribe to all events
40
54
  })
41
55
 
42
56
 
@@ -75,6 +89,60 @@ class HookEvent:
75
89
  def env(self, value: dict[str, str]) -> None:
76
90
  self.data["env"] = value
77
91
 
92
+ # -- Chat hook accessors --
93
+
94
+ @property
95
+ def message(self) -> str:
96
+ return self.data.get("message", "")
97
+
98
+ @message.setter
99
+ def message(self, value: str) -> None:
100
+ self.data["message"] = value
101
+
102
+ @property
103
+ def messages(self) -> list:
104
+ return self.data.get("messages", [])
105
+
106
+ @messages.setter
107
+ def messages(self, value: list) -> None:
108
+ self.data["messages"] = value
109
+
110
+ @property
111
+ def system_prompt(self) -> str:
112
+ return self.data.get("system_prompt", "")
113
+
114
+ @system_prompt.setter
115
+ def system_prompt(self, value: str) -> None:
116
+ self.data["system_prompt"] = value
117
+
118
+ @property
119
+ def params(self) -> dict[str, Any]:
120
+ return self.data.get("params", {})
121
+
122
+ @params.setter
123
+ def params(self, value: dict[str, Any]) -> None:
124
+ self.data["params"] = value
125
+
126
+ # -- Command hook accessors --
127
+
128
+ @property
129
+ def command(self) -> str:
130
+ return self.data.get("command", "")
131
+
132
+ @property
133
+ def command_args(self) -> str:
134
+ return self.data.get("command_args", "")
135
+
136
+ @property
137
+ def blocked(self) -> bool:
138
+ return self.data.get("blocked", False)
139
+
140
+ @blocked.setter
141
+ def blocked(self, value: bool) -> None:
142
+ self.data["blocked"] = value
143
+
144
+ # -- Generic accessors --
145
+
78
146
  def get(self, key: str, default: Any = None) -> Any:
79
147
  return self.data.get(key, default)
80
148
 
@@ -91,6 +159,8 @@ class PluginInput:
91
159
  directory: str # project root (os.getcwd())
92
160
  config_path: str # path to aru.json (or "")
93
161
  model_ref: str # current model reference
162
+ config: dict[str, Any] = field(default_factory=dict) # full config dict
163
+ session: Any = None # session object (if available at init time)
94
164
 
95
165
 
96
166
  class Hooks:
@@ -12,6 +12,7 @@ Plugin sources:
12
12
  from __future__ import annotations
13
13
 
14
14
  import asyncio
15
+ from collections import defaultdict
15
16
  import importlib
16
17
  import importlib.metadata
17
18
  import importlib.util
@@ -34,12 +35,20 @@ def _noop_manager():
34
35
 
35
36
 
36
37
  class PluginManager:
37
- """Loads plugins, aggregates hooks, and fires events."""
38
+ """Loads plugins, aggregates hooks, fires events, and manages event bus.
39
+
40
+ Two dispatch mechanisms:
41
+ - ``fire(hook_name, data)`` — sequential interceptors, handlers can mutate data.
42
+ - ``publish(event_type, data)`` — fan-out notifications, read-only for subscribers.
43
+ Also fires the ``event`` hook so plugins registered via hooks see all events.
44
+ """
38
45
 
39
46
  def __init__(self) -> None:
40
47
  self._hooks: list[Hooks] = []
41
48
  self._plugin_names: list[str] = []
42
49
  self._loaded = False
50
+ # Event bus: subscribers keyed by event type (or "*" for all)
51
+ self._subscribers: dict[str, list[Callable]] = defaultdict(list)
43
52
 
44
53
  @property
45
54
  def loaded(self) -> bool:
@@ -125,6 +134,16 @@ class PluginManager:
125
134
  pass # entry_points may fail on older Python
126
135
 
127
136
  self._loaded = True
137
+
138
+ # Fire config hook so plugins can react to the current configuration
139
+ if count > 0:
140
+ config_data: dict[str, Any] = {}
141
+ if plugin_input.config:
142
+ config_data = dict(plugin_input.config)
143
+ config_data.setdefault("directory", plugin_input.directory)
144
+ config_data.setdefault("model_ref", plugin_input.model_ref)
145
+ await self.fire("config", config_data)
146
+
128
147
  return count
129
148
 
130
149
  async def fire(self, event_name: str, data: dict[str, Any] | None = None) -> HookEvent:
@@ -152,6 +171,47 @@ class PluginManager:
152
171
 
153
172
  return event
154
173
 
174
+ # -- Event bus (pub/sub) --
175
+
176
+ def subscribe(self, event_type: str, callback: Callable) -> None:
177
+ """Subscribe to a specific event type (e.g. 'message.user', 'tool.called')."""
178
+ self._subscribers[event_type].append(callback)
179
+
180
+ def subscribe_all(self, callback: Callable) -> None:
181
+ """Subscribe to all events (wildcard)."""
182
+ self._subscribers["*"].append(callback)
183
+
184
+ async def publish(self, event_type: str, data: dict[str, Any] | None = None) -> None:
185
+ """Publish an event to all subscribers + fire the ``event`` hook.
186
+
187
+ Unlike ``fire()``, this is fan-out: subscribers receive a copy and
188
+ cannot mutate the event for other subscribers.
189
+ """
190
+ payload = {"event_type": event_type, **(data or {})}
191
+
192
+ # Notify typed subscribers
193
+ for cb in self._subscribers.get(event_type, []):
194
+ try:
195
+ if asyncio.iscoroutinefunction(cb):
196
+ await cb(payload)
197
+ else:
198
+ cb(payload)
199
+ except Exception as e:
200
+ logger.error("Event subscriber error (%s): %s", event_type, e)
201
+
202
+ # Notify wildcard subscribers
203
+ for cb in self._subscribers.get("*", []):
204
+ try:
205
+ if asyncio.iscoroutinefunction(cb):
206
+ await cb(payload)
207
+ else:
208
+ cb(payload)
209
+ except Exception as e:
210
+ logger.error("Event subscriber error (*): %s", e)
211
+
212
+ # Fire the ``event`` hook so hook-based plugins also see all events
213
+ await self.fire("event", payload)
214
+
155
215
  def get_plugin_tools(self) -> list[dict[str, Any]]:
156
216
  """Collect all tools registered by plugins.
157
217
 
@@ -26,6 +26,50 @@ from aru.permissions import get_skip_permissions
26
26
  _MUTATION_TOOLS = {"write_file", "edit_file", "bash"}
27
27
 
28
28
 
29
+ async def _fire_plugin_hook(event_name: str, data: dict) -> dict:
30
+ """Fire a plugin hook if the plugin manager is available. Returns (mutated) data."""
31
+ try:
32
+ from aru.runtime import get_ctx
33
+ ctx = get_ctx()
34
+ mgr = ctx.plugin_manager
35
+ if mgr is not None and mgr.loaded:
36
+ event = await mgr.fire(event_name, data)
37
+ return event.data
38
+ except (LookupError, AttributeError):
39
+ pass
40
+ return data
41
+
42
+
43
+ async def _publish_event(event_type: str, data: dict | None = None) -> None:
44
+ """Publish an event to the plugin event bus (fire-and-forget)."""
45
+ try:
46
+ from aru.runtime import get_ctx
47
+ ctx = get_ctx()
48
+ mgr = ctx.plugin_manager
49
+ if mgr is not None and mgr.loaded:
50
+ await mgr.publish(event_type, data or {})
51
+ except (LookupError, AttributeError):
52
+ pass
53
+
54
+
55
+ async def _fire_chat_message_hook(message: str, session=None) -> str:
56
+ """Fire chat.message hook — plugins can modify the user message."""
57
+ data = await _fire_plugin_hook("chat.message", {
58
+ "message": message,
59
+ "session_id": getattr(session, "id", None),
60
+ })
61
+ return data.get("message", message)
62
+
63
+
64
+ async def _fire_chat_messages_transform_hook(messages: list, session=None) -> list:
65
+ """Fire chat.messages.transform hook — plugins can modify message history."""
66
+ data = await _fire_plugin_hook("chat.messages.transform", {
67
+ "messages": messages,
68
+ "session_id": getattr(session, "id", None),
69
+ })
70
+ return data.get("messages", messages)
71
+
72
+
29
73
  def build_env_context(session, cwd: str | None = None) -> str:
30
74
  """Build environment context string (cwd, git status) for system prompt.
31
75
 
@@ -133,6 +177,15 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
133
177
  else:
134
178
  run_message = message
135
179
 
180
+ # Hook: chat.message — let plugins intercept/modify user message
181
+ run_message = await _fire_chat_message_hook(run_message, session)
182
+
183
+ # Event: message.user
184
+ await _publish_event("message.user", {
185
+ "message": run_message,
186
+ "session_id": getattr(session, "id", None),
187
+ })
188
+
136
189
  # Build conversation history as real messages for the LLM.
137
190
  # At turn start we only do reversible pruning — destructive compaction
138
191
  # is reserved for the post-turn reactive path (below) which fires when
@@ -150,6 +203,10 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
150
203
  prior_history = session.history[:-1]
151
204
  history_messages = to_agno_messages(prior_history)
152
205
 
206
+ # Hook: chat.messages.transform — let plugins modify history before LLM
207
+ if history_messages:
208
+ history_messages = await _fire_chat_messages_transform_hook(history_messages, session)
209
+
153
210
  # Combine: history messages + current enriched message
154
211
  if history_messages:
155
212
  history_messages.append(Message(role="user", content=run_message, images=images or None))
@@ -203,6 +260,11 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
203
260
  tracker.start(tool_id, label)
204
261
  status.set_text(f"{label}...")
205
262
  live.update(display)
263
+ # Event: tool.called
264
+ await _publish_event("tool.called", {
265
+ "tool_name": tool_name, "tool_id": tool_id,
266
+ "args": tool_args if isinstance(tool_args, dict) else {},
267
+ })
206
268
 
207
269
  elif isinstance(event, ToolCallCompletedEvent):
208
270
  _stall_counter = 0
@@ -230,6 +292,11 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
230
292
  })
231
293
  pending_tool_uses.pop(tool_id, None)
232
294
 
295
+ # Event: tool.completed
296
+ await _publish_event("tool.completed", {
297
+ "tool_id": tool_id,
298
+ "result_length": len(str(tool_result_text)) if tool_result_text else 0,
299
+ })
233
300
  result = tracker.complete(tool_id)
234
301
  for label, duration in tracker.pop_completed():
235
302
  dur_str = f" {duration:.1f}s" if duration >= 0.5 else ""
@@ -334,6 +401,14 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
334
401
  pass
335
402
 
336
403
  final_content = accumulated or final_content
404
+
405
+ # Event: message.assistant
406
+ await _publish_event("message.assistant", {
407
+ "content": final_content,
408
+ "tool_calls": collected_tool_calls,
409
+ "session_id": getattr(session, "id", None),
410
+ })
411
+
337
412
  remaining = (final_content or "")[display._flushed_len:]
338
413
  if remaining:
339
414
  console.print(Markdown(remaining))
@@ -1218,14 +1218,14 @@ _SUBAGENT_TOOLS = [
1218
1218
  ]
1219
1219
 
1220
1220
 
1221
- async def delegate_task(task: str, context: str = "", agent: str = "") -> str:
1221
+ async def delegate_task(task: str, context: str = "", agent_name: str = "") -> str:
1222
1222
  """Delegate a task to a sub-agent that runs autonomously. Multiple calls run concurrently.
1223
1223
  Use for independent research or subtasks to keep your own context clean.
1224
1224
 
1225
1225
  Args:
1226
1226
  task: What the sub-agent should do.
1227
1227
  context: Optional extra context (file paths, constraints).
1228
- agent: Optional custom agent name to use instead of the generic sub-agent.
1228
+ agent_name: Name of a specialized agent to use instead of the generic sub-agent.
1229
1229
  """
1230
1230
 
1231
1231
  async def _run() -> str:
@@ -1243,19 +1243,15 @@ async def delegate_task(task: str, context: str = "", agent: str = "") -> str:
1243
1243
 
1244
1244
  agent_perm = None
1245
1245
  custom_agent_defs = get_ctx().custom_agent_defs
1246
- # Agno may pass the caller Agent object instead of a string — coerce to str
1247
- agent_name = str(agent) if agent and isinstance(agent, str) else ""
1248
-
1249
- # Print delegation info so the user sees what's happening
1250
- from rich.console import Console
1251
- _console = Console()
1252
- if agent_name and agent_name in custom_agent_defs:
1253
- _console.print(f"[dim] → Delegating to agent [bold]{agent_name}[/bold] (task: {task[:80]}{'...' if len(task) > 80 else ''})[/dim]")
1254
- else:
1255
- _console.print(f"[dim] → Delegating to sub-agent #{agent_id} (task: {task[:80]}{'...' if len(task) > 80 else ''})[/dim]")
1256
-
1257
- if agent_name and agent_name in custom_agent_defs:
1258
- agent_def = custom_agent_defs[agent_name]
1246
+ _agent_name = str(agent_name).strip() if agent_name else ""
1247
+
1248
+ # Built-in explorer agent — fast, read-only codebase exploration
1249
+ if _agent_name == "explorer":
1250
+ from aru.agents.explorer import create_explorer
1251
+ sub = create_explorer(task, context)
1252
+ sub.name = f"Explorer-{agent_id}"
1253
+ elif _agent_name and _agent_name in custom_agent_defs:
1254
+ agent_def = custom_agent_defs[_agent_name]
1259
1255
  agent_perm = agent_def.permission
1260
1256
  tools = resolve_tools(agent_def.tools) if agent_def.tools else list(_SUBAGENT_TOOLS)
1261
1257
  tools = [t for t in tools if t is not delegate_task]
@@ -1288,15 +1284,16 @@ Do not create documentation files unless explicitly asked.
1288
1284
  markdown=True,
1289
1285
  )
1290
1286
 
1287
+ label = f"Explorer-{agent_id}" if _agent_name == "explorer" else f"SubAgent-{agent_id}"
1291
1288
  try:
1292
1289
  from aru.permissions import permission_scope
1293
1290
  with permission_scope(agent_perm):
1294
1291
  result = await sub.arun(task, stream=False)
1295
1292
  if result and result.content:
1296
- return _truncate_output(f"[SubAgent-{agent_id}] {result.content}")
1297
- return f"[SubAgent-{agent_id}] Task completed but no output was returned."
1293
+ return _truncate_output(f"[{label}] {result.content}")
1294
+ return f"[{label}] Task completed but no output was returned."
1298
1295
  except Exception as e:
1299
- return f"[SubAgent-{agent_id}] Error: {e}"
1296
+ return f"[{label}] Error: {e}"
1300
1297
 
1301
1298
  # Run in a separate asyncio Task so each sub-agent gets its own
1302
1299
  # contextvars snapshot — essential for parallel permission_scope isolation.
@@ -1408,15 +1405,21 @@ def _update_delegate_task_docstring():
1408
1405
  Args:
1409
1406
  task: What the sub-agent should do.
1410
1407
  context: Optional extra context (file paths, constraints).
1411
- agent: Name of a specialized agent to use. ALWAYS prefer a specialized agent when one matches the task."""
1408
+ agent_name: Name of a specialized agent to use. ALWAYS prefer a specialized agent when one matches the task.
1409
+
1410
+ Built-in agents (always available):
1411
+ - agent_name="explorer": Fast read-only codebase exploration agent. Use for searching files, \
1412
+ finding patterns, reading code, and understanding code structure. Optimized for speed with parallel tool calls. \
1413
+ When calling this agent, specify the desired thoroughness level: "quick" for basic searches, \
1414
+ "medium" for moderate exploration, or "very thorough" for comprehensive analysis."""
1412
1415
 
1413
1416
  custom_agent_defs = get_ctx().custom_agent_defs
1414
1417
  if custom_agent_defs:
1415
- lines = [f"\n\n IMPORTANT: When a specialized agent matches the task, you MUST pass its name in the agent parameter."]
1416
- lines.append(f" Available specialized agents:")
1418
+ lines = [f"\n\n IMPORTANT: When a specialized agent matches the task, you MUST pass its name in the agent_name parameter."]
1419
+ lines.append(f" Additional specialized agents:")
1417
1420
  for name, agent_def in custom_agent_defs.items():
1418
- lines.append(f" - agent=\"{name}\": {agent_def.description}")
1419
- lines.append(f"\n If no specialized agent fits, omit the agent parameter to use a generic sub-agent.")
1421
+ lines.append(f' - agent_name="{name}": {agent_def.description}')
1422
+ lines.append(f"\n If no specialized agent fits, omit the agent_name parameter to use a generic sub-agent.")
1420
1423
  base_doc += "\n".join(lines)
1421
1424
 
1422
1425
  delegate_task.__doc__ = base_doc
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.23.0
3
+ Version: 0.24.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
@@ -20,6 +20,7 @@ aru/session.py
20
20
  aru/agents/__init__.py
21
21
  aru/agents/base.py
22
22
  aru/agents/executor.py
23
+ aru/agents/explorer.py
23
24
  aru/agents/planner.py
24
25
  aru/plugins/__init__.py
25
26
  aru/plugins/custom_tools.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aru-code"
7
- version = "0.23.0"
7
+ version = "0.24.0"
8
8
  description = "A Claude Code clone built with Agno agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -623,7 +623,7 @@ class TestDelegateTaskDocstring:
623
623
  }
624
624
  set_custom_agents(agents)
625
625
  doc = delegate_task.__doc__
626
- assert 'agent="reviewer"' in doc
626
+ assert 'agent_name="reviewer"' in doc
627
627
  assert "Review code for quality" in doc
628
628
  # Primary agents should not be listed (only subagents are registered)
629
629
  assert "Primary" not in doc
@@ -1 +0,0 @@
1
- __version__ = "0.23.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes