Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
de2d4a7
[#23896] Initial commit - ros2 cli commands
Danipiza Dec 3, 2025
c8f2147
[#23896] Updated code to use rich terminal with default tools
Danipiza Dec 4, 2025
50668bc
[#23896] Applied revision
Danipiza Dec 5, 2025
17069b6
[#23896] Changed to load 'default_tools.py' as a submodule
Danipiza Dec 19, 2025
fd1513c
[#23897] Added ros2 publisher/subcriber default tool
Danipiza Jan 8, 2026
b863d72
[#23897] Added ros2 types for publisher/subscriber tool
Danipiza Jan 8, 2026
0d98ff2
[#23897] Updated code with revision suggestions
Danipiza Jan 27, 2026
8caf4cd
[#23897] Fixed rebase changes
Danipiza Jan 28, 2026
b31284a
[#23897] Applied revision
Danipiza Jan 29, 2026
ed2f76f
[#23897] Applied revision
Danipiza Feb 2, 2026
429fbd7
[#23897] Added missing previous commit changes
Danipiza Feb 2, 2026
508c41d
[#23897] Applied revision
Danipiza Feb 4, 2026
0139c33
[#23897] Applied Ruff + notify error in textual pop-up
Danipiza Feb 10, 2026
0289489
[#23897] Applied Ruff after rebase
Danipiza Feb 10, 2026
e9b65dd
[#23897] Applied revision
Danipiza Feb 19, 2026
33b292a
[#23897] Applied revision (Pub/Sub tools + UI updates)
Danipiza Feb 19, 2026
f078003
[#23897] Applied Ruff
Danipiza Feb 19, 2026
3d25f8d
[#23897] Applied revision
Danipiza Feb 24, 2026
a3a8ff5
[#23897] Applied revision (subscribe tool, scroll end, ros2 topic pub…
Danipiza Mar 3, 2026
2d71704
[#23897] Move default node and initialize it
cferreiragonz Mar 18, 2026
8a557fc
[#23897] Improve help message with rerun command
cferreiragonz Mar 18, 2026
dccc5e5
[#23897] Fix bug in several tools
cferreiragonz Mar 18, 2026
d5be74b
[#23897] Add default tools tests
cferreiragonz Mar 18, 2026
3efd9e4
[#23897] Add default tools workflow
cferreiragonz Mar 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,28 @@ jobs:

- name: Run unit tests
run: |
python -m unittest discover -s tests/unittest -t . -p "test*.py" -v
python -m unittest discover -s tests/unittest -t . -p "test*.py" --ignore-patterns "test_default_tools.py" -v

ros2_unittests:
name: ROS 2 unit tests (default tools)
runs-on: ubuntu-24.04
container:
image: eprosima/vulcanexus:kilted-desktop

steps:
- name: Sync repository
uses: eProsima/eProsima-CI/external/checkout@v0

- name: Install VulcanAI library
run: |
python3 -m pip install -U pip --break-system-packages
python3 -m pip install -e .[test] --break-system-packages

- name: Run ROS 2 default tools tests
shell: bash
run: |
source /opt/ros/jazzy/setup.bash
python3 -m unittest discover -s tests/unittest -t . -p "test_default_tools.py" -v

integration:
name: Integration tests (pytest)
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,6 @@ select = ["E", "F", "I"]

[tool.ruff.lint.isort]
known-first-party = ["vulcanai"]

[project.entry-points."ros2_default_tools"]
default_tools = "vulcanai.tools.default_tools"
122 changes: 106 additions & 16 deletions src/vulcanai/console/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class VulcanConsole(App):
# Two panels: left (log + input) and right (history + variables)
# Right panel: 48 characters length
# Left panel: fills remaining space

CSS = """
Screen {
layout: horizontal;
Expand All @@ -74,6 +75,13 @@ class VulcanConsole(App):
border: tall #333333;
}

#streamcontent {
height: 0;
min-height: 0;
border: solid #56AA08;
display: none;
}

#llm_spinner {
height: 0;
display: none;
Expand Down Expand Up @@ -120,6 +128,7 @@ def __init__(
tools_from_entrypoints: str = "",
user_context: str = "",
main_node=None,
default_tools: bool = True,
):
super().__init__() # Textual lib

Expand All @@ -137,10 +146,14 @@ def __init__(
self.model = model
# 'k' value for top_k tools selection
self.k = k
# Flag to indicate if default tools should be enabled
self.default_tools = default_tools
# Iterative mode
self.iterative = iterative
# CustomLogTextArea instance
self.left_pannel = None
self.main_pannel = None
# Subprocess output panel
self.stream_pannel = None
# Logger instance
self.logger = VulcanAILogger.default()
self.logger.set_textualizer_console(TextualLogSink(self))
Expand All @@ -166,6 +179,8 @@ def __init__(

# Streaming task control
self.stream_task = None
# Route logger output to subprocess panel when needed.
self._route_logs_to_stream_panel = False
# Suggestion index for RadioListModal
self.suggestion_index = -1
self.suggestion_index_changed = threading.Event()
Expand All @@ -188,7 +203,8 @@ async def on_mount(self) -> None:
Function called when the console is mounted.
"""

self.left_pannel = self.query_one("#logcontent", CustomLogTextArea)
self.main_pannel = self.query_one("#logcontent", CustomLogTextArea)
self.stream_pannel = self.query_one("#streamcontent", CustomLogTextArea)
self.spinner_status = self.query_one("#llm_spinner", SpinnerStatus)
self.hooks = SpinnerHook(self.spinner_status)

Expand All @@ -197,7 +213,7 @@ async def on_mount(self) -> None:
sys.stdout = StreamToTextual(self, "stdout")
sys.stderr = StreamToTextual(self, "stderr")

if self.main_node is not None:
if self.main_node is not None or self.default_tools:
attach_ros_logger_to_console(self)

self.loop = asyncio.get_running_loop()
Expand All @@ -223,6 +239,9 @@ def compose(self) -> ComposeResult:
with Horizontal():
# Left
with Vertical(id="left"):
# Subprocess stream area (hidden by default, shown on-demand)
streamcontent = CustomLogTextArea(id="streamcontent")
yield streamcontent
# Log Area
logcontent = CustomLogTextArea(id="logcontent")
yield logcontent
Expand All @@ -248,7 +267,6 @@ async def bootstrap(self) -> None:
Blocking operations (file I/O) run in executor, non-blocking in event loop.
"""

# Initialize manager (potentially blocking, run in executor)
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self.init_manager)

Expand Down Expand Up @@ -278,6 +296,15 @@ async def bootstrap(self) -> None:
except Exception:
pass

# Load a default ROS 2 node if default tools are enabled but no node is provided
if self.default_tools and self.main_node is None:
try:
from vulcanai.tools.default_tools import ROS2DefaultToolNode

self.main_node = ROS2DefaultToolNode()
except ImportError:
self.logger.log_console("Unable to load ROS 2 default node for default tools.")

# -- Register tools (file I/O - run in executor) --
# File paths tools
for tool_file_path in self.register_from_file:
Expand All @@ -298,7 +325,7 @@ async def bootstrap(self) -> None:

self.is_ready = True
self.logger.log_console("VulcanAI Interactive Console")
self.logger.log_console("Use <bold>'Ctrl+Q'</bold> to quit.")
self.logger.log_console("Use <bold>'/exit'</bold> or press <bold>'Ctrl+Q'</bold> to quit.")

# Activate the terminal input
self.set_input_enabled(True)
Expand Down Expand Up @@ -345,8 +372,11 @@ def worker(user_input: str = "") -> None:
self.logger.log_console(f"Output of plan: {bb_ret}")

except KeyboardInterrupt:
self.logger.log_msg("<yellow>Exiting...</yellow>")
return
if self.stream_task is None:
self.logger.log_msg("<yellow>Exiting...</yellow>")
else:
self.stream_task.cancel() # triggers CancelledError in the task
self.stream_task = None
except EOFError:
self.logger.log_msg("<yellow>Exiting...</yellow>")
return
Expand Down Expand Up @@ -447,17 +477,20 @@ async def open_checklist(self, tools_list: list[str], active_tools_num: int) ->
self.logger.log_console(f"Deactivated tool <bold>'{tool}'</bold>")

@work
async def open_radiolist(self, option_list: list[str], tool: str = "") -> str:
async def open_radiolist(
self, option_list: list[str], tool: str = "", category: str = "", input_string: str = ""
) -> str:
"""
Function used to open a RadioList ModalScreen in the console.
Used in the tool suggestion selection, for default tools.
"""
# Create the checklist dialog
selected = await self.push_screen_wait(RadioListModal(option_list))
selected = await self.push_screen_wait(RadioListModal(option_list, category, input_string))

if selected is None:
self.logger.log_tool("Suggestion cancelled", tool_name=tool)
self.suggestion_index = -2
self.suggestion_index_changed.set()
return

self.logger.log_tool(f'Selected suggestion: "{option_list[selected]}"', tool_name=tool)
Expand All @@ -484,7 +517,7 @@ def cmd_help(self, _) -> None:
"/<bold>show_history</bold> - Show the current history\n"
"/<bold>clear_history</bold> - Clear the history\n"
"/<bold>plan</bold> - Show the last generated plan\n"
"/<bold>rerun</bold> - Rerun the last plan\n"
"/<bold>rerun 'int'</bold> - Rerun the last plan or the specified plan by index\n"
"/<bold>bb</bold> - Show the last blackboard state\n"
"/<bold>clear</bold> - Clears the console screen\n"
"/<bold>exit</bold> - Exit the console\n"
Expand Down Expand Up @@ -624,12 +657,15 @@ async def _rerun_worker(self, args) -> None:
else:
selected_plan = int(args[0])
if selected_plan < -1:
self.logger.log_console("Usage: /rerun 'int' [int >= -1].")
self.logger.log_console("Usage: /rerun 'int' [int > -1].")
return

if not self.plans_list:
self.logger.log_console("No plan to rerun.")
return
elif selected_plan >= len(self.plans_list):
self.logger.log_console("Selected Plan index do not exists. selected_plan >= len(executed_plans).")
return

self.logger.log_console(f"Rerunning {selected_plan}-th plan...")

Expand Down Expand Up @@ -657,7 +693,9 @@ def cmd_blackboard_state(self, _) -> None:
self.logger.log_console("No blackboard available.")

def cmd_clear(self, _) -> None:
self.left_pannel.clear_console()
if self.stream_pannel is not None:
self.stream_pannel.clear_console()
self.main_pannel.clear_console()

def cmd_quit(self, _) -> None:
self.exit()
Expand All @@ -666,6 +704,54 @@ def cmd_quit(self, _) -> None:

# region Logging

def show_subprocess_panel(self) -> None:
"""
Show the dedicated subprocess output panel at the top of the main panel.
"""
if self.stream_pannel is None:
return

self.stream_pannel.clear_console()
self.stream_pannel.display = True
self.stream_pannel.styles.height = 12
self.stream_pannel.refresh(layout=True)
self.refresh(layout=True)

def change_route_logs(self, value: bool = False) -> None:
"""
Route logger sink output to stream panel.

value = True -> Stream panel
value = False -> Main panel
"""

self._route_logs_to_stream_panel = value

def hide_subprocess_panel(self) -> None:
"""
Hide the subprocess output panel and return space to the main log panel.
"""
if self.stream_pannel is None:
return

self.stream_pannel.display = False
self.stream_pannel.styles.height = 0
self.stream_pannel.refresh(layout=True)
self.refresh(layout=True)

def add_subprocess_line(self, input: str) -> None:
"""
Write output into the dedicated subprocess panel.
"""
if self.stream_pannel is None:
self.add_line(input)
return

lines = input.splitlines()
for line in lines:
if not self.stream_pannel.append_line(line):
self.logger.log_console("Warning: Trying to add an empty subprocess line.")

def add_line(self, input: str, color: str = "", subprocess_flag: bool = False) -> None:
"""
Function used to write an input in the VulcanAI terminal.
Expand All @@ -679,20 +765,24 @@ def add_line(self, input: str, color: str = "", subprocess_flag: bool = False) -
color_begin = f"<{color}>"
color_end = f"</{color}>"

target_panel = self.main_pannel
if self._route_logs_to_stream_panel and self.stream_pannel is not None and self.stream_pannel.display:
target_panel = self.stream_pannel

# Append each line; deque automatically truncates old ones
for line in lines:
line_processed = line
if subprocess_flag:
line_processed = escape(line)
text = f"{color_begin}{line_processed}{color_end}"
if not self.left_pannel.append_line(text):
if not target_panel.append_line(text):
self.logger.log_console("Warning: Trying to add an empty line.")

def delete_last_line(self):
"""
Function used to remove the last line in the VulcanAI terminal.
"""
self.left_pannel.delete_last_row()
self.main_pannel.delete_last_row()

# endregion

Expand Down Expand Up @@ -961,7 +1051,7 @@ async def on_key(self, event: events.Key) -> None:
def set_stream_task(self, input_stream):
"""
Function used in the tools to set the current streaming task.
with this variable the user can finish the execution of the
With this variable the user can finish the execution of the
task by using the signal "Ctrl + C"
"""
self.stream_task = input_stream
Expand Down Expand Up @@ -1088,7 +1178,7 @@ def init_manager(self) -> None:

self.logger.log_console(f"Initializing Manager <bold>'{ConsoleManager.__name__}'</bold>...")

self.manager = ConsoleManager(model=self.model, k=self.k, logger=self.logger)
self.manager = ConsoleManager(model=self.model, k=self.k, logger=self.logger, default_tools=self.default_tools)

self.logger.log_console(f"Manager initialized with model <bold>'{self.model.replace('ollama-', '')}</bold>'")
# Update right panel info
Expand Down
4 changes: 2 additions & 2 deletions src/vulcanai/console/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ class VulcanAILogger:
"executor": "#15B606",
"vulcanai": "#56AA08",
"user": "#91DD16",
"validator": "#C49C00",
"tool": "#EB921E",
"validator": "#9600C4",
"tool": "#C49C00",
"error": "#FF0000",
"console": "#8F6296",
"warning": "#D8C412",
Expand Down
Loading