aru-code 0.10.0__tar.gz → 0.11.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 (57) hide show
  1. {aru_code-0.10.0/aru_code.egg-info → aru_code-0.11.0}/PKG-INFO +33 -4
  2. {aru_code-0.10.0 → aru_code-0.11.0}/README.md +32 -3
  3. aru_code-0.11.0/aru/__init__.py +1 -0
  4. {aru_code-0.10.0 → aru_code-0.11.0}/aru/cli.py +14 -8
  5. {aru_code-0.10.0 → aru_code-0.11.0}/aru/completers.py +29 -8
  6. {aru_code-0.10.0 → aru_code-0.11.0}/aru/runner.py +8 -3
  7. {aru_code-0.10.0 → aru_code-0.11.0}/aru/tools/codebase.py +17 -4
  8. {aru_code-0.10.0 → aru_code-0.11.0/aru_code.egg-info}/PKG-INFO +33 -4
  9. {aru_code-0.10.0 → aru_code-0.11.0}/pyproject.toml +1 -1
  10. {aru_code-0.10.0 → aru_code-0.11.0}/tests/test_cli.py +5 -5
  11. {aru_code-0.10.0 → aru_code-0.11.0}/tests/test_cli_completers.py +97 -1
  12. aru_code-0.10.0/aru/__init__.py +0 -1
  13. {aru_code-0.10.0 → aru_code-0.11.0}/LICENSE +0 -0
  14. {aru_code-0.10.0 → aru_code-0.11.0}/aru/agent_factory.py +0 -0
  15. {aru_code-0.10.0 → aru_code-0.11.0}/aru/agents/__init__.py +0 -0
  16. {aru_code-0.10.0 → aru_code-0.11.0}/aru/agents/base.py +0 -0
  17. {aru_code-0.10.0 → aru_code-0.11.0}/aru/agents/executor.py +0 -0
  18. {aru_code-0.10.0 → aru_code-0.11.0}/aru/agents/planner.py +0 -0
  19. {aru_code-0.10.0 → aru_code-0.11.0}/aru/commands.py +0 -0
  20. {aru_code-0.10.0 → aru_code-0.11.0}/aru/config.py +0 -0
  21. {aru_code-0.10.0 → aru_code-0.11.0}/aru/context.py +0 -0
  22. {aru_code-0.10.0 → aru_code-0.11.0}/aru/display.py +0 -0
  23. {aru_code-0.10.0 → aru_code-0.11.0}/aru/permissions.py +0 -0
  24. {aru_code-0.10.0 → aru_code-0.11.0}/aru/providers.py +0 -0
  25. {aru_code-0.10.0 → aru_code-0.11.0}/aru/runtime.py +0 -0
  26. {aru_code-0.10.0 → aru_code-0.11.0}/aru/session.py +0 -0
  27. {aru_code-0.10.0 → aru_code-0.11.0}/aru/tools/__init__.py +0 -0
  28. {aru_code-0.10.0 → aru_code-0.11.0}/aru/tools/ast_tools.py +0 -0
  29. {aru_code-0.10.0 → aru_code-0.11.0}/aru/tools/gitignore.py +0 -0
  30. {aru_code-0.10.0 → aru_code-0.11.0}/aru/tools/mcp_client.py +0 -0
  31. {aru_code-0.10.0 → aru_code-0.11.0}/aru/tools/ranker.py +0 -0
  32. {aru_code-0.10.0 → aru_code-0.11.0}/aru/tools/tasklist.py +0 -0
  33. {aru_code-0.10.0 → aru_code-0.11.0}/aru_code.egg-info/SOURCES.txt +0 -0
  34. {aru_code-0.10.0 → aru_code-0.11.0}/aru_code.egg-info/dependency_links.txt +0 -0
  35. {aru_code-0.10.0 → aru_code-0.11.0}/aru_code.egg-info/entry_points.txt +0 -0
  36. {aru_code-0.10.0 → aru_code-0.11.0}/aru_code.egg-info/requires.txt +0 -0
  37. {aru_code-0.10.0 → aru_code-0.11.0}/aru_code.egg-info/top_level.txt +0 -0
  38. {aru_code-0.10.0 → aru_code-0.11.0}/setup.cfg +0 -0
  39. {aru_code-0.10.0 → aru_code-0.11.0}/tests/test_agents_base.py +0 -0
  40. {aru_code-0.10.0 → aru_code-0.11.0}/tests/test_ast_tools.py +0 -0
  41. {aru_code-0.10.0 → aru_code-0.11.0}/tests/test_cli_advanced.py +0 -0
  42. {aru_code-0.10.0 → aru_code-0.11.0}/tests/test_cli_base.py +0 -0
  43. {aru_code-0.10.0 → aru_code-0.11.0}/tests/test_cli_new.py +0 -0
  44. {aru_code-0.10.0 → aru_code-0.11.0}/tests/test_cli_run_cli.py +0 -0
  45. {aru_code-0.10.0 → aru_code-0.11.0}/tests/test_cli_session.py +0 -0
  46. {aru_code-0.10.0 → aru_code-0.11.0}/tests/test_cli_shell.py +0 -0
  47. {aru_code-0.10.0 → aru_code-0.11.0}/tests/test_codebase.py +0 -0
  48. {aru_code-0.10.0 → aru_code-0.11.0}/tests/test_config.py +0 -0
  49. {aru_code-0.10.0 → aru_code-0.11.0}/tests/test_context.py +0 -0
  50. {aru_code-0.10.0 → aru_code-0.11.0}/tests/test_executor.py +0 -0
  51. {aru_code-0.10.0 → aru_code-0.11.0}/tests/test_gitignore.py +0 -0
  52. {aru_code-0.10.0 → aru_code-0.11.0}/tests/test_main.py +0 -0
  53. {aru_code-0.10.0 → aru_code-0.11.0}/tests/test_mcp_client.py +0 -0
  54. {aru_code-0.10.0 → aru_code-0.11.0}/tests/test_permissions.py +0 -0
  55. {aru_code-0.10.0 → aru_code-0.11.0}/tests/test_planner.py +0 -0
  56. {aru_code-0.10.0 → aru_code-0.11.0}/tests/test_providers.py +0 -0
  57. {aru_code-0.10.0 → aru_code-0.11.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.10.0
3
+ Version: 0.11.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
@@ -55,6 +55,7 @@ An intelligent coding assistant for the terminal, powered by LLMs and [Agno](htt
55
55
 
56
56
  - **Multi-Agent Architecture** — Specialized agents for planning, execution, and conversation
57
57
  - **Interactive CLI** — Streaming responses, multi-line paste, session management
58
+ - **Image Support** — Attach images via `@` mentions for multimodal analysis (Claude, GPT-4o, Gemini)
58
59
  - **16 Integrated Tools** — File operations, code search, shell, web search, task delegation
59
60
  - **Task Planning** — Break down complex tasks into steps with automatic execution
60
61
  - **Multi-Provider** — Anthropic, OpenAI, Ollama, Groq, OpenRouter, DeepSeek, and others via custom configuration
@@ -69,7 +70,7 @@ An intelligent coding assistant for the terminal, powered by LLMs and [Agno](htt
69
70
  pip install aru-code
70
71
  ```
71
72
 
72
- > **Requirements:** Python 3.13+
73
+ > **Requirements:** Python 3.11+
73
74
 
74
75
  ### 2. Configure the API Key
75
76
 
@@ -131,6 +132,23 @@ aru> ! pytest tests/ -v
131
132
  aru> /model ollama/codellama
132
133
  ```
133
134
 
135
+ ### Image Support
136
+
137
+ Attach images to your messages using the same `@` mention syntax used for files. Aru detects image files by extension and sends them to the LLM as visual content for multimodal analysis.
138
+
139
+ **Supported formats:** `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.bmp`
140
+
141
+ ```
142
+ aru> describe @screenshot.png
143
+ aru> compare @before.png and @after.png
144
+ aru> review @code.py and explain the diagram in @architecture.png
145
+ aru> analyze @D:/full/path/to/image.jpg
146
+ ```
147
+
148
+ Images are sent natively to the model via the provider's multimodal API — no base64 text is injected into the conversation. Works with any multimodal model (Claude Opus/Sonnet, GPT-4o, Gemini, etc.). The autocomplete shows an `[image]` label for image files.
149
+
150
+ > **Note:** Images require a multimodal model. Local models via Ollama may not support image input. Maximum file size: 20MB.
151
+
134
152
  ## Configuration
135
153
 
136
154
  ### Models and Providers
@@ -374,11 +392,22 @@ performance, and readability. Do NOT modify files.
374
392
 
375
393
  #### Invocation
376
394
 
395
+ There are three ways to invoke a custom agent:
396
+
397
+ | Method | Syntax | When to use |
398
+ |--------|--------|-------------|
399
+ | **Slash command** | `/reviewer src/auth.py` | Directly invoke a `primary` agent by name |
400
+ | **@mention** | `@reviewer check this function` | Mention an agent anywhere in your message |
401
+ | **delegate_task** | Automatic (subagents only) | Subagent names and descriptions are injected into the `delegate_task` tool description, so the LLM sees them and can call `delegate_task(task="...", agent="name")` on its own when it judges the task fits |
402
+
377
403
  ```
378
- aru> /reviewer src/auth.py # invoke by slash + filename (without .md)
379
- aru> /agents # list all custom agents
404
+ aru> /reviewer src/auth.py # slash command (primary agents)
405
+ aru> @reviewer check the auth module # @mention (primary or subagent)
406
+ aru> /agents # list all custom agents
380
407
  ```
381
408
 
409
+ > **Note:** Slash commands (`/name`) are only available for `primary` agents — subagents are blocked with a warning. `@mention` works for any agent regardless of mode. Subagents can be invoked in two ways: automatically by the LLM via `delegate_task`, or manually by the user via `@name`.
410
+
382
411
  #### Discovery paths
383
412
 
384
413
  Agents are discovered from multiple locations (later overrides earlier):
@@ -8,6 +8,7 @@ An intelligent coding assistant for the terminal, powered by LLMs and [Agno](htt
8
8
 
9
9
  - **Multi-Agent Architecture** — Specialized agents for planning, execution, and conversation
10
10
  - **Interactive CLI** — Streaming responses, multi-line paste, session management
11
+ - **Image Support** — Attach images via `@` mentions for multimodal analysis (Claude, GPT-4o, Gemini)
11
12
  - **16 Integrated Tools** — File operations, code search, shell, web search, task delegation
12
13
  - **Task Planning** — Break down complex tasks into steps with automatic execution
13
14
  - **Multi-Provider** — Anthropic, OpenAI, Ollama, Groq, OpenRouter, DeepSeek, and others via custom configuration
@@ -22,7 +23,7 @@ An intelligent coding assistant for the terminal, powered by LLMs and [Agno](htt
22
23
  pip install aru-code
23
24
  ```
24
25
 
25
- > **Requirements:** Python 3.13+
26
+ > **Requirements:** Python 3.11+
26
27
 
27
28
  ### 2. Configure the API Key
28
29
 
@@ -84,6 +85,23 @@ aru> ! pytest tests/ -v
84
85
  aru> /model ollama/codellama
85
86
  ```
86
87
 
88
+ ### Image Support
89
+
90
+ Attach images to your messages using the same `@` mention syntax used for files. Aru detects image files by extension and sends them to the LLM as visual content for multimodal analysis.
91
+
92
+ **Supported formats:** `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.bmp`
93
+
94
+ ```
95
+ aru> describe @screenshot.png
96
+ aru> compare @before.png and @after.png
97
+ aru> review @code.py and explain the diagram in @architecture.png
98
+ aru> analyze @D:/full/path/to/image.jpg
99
+ ```
100
+
101
+ Images are sent natively to the model via the provider's multimodal API — no base64 text is injected into the conversation. Works with any multimodal model (Claude Opus/Sonnet, GPT-4o, Gemini, etc.). The autocomplete shows an `[image]` label for image files.
102
+
103
+ > **Note:** Images require a multimodal model. Local models via Ollama may not support image input. Maximum file size: 20MB.
104
+
87
105
  ## Configuration
88
106
 
89
107
  ### Models and Providers
@@ -327,11 +345,22 @@ performance, and readability. Do NOT modify files.
327
345
 
328
346
  #### Invocation
329
347
 
348
+ There are three ways to invoke a custom agent:
349
+
350
+ | Method | Syntax | When to use |
351
+ |--------|--------|-------------|
352
+ | **Slash command** | `/reviewer src/auth.py` | Directly invoke a `primary` agent by name |
353
+ | **@mention** | `@reviewer check this function` | Mention an agent anywhere in your message |
354
+ | **delegate_task** | Automatic (subagents only) | Subagent names and descriptions are injected into the `delegate_task` tool description, so the LLM sees them and can call `delegate_task(task="...", agent="name")` on its own when it judges the task fits |
355
+
330
356
  ```
331
- aru> /reviewer src/auth.py # invoke by slash + filename (without .md)
332
- aru> /agents # list all custom agents
357
+ aru> /reviewer src/auth.py # slash command (primary agents)
358
+ aru> @reviewer check the auth module # @mention (primary or subagent)
359
+ aru> /agents # list all custom agents
333
360
  ```
334
361
 
362
+ > **Note:** Slash commands (`/name`) are only available for `primary` agents — subagents are blocked with a warning. `@mention` works for any agent regardless of mode. Subagents can be invoked in two ways: automatically by the LLM via `delegate_task`, or manually by the user via `@name`.
363
+
335
364
  #### Discovery paths
336
365
 
337
366
  Agents are discovered from multiple locations (later overrides earlier):
@@ -0,0 +1 @@
1
+ __version__ = "0.11.0"
@@ -230,9 +230,15 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
230
230
 
231
231
  # Resolve @file mentions (skip known agent names)
232
232
  _agent_names = set(config.custom_agents.keys()) if config.custom_agents else set()
233
- resolved, injected = _resolve_mentions(user_input, os.getcwd(), _agent_names)
234
- if resolved != user_input:
235
- console.print(f"[dim]Attached {injected} file(s) from @ mentions[/dim]")
233
+ resolved, injected, attached_images = _resolve_mentions(user_input, os.getcwd(), _agent_names)
234
+ if injected > 0:
235
+ parts = []
236
+ text_count = injected - len(attached_images)
237
+ if text_count > 0:
238
+ parts.append(f"{text_count} file(s)")
239
+ if attached_images:
240
+ parts.append(f"{len(attached_images)} image(s)")
241
+ console.print(f"[dim]Attached {', '.join(parts)} from @ mentions[/dim]")
236
242
  user_input = resolved
237
243
 
238
244
  if paste_state.pasted_content and user_text:
@@ -441,7 +447,7 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
441
447
  else:
442
448
  agent = create_general_agent(session, config)
443
449
  session.add_message("user", user_input)
444
- run_result = await run_agent_capture(agent, prompt, session)
450
+ run_result = await run_agent_capture(agent, prompt, session, images=attached_images or None)
445
451
  if run_result.content:
446
452
  session.add_message("assistant", run_result.with_tools_summary())
447
453
  elif cmd_name in config.skills:
@@ -454,7 +460,7 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
454
460
 
455
461
  agent = create_general_agent(session, config)
456
462
  session.add_message("user", user_input)
457
- run_result = await run_agent_capture(agent, prompt, session)
463
+ run_result = await run_agent_capture(agent, prompt, session, images=attached_images or None)
458
464
  if run_result.content:
459
465
  session.add_message("assistant", run_result.with_tools_summary())
460
466
  elif cmd_name in config.custom_agents:
@@ -467,7 +473,7 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
467
473
  agent = create_custom_agent_instance(agent_def, session, config)
468
474
  session.add_message("user", user_input)
469
475
  with permission_scope(agent_def.permission):
470
- run_result = await run_agent_capture(agent, cmd_args or user_input, session)
476
+ run_result = await run_agent_capture(agent, cmd_args or user_input, session, images=attached_images or None)
471
477
  if run_result.content:
472
478
  session.add_message("assistant", run_result.with_tools_summary())
473
479
  else:
@@ -495,13 +501,13 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
495
501
  agent = create_custom_agent_instance(agent_def, session, config)
496
502
  session.add_message("user", user_input)
497
503
  with permission_scope(agent_def.permission):
498
- run_result = await run_agent_capture(agent, message_text, session)
504
+ run_result = await run_agent_capture(agent, message_text, session, images=attached_images or None)
499
505
  if run_result.content:
500
506
  session.add_message("assistant", run_result.with_tools_summary())
501
507
  else:
502
508
  agent = create_general_agent(session, config)
503
509
  session.add_message("user", user_input)
504
- run_result = await run_agent_capture(agent, user_input, session)
510
+ run_result = await run_agent_capture(agent, user_input, session, images=attached_images or None)
505
511
  if run_result.content:
506
512
  session.add_message("assistant", run_result.with_tools_summary())
507
513
 
@@ -12,25 +12,31 @@ from prompt_toolkit.formatted_text import HTML
12
12
  from prompt_toolkit.key_binding import KeyBindings
13
13
  from prompt_toolkit.keys import Keys
14
14
 
15
+ from agno.media import Image
16
+
15
17
  from aru.commands import SLASH_COMMANDS
16
18
  from aru.config import AgentConfig
17
19
 
18
- _MENTION_RE = re.compile(r'(?<!\S)@([a-zA-Z0-9_./\\-]+)')
20
+ _MENTION_RE = re.compile(r'(?<!\S)@([a-zA-Z0-9_./\\:-]+)')
19
21
  _MENTION_MAX_SIZE = 30_000 # bytes, same limit as read_file
22
+ _IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
23
+ _IMAGE_MAX_SIZE = 20 * 1024 * 1024 # 20MB
20
24
 
21
25
 
22
- def _resolve_mentions(text: str, cwd: str, agent_names: set[str] | None = None) -> tuple[str, int]:
26
+ def _resolve_mentions(text: str, cwd: str, agent_names: set[str] | None = None) -> tuple[str, int, list[Image]]:
23
27
  """Resolve @file mentions by appending file contents to the message.
24
28
 
29
+ Image files (png, jpg, etc.) are returned as Image objects instead of text.
25
30
  Skips @mentions that match known agent names.
26
- Returns (resolved_text, number_of_files_attached).
31
+ Returns (resolved_text, number_of_files_attached, images).
27
32
  """
28
33
  agent_names = agent_names or set()
29
34
  matches = list(_MENTION_RE.finditer(text))
30
35
  if not matches:
31
- return text, 0
36
+ return text, 0, []
32
37
 
33
38
  appendix_parts = []
39
+ images: list[Image] = []
34
40
  seen = set()
35
41
  for m in matches:
36
42
  rel_path = m.group(1)
@@ -39,9 +45,21 @@ def _resolve_mentions(text: str, cwd: str, agent_names: set[str] | None = None)
39
45
  if rel_path in seen:
40
46
  continue
41
47
  seen.add(rel_path)
42
- abs_path = os.path.join(cwd, rel_path)
48
+ abs_path = rel_path if os.path.isabs(rel_path) else os.path.join(cwd, rel_path)
43
49
  if not os.path.isfile(abs_path):
44
50
  continue
51
+
52
+ ext = os.path.splitext(rel_path)[1].lower()
53
+ if ext in _IMAGE_EXTENSIONS:
54
+ try:
55
+ size = os.path.getsize(abs_path)
56
+ if size > _IMAGE_MAX_SIZE:
57
+ continue
58
+ images.append(Image(filepath=abs_path, id=rel_path))
59
+ except OSError:
60
+ pass
61
+ continue
62
+
45
63
  try:
46
64
  size = os.path.getsize(abs_path)
47
65
  with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
@@ -57,9 +75,10 @@ def _resolve_mentions(text: str, cwd: str, agent_names: set[str] | None = None)
57
75
  except OSError:
58
76
  continue
59
77
 
78
+ attached = len(appendix_parts) + len(images)
60
79
  if appendix_parts:
61
- return text + "".join(appendix_parts), len(appendix_parts)
62
- return text, 0
80
+ return text + "".join(appendix_parts), attached, images
81
+ return text, attached, images
63
82
 
64
83
 
65
84
  def _extract_agent_mention(
@@ -206,7 +225,9 @@ class FileMentionCompleter(Completer):
206
225
 
207
226
  is_dir = os.path.isdir(full_path)
208
227
  display_text = rel_prefix + entry + ("/" if is_dir else "")
209
- meta = "dir" if is_dir else ""
228
+ file_ext = os.path.splitext(entry)[1].lower()
229
+ is_image = not is_dir and file_ext in _IMAGE_EXTENSIONS
230
+ meta = "dir" if is_dir else ("image" if is_image else "")
210
231
 
211
232
  yield Completion(
212
233
  display_text,
@@ -43,7 +43,8 @@ class AgentRunResult:
43
43
  return f"{self.content}\n\n[Tools]\n{tools_section}"
44
44
 
45
45
 
46
- async def run_agent_capture(agent, message: str, session=None, lightweight: bool = False) -> AgentRunResult:
46
+ async def run_agent_capture(agent, message: str, session=None, lightweight: bool = False,
47
+ images: list | None = None) -> AgentRunResult:
47
48
  """Run agent with async streaming display and parallel tool execution.
48
49
 
49
50
  Args:
@@ -51,6 +52,7 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
51
52
  message: The user message/prompt.
52
53
  session: Optional session for history and context.
53
54
  lightweight: If True, skip tree/git/plan context and history (for executor steps).
55
+ images: Optional list of agno.media.Image objects to attach.
54
56
 
55
57
  Returns:
56
58
  AgentRunResult with text output and list of tool call labels.
@@ -116,7 +118,7 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
116
118
 
117
119
  # Combine: history messages + current enriched message
118
120
  if history_messages:
119
- history_messages.append(Message(role="user", content=run_message))
121
+ history_messages.append(Message(role="user", content=run_message, images=images or None))
120
122
  agent_input = history_messages
121
123
  else:
122
124
  agent_input = run_message
@@ -130,7 +132,10 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
130
132
  _stall_counter = 0
131
133
  _stalled = False
132
134
  _STALL_LIMIT = 20
133
- async for event in agent.arun(agent_input, stream=True, stream_events=True, yield_run_output=True):
135
+ arun_kwargs = dict(stream=True, stream_events=True, yield_run_output=True)
136
+ if isinstance(agent_input, str) and images:
137
+ arun_kwargs["images"] = images
138
+ async for event in agent.arun(agent_input, **arun_kwargs):
134
139
  if isinstance(event, RunOutput):
135
140
  run_output = event
136
141
  break
@@ -1023,8 +1023,19 @@ async def delegate_task(task: str, context: str = "", agent: str = "") -> str:
1023
1023
 
1024
1024
  agent_perm = None
1025
1025
  custom_agent_defs = get_ctx().custom_agent_defs
1026
- if agent and agent in custom_agent_defs:
1027
- agent_def = custom_agent_defs[agent]
1026
+ # Agno may pass the caller Agent object instead of a string — coerce to str
1027
+ agent_name = str(agent) if agent and isinstance(agent, str) else ""
1028
+
1029
+ # Print delegation info so the user sees what's happening
1030
+ from rich.console import Console
1031
+ _console = Console()
1032
+ if agent_name and agent_name in custom_agent_defs:
1033
+ _console.print(f"[dim] → Delegating to agent [bold]{agent_name}[/bold] (task: {task[:80]}{'...' if len(task) > 80 else ''})[/dim]")
1034
+ else:
1035
+ _console.print(f"[dim] → Delegating to sub-agent #{agent_id} (task: {task[:80]}{'...' if len(task) > 80 else ''})[/dim]")
1036
+
1037
+ if agent_name and agent_name in custom_agent_defs:
1038
+ agent_def = custom_agent_defs[agent_name]
1028
1039
  agent_perm = agent_def.permission
1029
1040
  tools = resolve_tools(agent_def.tools) if agent_def.tools else list(_SUBAGENT_TOOLS)
1030
1041
  tools = [t for t in tools if t is not delegate_task]
@@ -1186,13 +1197,15 @@ def _update_delegate_task_docstring():
1186
1197
  Args:
1187
1198
  task: What the sub-agent should do.
1188
1199
  context: Optional extra context (file paths, constraints).
1189
- agent: Optional custom agent name to use instead of the generic sub-agent."""
1200
+ agent: Name of a specialized agent to use. ALWAYS prefer a specialized agent when one matches the task."""
1190
1201
 
1191
1202
  custom_agent_defs = get_ctx().custom_agent_defs
1192
1203
  if custom_agent_defs:
1193
- lines = [f"\n\n Available specialized agents (use the agent parameter to invoke):"]
1204
+ lines = [f"\n\n IMPORTANT: When a specialized agent matches the task, you MUST pass its name in the agent parameter."]
1205
+ lines.append(f" Available specialized agents:")
1194
1206
  for name, agent_def in custom_agent_defs.items():
1195
1207
  lines.append(f" - agent=\"{name}\": {agent_def.description}")
1208
+ lines.append(f"\n If no specialized agent fits, omit the agent parameter to use a generic sub-agent.")
1196
1209
  base_doc += "\n".join(lines)
1197
1210
 
1198
1211
  delegate_task.__doc__ = base_doc
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.10.0
3
+ Version: 0.11.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
@@ -55,6 +55,7 @@ An intelligent coding assistant for the terminal, powered by LLMs and [Agno](htt
55
55
 
56
56
  - **Multi-Agent Architecture** — Specialized agents for planning, execution, and conversation
57
57
  - **Interactive CLI** — Streaming responses, multi-line paste, session management
58
+ - **Image Support** — Attach images via `@` mentions for multimodal analysis (Claude, GPT-4o, Gemini)
58
59
  - **16 Integrated Tools** — File operations, code search, shell, web search, task delegation
59
60
  - **Task Planning** — Break down complex tasks into steps with automatic execution
60
61
  - **Multi-Provider** — Anthropic, OpenAI, Ollama, Groq, OpenRouter, DeepSeek, and others via custom configuration
@@ -69,7 +70,7 @@ An intelligent coding assistant for the terminal, powered by LLMs and [Agno](htt
69
70
  pip install aru-code
70
71
  ```
71
72
 
72
- > **Requirements:** Python 3.13+
73
+ > **Requirements:** Python 3.11+
73
74
 
74
75
  ### 2. Configure the API Key
75
76
 
@@ -131,6 +132,23 @@ aru> ! pytest tests/ -v
131
132
  aru> /model ollama/codellama
132
133
  ```
133
134
 
135
+ ### Image Support
136
+
137
+ Attach images to your messages using the same `@` mention syntax used for files. Aru detects image files by extension and sends them to the LLM as visual content for multimodal analysis.
138
+
139
+ **Supported formats:** `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.bmp`
140
+
141
+ ```
142
+ aru> describe @screenshot.png
143
+ aru> compare @before.png and @after.png
144
+ aru> review @code.py and explain the diagram in @architecture.png
145
+ aru> analyze @D:/full/path/to/image.jpg
146
+ ```
147
+
148
+ Images are sent natively to the model via the provider's multimodal API — no base64 text is injected into the conversation. Works with any multimodal model (Claude Opus/Sonnet, GPT-4o, Gemini, etc.). The autocomplete shows an `[image]` label for image files.
149
+
150
+ > **Note:** Images require a multimodal model. Local models via Ollama may not support image input. Maximum file size: 20MB.
151
+
134
152
  ## Configuration
135
153
 
136
154
  ### Models and Providers
@@ -374,11 +392,22 @@ performance, and readability. Do NOT modify files.
374
392
 
375
393
  #### Invocation
376
394
 
395
+ There are three ways to invoke a custom agent:
396
+
397
+ | Method | Syntax | When to use |
398
+ |--------|--------|-------------|
399
+ | **Slash command** | `/reviewer src/auth.py` | Directly invoke a `primary` agent by name |
400
+ | **@mention** | `@reviewer check this function` | Mention an agent anywhere in your message |
401
+ | **delegate_task** | Automatic (subagents only) | Subagent names and descriptions are injected into the `delegate_task` tool description, so the LLM sees them and can call `delegate_task(task="...", agent="name")` on its own when it judges the task fits |
402
+
377
403
  ```
378
- aru> /reviewer src/auth.py # invoke by slash + filename (without .md)
379
- aru> /agents # list all custom agents
404
+ aru> /reviewer src/auth.py # slash command (primary agents)
405
+ aru> @reviewer check the auth module # @mention (primary or subagent)
406
+ aru> /agents # list all custom agents
380
407
  ```
381
408
 
409
+ > **Note:** Slash commands (`/name`) are only available for `primary` agents — subagents are blocked with a warning. `@mention` works for any agent regardless of mode. Subagents can be invoked in two ways: automatically by the LLM via `delegate_task`, or manually by the user via `@name`.
410
+
382
411
  #### Discovery paths
383
412
 
384
413
  Agents are discovered from multiple locations (later overrides earlier):
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aru-code"
7
- version = "0.10.0"
7
+ version = "0.11.0"
8
8
  description = "A Claude Code clone built with Agno agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -46,32 +46,32 @@ class TestSanitizeInput:
46
46
 
47
47
  class TestResolveMentions:
48
48
  def test_no_mentions(self, tmp_path):
49
- result, count = _resolve_mentions("hello world", str(tmp_path))
49
+ result, count, _imgs = _resolve_mentions("hello world", str(tmp_path))
50
50
  assert result == "hello world"
51
51
  assert count == 0
52
52
 
53
53
  def test_resolves_file_mention(self, tmp_path):
54
54
  (tmp_path / "config.py").write_text("DEBUG = True")
55
- result, count = _resolve_mentions("check @config.py", str(tmp_path))
55
+ result, count, _imgs = _resolve_mentions("check @config.py", str(tmp_path))
56
56
  assert "DEBUG = True" in result
57
57
  assert "Contents of config.py" in result
58
58
  assert count == 1
59
59
 
60
60
  def test_nonexistent_file_ignored(self, tmp_path):
61
- result, count = _resolve_mentions("check @missing.py", str(tmp_path))
61
+ result, count, _imgs = _resolve_mentions("check @missing.py", str(tmp_path))
62
62
  assert result == "check @missing.py"
63
63
  assert count == 0
64
64
 
65
65
  def test_deduplicates_mentions(self, tmp_path):
66
66
  (tmp_path / "file.py").write_text("code")
67
- result, count = _resolve_mentions("@file.py and @file.py", str(tmp_path))
67
+ result, count, _imgs = _resolve_mentions("@file.py and @file.py", str(tmp_path))
68
68
  assert result.count("Contents of file.py") == 1
69
69
  assert count == 1
70
70
 
71
71
  def test_multiple_files(self, tmp_path):
72
72
  (tmp_path / "a.py").write_text("aaa")
73
73
  (tmp_path / "b.py").write_text("bbb")
74
- result, count = _resolve_mentions("@a.py and @b.py", str(tmp_path))
74
+ result, count, _imgs = _resolve_mentions("@a.py and @b.py", str(tmp_path))
75
75
  assert "Contents of a.py" in result
76
76
  assert "Contents of b.py" in result
77
77
  assert count == 2
@@ -7,13 +7,17 @@ from unittest.mock import Mock, patch
7
7
  import pytest
8
8
  from prompt_toolkit.document import Document
9
9
 
10
+ from agno.media import Image
11
+
10
12
  from aru.cli import (
11
13
  SlashCommandCompleter,
12
14
  FileMentionCompleter,
13
15
  AruCompleter,
14
16
  SLASH_COMMANDS,
15
17
  _extract_agent_mention,
18
+ _resolve_mentions,
16
19
  )
20
+ from aru.completers import _IMAGE_EXTENSIONS
17
21
  from aru.config import CustomAgent, CustomCommand
18
22
 
19
23
 
@@ -593,4 +597,96 @@ class TestExtractAgentMention:
593
597
  def test_agent_not_matched_if_attached_to_word(self):
594
598
  agents = self._make_agents()
595
599
  # @reviewer preceded by non-whitespace should not match
596
- assert _extract_agent_mention("email@reviewer", agents) is None
600
+ assert _extract_agent_mention("email@reviewer", agents) is None
601
+
602
+
603
+ # ── Image mention support ──────────────────────────────────────────
604
+
605
+
606
+ class TestImageMentions:
607
+ """Tests for image file detection in @mentions."""
608
+
609
+ def test_resolve_mentions_returns_three_tuple(self, tmp_path):
610
+ result = _resolve_mentions("hello", str(tmp_path))
611
+ assert len(result) == 3
612
+ text, count, images = result
613
+ assert text == "hello"
614
+ assert count == 0
615
+ assert images == []
616
+
617
+ def test_resolve_mentions_image_file(self, tmp_path):
618
+ img = tmp_path / "screenshot.png"
619
+ img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100)
620
+
621
+ text, count, images = _resolve_mentions(
622
+ "analyze @screenshot.png", str(tmp_path)
623
+ )
624
+ assert count == 1
625
+ assert len(images) == 1
626
+ assert isinstance(images[0], Image)
627
+ assert images[0].id == "screenshot.png"
628
+ # Image content should NOT be appended as text
629
+ assert "```" not in text
630
+
631
+ def test_resolve_mentions_mixed_files_and_images(self, tmp_path):
632
+ (tmp_path / "code.py").write_text("print('hello')", encoding="utf-8")
633
+ (tmp_path / "diagram.jpg").write_bytes(b"\xff\xd8\xff" + b"\x00" * 100)
634
+
635
+ text, count, images = _resolve_mentions(
636
+ "review @code.py and @diagram.jpg", str(tmp_path)
637
+ )
638
+ assert count == 2
639
+ assert len(images) == 1
640
+ assert images[0].id == "diagram.jpg"
641
+ # Text file content should be appended
642
+ assert "print('hello')" in text
643
+
644
+ def test_resolve_mentions_multiple_images(self, tmp_path):
645
+ (tmp_path / "a.png").write_bytes(b"\x89PNG" + b"\x00" * 100)
646
+ (tmp_path / "b.webp").write_bytes(b"RIFF" + b"\x00" * 100)
647
+
648
+ text, count, images = _resolve_mentions(
649
+ "compare @a.png @b.webp", str(tmp_path)
650
+ )
651
+ assert count == 2
652
+ assert len(images) == 2
653
+
654
+ def test_resolve_mentions_image_too_large(self, tmp_path):
655
+ img = tmp_path / "huge.png"
656
+ # Write just over the 20MB limit header
657
+ img.write_bytes(b"\x89PNG" + b"\x00" * (20 * 1024 * 1024 + 1))
658
+
659
+ text, count, images = _resolve_mentions(
660
+ "analyze @huge.png", str(tmp_path)
661
+ )
662
+ assert count == 0
663
+ assert len(images) == 0
664
+
665
+ def test_resolve_mentions_all_image_extensions(self, tmp_path):
666
+ for ext in _IMAGE_EXTENSIONS:
667
+ fname = f"test{ext}"
668
+ (tmp_path / fname).write_bytes(b"\x00" * 100)
669
+
670
+ mentions = " ".join(f"@test{ext}" for ext in _IMAGE_EXTENSIONS)
671
+ text, count, images = _resolve_mentions(mentions, str(tmp_path))
672
+ assert len(images) == len(_IMAGE_EXTENSIONS)
673
+
674
+ def test_image_completer_shows_image_metadata(self, tmp_path):
675
+ (tmp_path / "photo.png").touch()
676
+ (tmp_path / "code.py").touch()
677
+
678
+ completer = FileMentionCompleter()
679
+ doc = Document("@")
680
+ with patch("os.getcwd", return_value=str(tmp_path)):
681
+ with patch("aru.tools.gitignore.is_ignored", return_value=False):
682
+ completions = list(completer.get_completions(doc, Mock()))
683
+
684
+ by_name = {c.text: c for c in completions}
685
+ assert "photo.png" in by_name
686
+ assert "code.py" in by_name
687
+ # Image should have "image" in metadata
688
+ photo_meta = str(by_name["photo.png"].display_meta)
689
+ assert "image" in photo_meta
690
+ # Code file should NOT have "image" in metadata
691
+ code_meta = str(by_name["code.py"].display_meta)
692
+ assert "image" not in code_meta
@@ -1 +0,0 @@
1
- __version__ = "0.10.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