diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 757b8f4..8759807 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -35,7 +35,49 @@ jobs:
- name: Run unit tests
run: |
- python -m unittest discover -s tests/unittest -t . -p "test*.py" -v
+ mapfile -t test_modules < <(
+ find tests/unittest -maxdepth 1 -name "test*.py" ! -name "test_default_tools.py" -printf "%f\n" |
+ sed 's/\.py$//' |
+ sed 's/^/tests.unittest./' |
+ sort
+ )
+ if [ "${#test_modules[@]}" -eq 0 ]; then
+ echo "No unit test modules found"
+ exit 1
+ fi
+ python -m unittest -v "${test_modules[@]}"
+
+ 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 ROS 2 auxiliary test dependencies
+ run: |
+ apt-get update && apt-get install -y --no-install-recommends \
+ python3-venv \
+ ros-kilted-examples-rclpy-minimal-service \
+ ros-kilted-examples-rclpy-minimal-action-server
+
+ - name: Install VulcanAI library
+ shell: bash
+ run: |
+ python3 -m venv .venv --system-site-packages
+ source .venv/bin/activate
+ python -m pip install -U pip
+ python -m pip install -e .[test]
+
+ - name: Run ROS 2 default tools tests
+ shell: bash
+ run: |
+ source /opt/ros/$ROS_DISTRO/setup.bash
+ source .venv/bin/activate
+ python -m unittest discover -s tests/unittest -t . -p "test_default_tools.py" -v
integration:
name: Integration tests (pytest)
diff --git a/pyproject.toml b/pyproject.toml
index a7ae25c..ee51a38 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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"
diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py
index 064ceab..8ba2ce0 100644
--- a/src/vulcanai/console/console.py
+++ b/src/vulcanai/console/console.py
@@ -50,14 +50,62 @@ def __init__(self, textual_console) -> None:
self.console = textual_console
def write(self, msg: str, color: str = "") -> None:
+ # Logger calls can come from worker threads
+ # Route UI writes to Textual app thread
+ app_thread_id = getattr(self.console, "_thread_id", None)
+ if app_thread_id is not None and threading.get_ident() != app_thread_id:
+ self.console.call_from_thread(self.console.add_line, msg, color)
+ return
+
self.console.add_line(msg, color)
+def ensure_ros_parameter_services(node):
+ """Attach ROS parameter services to a node when they were disabled at construction."""
+ if node is None:
+ return None
+
+ parameter_service = getattr(node, "_parameter_service", None)
+ if parameter_service is not None:
+ return parameter_service
+
+ from rclpy.parameter_service import ParameterService
+
+ parameter_service = ParameterService(node)
+ setattr(node, "_parameter_service", parameter_service)
+ return parameter_service
+
+
+def get_ros_spin_lock(node) -> threading.Lock | None:
+ """Return a shared spin lock stored on the node."""
+ if node is None:
+ return None
+
+ spin_lock = getattr(node, "_vulcanai_spin_lock", None)
+ if spin_lock is None:
+ spin_lock = threading.Lock()
+ setattr(node, "_vulcanai_spin_lock", spin_lock)
+ return spin_lock
+
+
+def has_ros2_runtime() -> bool:
+ """Return True when the ROS 2 Python runtime is available."""
+ try:
+ import rclpy # noqa: F401
+ except ImportError:
+ return False
+ return True
+
+
class VulcanConsole(App):
+ DEFAULT_CMD_PLACEHOLDER = "> "
+ # Extra info when a streaming process is active
+ STREAM_CMD_PLACEHOLDER = "Press Ctrl+C to stop stream | > "
# CSS Styles
# Two panels: left (log + input) and right (history + variables)
# Right panel: 48 characters length
# Left panel: fills remaining space
+
CSS = """
Screen {
layout: horizontal;
@@ -85,15 +133,24 @@ class VulcanConsole(App):
}
#logcontent {
- height: auto;
+ height: 1fr;
min-height: 1;
- max-height: 1fr;
border: tall #333333;
- scrollbar-size-vertical: 0;
- scrollbar-size-horizontal: 0;
+ scrollbar-size-vertical: 1;
+ scrollbar-size-horizontal: 1;
color: #ffffff;
}
+ #streamcontent {
+ height: 0;
+ min-height: 0;
+ border: solid #56AA08;
+ display: none;
+ overflow: auto auto;
+ scrollbar-size-vertical: 1;
+ scrollbar-size-horizontal: 1;
+ }
+
#llm_spinner {
height: 0;
display: none;
@@ -148,6 +205,8 @@ def __init__(
tools_from_entrypoints: str = "",
user_context: str = "",
main_node=None,
+ default_tools: bool = True,
+ debug: bool = False,
):
# Used to set the same textual colors in a docker container
os.environ.setdefault("COLORTERM", "truecolor")
@@ -172,10 +231,16 @@ 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
+ # Enable debug-only logs
+ self.debug_flag = debug
# 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))
@@ -201,12 +266,20 @@ def __init__(
# Streaming task control
self.stream_task = None
+ # Number of active streaming processes routing logs to subprocess panel
+ self._route_logs_to_stream_panel = 0
+ # Visibility state for stream panel routing
+ self._stream_panel_visible = False
+ # Buffered main-log lines emitted while stream panel is open and waiting
+ # for user Ctrl+C closure
+ self._deferred_main_lines_after_stream: list[str] = []
# Suggestion index for RadioListModal
self.suggestion_index = -1
self.suggestion_index_changed = threading.Event()
self._gnome_profile_schema: str | None = None
self._gnome_scrollbar_policy_backup: str | None = None
+ self._main_node_spin_task: asyncio.Task | None = None
async def on_mouse_down(self, event: MouseEvent) -> None:
"""
@@ -226,7 +299,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)
@@ -235,7 +309,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()
@@ -264,10 +338,14 @@ def compose(self) -> ComposeResult:
# Log Area
logcontent = CustomLogTextArea(id="logcontent")
yield logcontent
+ # Subprocess stream area (hidden by default)
+ # Kept below the main log to avoid visually pushing main logs upward
+ streamcontent = CustomLogTextArea(id="streamcontent")
+ yield streamcontent
# Spinner Area
yield SpinnerStatus(logcontent, id="llm_spinner")
# Input Area
- yield Input(placeholder="> ", id="cmd")
+ yield Input(placeholder=self.DEFAULT_CMD_PLACEHOLDER, id="cmd")
# Right
with Vertical(id="right"):
@@ -286,7 +364,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)
@@ -297,6 +374,7 @@ async def bootstrap(self) -> None:
"/edit_tools": self.cmd_edit_tools,
"/change_k": self.cmd_change_k,
"/history": self.cmd_history_index,
+ "/debug": self.cmd_debug,
"/show_history": self.cmd_show_history,
"/clear_history": self.cmd_clear_history,
"/plan": self.cmd_plan,
@@ -316,6 +394,18 @@ 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.")
+
+ if has_ros2_runtime():
+ self._enable_main_node_ros_support()
+
# -- Register tools (file I/O - run in executor) --
# File paths tools
for tool_file_path in self.register_from_file:
@@ -336,7 +426,15 @@ async def bootstrap(self) -> None:
self.is_ready = True
self.logger.log_console("VulcanAI Interactive Console")
- self.logger.log_console("Use 'Ctrl+Q' to quit.")
+ self.logger.log_console("Clipboard: select text and press F4 to copy. Use Ctrl+V or middle-click to paste.")
+ self.logger.log_console("Use '/exit' or press 'Ctrl+Q' to quit.")
+
+ # Anchor the main log at the bottom after all startup output has been queued,
+ # so the user sees the latest line (and input prompt) without manual scrolling.
+ if self.main_pannel is not None:
+ self.main_pannel.scroll_end(animate=False, immediate=True, x_axis=False)
+ self.call_after_refresh(self.main_pannel.scroll_end, animate=False, immediate=True, x_axis=False)
+ self.call_later(self.main_pannel.scroll_end, animate=False, immediate=True, x_axis=False)
# Activate the terminal input
self.set_input_enabled(True)
@@ -358,6 +456,7 @@ def worker(user_input: str = "") -> None:
self.set_input_enabled(False)
try:
+ bb_before_ids = {k: id(v) for k, v in self.manager.bb.items()}
images = []
# Add the images
@@ -376,15 +475,21 @@ def worker(user_input: str = "") -> None:
self.plans_list.append(result.get("plan", None))
self.last_bb = result.get("blackboard", None)
- # Print the backboard state
+ # Print the blackboard state
bb_ret = result.get("blackboard", None)
- if bb_ret:
- bb_ret = bb_ret.text_snapshot().replace("<", "'").replace(">", "'")
- self.logger.log_console(f"Output of plan: {bb_ret}")
+ if bb_ret and self.debug_flag:
+ updated_keys = self._updated_blackboard_keys(bb_before_ids, bb_ret)
+ bb_ret_str = "No new output generated by executed tools."
+ if updated_keys:
+ bb_ret_str = self._format_blackboard_subset(bb_ret, updated_keys)
+ self.logger.log_console(f"Output of plan: {bb_ret_str}")
except KeyboardInterrupt:
- self.logger.log_msg("Exiting...")
- return
+ if self.stream_task is None:
+ self.logger.log_msg("Exiting...")
+ else:
+ self.stream_task.cancel() # triggers CancelledError in the task
+ self.stream_task = None
except EOFError:
self.logger.log_msg("Exiting...")
return
@@ -397,6 +502,69 @@ def worker(user_input: str = "") -> None:
# region Utilities
+ def _updated_blackboard_keys(self, before_ids: dict[str, int], bb_after) -> list[str]:
+ """
+ Return blackboard keys that were created or replaced during the latest execution.
+ """
+ updated_keys = []
+ for key, value in bb_after.items():
+ if key not in before_ids or before_ids[key] != id(value):
+ updated_keys.append(key)
+ return updated_keys
+
+ def _format_blackboard_subset(self, bb, keys: list[str]) -> str:
+ """
+ Format a subset of blackboard keys for debug logging in Textual console.
+ """
+ if not keys:
+ return "{}"
+
+ subset = {k: bb.get(k) for k in keys if k in bb}
+ if not subset:
+ return "{}"
+
+ # Keep output on a single line while avoiding Textual tag parsing issues.
+ return repr(subset).replace("<", "'").replace(">", "'")
+
+ def _enable_main_node_ros_support(self) -> None:
+ """Ensure the shared ROS node is responsive to external ROS CLI calls."""
+ if self.main_node is None or not has_ros2_runtime():
+ return
+
+ get_ros_spin_lock(self.main_node)
+
+ try:
+ ensure_ros_parameter_services(self.main_node)
+ except Exception as e:
+ self.logger.log_console(
+ f"Unable to attach ROS parameter services to the shared node: {e}",
+ )
+
+ if self._main_node_spin_task is None or self._main_node_spin_task.done():
+ self._main_node_spin_task = asyncio.create_task(self._spin_main_node_loop())
+
+ async def _spin_main_node_loop(self) -> None:
+ """Keep the shared ROS node serviced so CLI tools do not time out on it."""
+ try:
+ import rclpy
+ except ImportError:
+ return
+
+ while self.main_node is not None:
+ try:
+ if rclpy.ok():
+ spin_lock = get_ros_spin_lock(self.main_node)
+ if spin_lock is not None and spin_lock.acquire(blocking=False):
+ try:
+ rclpy.spin_once(self.main_node, timeout_sec=0.0)
+ finally:
+ spin_lock.release()
+ except Exception:
+ # The node may be shutting down while the console exits.
+ break
+
+ await asyncio.sleep(0.05)
+
def _apply_history_to_input(self) -> None:
"""
Function used to apply the current history index to the input box.
@@ -458,44 +626,47 @@ def _update_variables_panel(self) -> None:
kvalue_widget.update(text)
@work # Runs in a worker. waiting won't freeze the UI
- async def open_checklist(self, tools_list: list[str], active_tools_num: int) -> None:
+ async def open_checklist(self, grouped: list, active_set: set, default_set: set | None = None) -> None:
"""
Function used to open a Checklist ModalScreen in the console.
Used in the /edit_tools command.
"""
# Create the checklist dialog
- selected = await self.push_screen_wait(CheckListModal(tools_list, active_tools_num))
+ selected = await self.push_screen_wait(CheckListModal(grouped, active_set, default_set))
if selected is None:
self.logger.log_msg("Selection cancelled.")
else:
- # Iterate over all tools and activate/deactivate accordingly
- # to the selection made by the user
- for tool_tmp in tools_list:
- # Remove "- " prefix
- tool = tool_tmp[2:]
-
- if tool_tmp in selected:
- # Current tool checbox, activated
- if self.manager.registry.activate_tool(tool):
- self.logger.log_console(f"Activated tool '{tool}'")
+ selected_set = set(selected)
+ # Collect all non-help tool names
+ all_tool_names = set(self.manager.registry.tools.keys()) | set(
+ self.manager.registry.deactivated_tools.keys()
+ )
+ all_tool_names.discard("help")
+
+ for tool_name in all_tool_names:
+ if tool_name in selected_set:
+ if self.manager.registry.activate_tool(tool_name):
+ self.logger.log_console(self._tool_toggle_log_message(tool_name, activated=True))
else:
- # Current tool checbox, deactivated
- if self.manager.registry.deactivate_tool(tool):
- self.logger.log_console(f"Deactivated tool '{tool}'")
+ if self.manager.registry.deactivate_tool(tool_name):
+ self.logger.log_console(self._tool_toggle_log_message(tool_name, activated=False))
@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)
@@ -519,10 +690,12 @@ def cmd_help(self, _) -> None:
" or show the current value if no 'int' is provided\n"
"/history 'int' - Change the history depth or show the current value if no"
" 'int' is provided\n"
+ "/debug 'bool' - Set debug mode to true/false, or show current value if no"
+ " bool is provided\n"
"/show_history - Show the current history\n"
"/clear_history - Clear the history\n"
"/plan - Show the last generated plan\n"
- "/rerun - Rerun the last plan\n"
+ "/rerun 'int' - Rerun the last plan or the specified plan by index\n"
"/bb - Show the last blackboard state\n"
"/clear - Clears the console screen\n"
"/exit - Exit the console\n"
@@ -534,7 +707,7 @@ def cmd_help(self, _) -> None:
"Available keybinds:\n"
"‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\n"
"F2 - Show this help message\n"
- "F3 - Copy selection area\n"
+ "F4 - Copy selection area\n"
"Ctrl+Q - Exit the console\n"
"Ctrl+L - Clears the console screen\n"
"Ctrl+U - Clears the entire command line input\n"
@@ -548,26 +721,55 @@ def cmd_help(self, _) -> None:
self.logger.log_console(table, "console")
def cmd_tools(self, _) -> None:
+ def get_tool_summary(tool) -> str:
+ summary = getattr(tool, "tool_description", None) or tool.description
+ return escape(summary)
+
tmp_msg = f"(current index k={self.manager.k})"
tool_msg = ("_" * len(tmp_msg)) + "\n"
tool_msg += "Available tools:\n"
tool_msg += tmp_msg + "\n" + ("‾" * len(tmp_msg)) + "\n"
- for tool in self.manager.registry.tools.values():
- tool_msg += f"- {tool.name}: {tool.description}\n"
- self.logger.log_console(tool_msg, "console")
+ tool_names = [name for name in self.manager.registry.tools.keys() if name != "help"]
+ grouped = self.manager.registry.group_tool_names(tool_names)
- def cmd_edit_tools(self, _) -> None:
- tools_list = []
- for tool in self.manager.registry.tools.values():
- tools_list.append(f"- {tool.name}")
+ for entry_name, subtools in grouped:
+ if subtools is None:
+ tool = self.manager.registry.tools[entry_name]
+ tool_msg += f"{tool.name}: {get_tool_summary(tool)}\n"
+ else:
+ tool_msg += f"{entry_name}:\n"
+ for subtool in subtools:
+ full_name = f"{entry_name}_{subtool}"
+ tool = self.manager.registry.tools[full_name]
+ tool_msg += f" - {subtool}: {get_tool_summary(tool)}\n"
- active_tools_num = len(tools_list)
+ if "help" in self.manager.registry.tools:
+ help_tool = self.manager.registry.tools["help"]
+ tool_msg += f"- {help_tool.name}: {get_tool_summary(help_tool)}\n"
- for deactivated_tool in self.manager.registry.deactivated_tools.values():
- tools_list.append(f"- {deactivated_tool.name}")
+ self.logger.log_console(tool_msg, "console")
+
+ def _is_default_tool(self, tool_name: str) -> bool:
+ tool = self.manager.registry._get_tool_by_name(tool_name)
+ return tool is not None and tool.__class__.__module__ == "vulcanai.tools.default_tools"
- self.open_checklist(tools_list, active_tools_num)
+ def _tool_toggle_log_message(self, tool_name: str, activated: bool) -> str:
+ action = "Activated" if activated else "Deactivated"
+ if self._is_default_tool(tool_name):
+ return f"{action} default tool '{tool_name}'"
+ return f"{action} tool '{tool_name}'"
+
+ def cmd_edit_tools(self, _) -> None:
+ all_names = sorted(
+ n
+ for n in list(self.manager.registry.tools.keys()) + list(self.manager.registry.deactivated_tools.keys())
+ if n != "help"
+ )
+ active_set = set(self.manager.registry.tools.keys())
+ default_set = {name for name in all_names if self._is_default_tool(name)}
+ grouped = self.manager.registry.group_tool_names(all_names)
+ self.open_checklist(grouped, active_set, default_set)
def cmd_change_k(self, args) -> None:
if len(args) == 0:
@@ -596,6 +798,23 @@ def cmd_history_index(self, args) -> None:
# Update right panel info
self._update_variables_panel()
+ def cmd_debug(self, args) -> None:
+ if len(args) == 0:
+ self.logger.log_console(f"Current 'debug' is {self.debug_flag}")
+ return
+
+ if len(args) != 1:
+ self.logger.log_console(f"Usage: /debug 'bool' - Actual 'debug' is {self.debug_flag}")
+ return
+
+ value = args[0].strip().lower()
+ if value not in ("true", "false"):
+ self.logger.log_console(f"Usage: /debug 'bool' - Actual 'debug' is {self.debug_flag}")
+ return
+
+ self.debug_flag = value == "true"
+ self.logger.log_console(f"Set 'debug' to {self.debug_flag}")
+
def cmd_show_history(self, _) -> None:
if not self.manager.history:
self.logger.log_console("No history available.")
@@ -662,12 +881,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...")
@@ -680,7 +902,8 @@ async def _rerun_worker(self, args) -> None:
# UI updates must happen on the app thread:
def apply_result():
self.last_bb = last_bb
- self.logger.log_console(f"Output of rerun: {last_bb_parsed}")
+ if self.debug_flag:
+ self.logger.log_console(f"Output of rerun: {last_bb_parsed}")
self.call_from_thread(apply_result)
@@ -695,7 +918,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()
@@ -704,10 +929,167 @@ def cmd_quit(self, _) -> None:
# region Logging
+ def show_subprocess_panel(self, show_notice: bool = True) -> None:
+ """
+ Show the dedicated subprocess output panel at the top of the main panel.
+ """
+
+ if self.stream_pannel is None:
+ return
+
+ follow_main_output = False
+ if self.main_pannel is not None:
+ follow_main_output = self.main_pannel.should_follow_output()
+
+ if show_notice:
+ self.logger.log_tool(
+ "Streaming terminal opened. Press Ctrl+C to stop the running process.", color="tool"
+ )
+
+ self.stream_pannel.clear_console()
+ self.stream_pannel.display = True
+ self.stream_pannel.styles.height = 12
+ self.query_one("#left", Vertical).refresh(layout=True)
+ self.stream_pannel.refresh(layout=True)
+ self.refresh(layout=True)
+ self._stream_panel_visible = True
+ cmd = self.query_one("#cmd", Input)
+ cmd.placeholder = self.STREAM_CMD_PLACEHOLDER
+ if follow_main_output:
+ self.main_pannel.scroll_end(animate=False, immediate=True, x_axis=False)
+ self.call_after_refresh(self.main_pannel.scroll_end, animate=False, immediate=True, x_axis=False)
+ self.call_later(self.main_pannel.scroll_end, animate=False, immediate=True, x_axis=False)
+
+ def change_route_logs(self, value: bool = False) -> None:
+ """
+ Manage logger sink output routing to stream panel.
+
+ value = True -> Increment active streaming routes.
+ value = False -> Decrement active streaming routes.
+ """
+
+ if value:
+ self._route_logs_to_stream_panel += 1
+ if self.stream_pannel is not None and not self._stream_panel_visible:
+ # Routing is active -> ensure stream panel is visible
+ self.show_subprocess_panel(show_notice=False)
+ else:
+ self._route_logs_to_stream_panel = max(0, self._route_logs_to_stream_panel - 1)
+ if not self._is_stream_task_active():
+ # No active stream: clear stale route counter/state, but keep the
+ # stream panel visible until user explicitly closes it with "Ctrl + C"
+ self._route_logs_to_stream_panel = 0
+ self.stream_task = None
+
+ def _is_stream_task_active(self) -> bool:
+ """
+ Return True if a streaming task/future is currently active
+ """
+
+ if self.stream_task is None:
+ return False
+ try:
+ return not self.stream_task.done()
+ except Exception:
+ try:
+ return not self.stream_task.cancelled()
+ except Exception:
+ # Unknown task-like object
+ return True
+
+ def reset_stream_state(self) -> None:
+ """
+ Reset stream routing and panel visibility
+ """
+
+ self._route_logs_to_stream_panel = 0
+ self.stream_task = None
+ if self._stream_panel_visible:
+ self.hide_subprocess_panel()
+
+ def should_defer_line_until_stream_panel_close(self, line: str) -> bool:
+ """
+ Return True when a line should be deferred until the stream panel is closed
+ """
+
+ return any(marker in line for marker in ("[TOOL", "[EXECUTOR]", "[ROS]", "Output of plan:"))
+
+ def write_deferred_main_output(self) -> None:
+ """
+ Flush buffered main-log lines captured while stream panel was open.
+ """
+
+ if not self._deferred_main_lines_after_stream or self.main_pannel is None:
+ return
+
+ follow_main_output = self.main_pannel.should_follow_output()
+ pending = self._deferred_main_lines_after_stream
+ self._deferred_main_lines_after_stream = []
+
+ for text in pending:
+ if not self.main_pannel.append_line(text, force_follow_output=follow_main_output):
+ self.logger.log_console("Warning: Trying to add an empty deferred line.")
+
+ 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
+
+ follow_main_output = False
+ if self.main_pannel is not None:
+ follow_main_output = self.main_pannel.should_follow_output()
+
+ self.stream_pannel.display = False
+ self.stream_pannel.styles.height = 0
+ self.stream_pannel.refresh(layout=True)
+ self.refresh(layout=True)
+ self._stream_panel_visible = False
+ self.write_deferred_main_output()
+ cmd = self.query_one("#cmd", Input)
+ cmd.placeholder = self.DEFAULT_CMD_PLACEHOLDER
+ if follow_main_output:
+ self.main_pannel.scroll_end(animate=False, immediate=True, x_axis=False)
+ self.call_after_refresh(self.main_pannel.scroll_end, animate=False, immediate=True, x_axis=False)
+
+ 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
+
+ # Reopen only if there is an active stream/routing context
+ if not self._stream_panel_visible:
+ # After user send sigint "Ctrl + C" reset both flags
+ # Late subprocess lines should go to main log
+ # without reopening the stream panel
+ should_reopen = self._route_logs_to_stream_panel > 0 or self._is_stream_task_active()
+ if should_reopen:
+ self.show_subprocess_panel(show_notice=False)
+
+ if not self._stream_panel_visible:
+ 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.
"""
+
+ app_thread_id = getattr(self, "_thread_id", None)
+ if app_thread_id is not None and threading.get_ident() != app_thread_id:
+ self.call_from_thread(self.add_line, input, color, subprocess_flag)
+ return
+
# Split incoming text into individual lines
lines = input.splitlines()
@@ -717,20 +1099,46 @@ 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 > 0 and self.stream_pannel is not None:
+ if not self._stream_panel_visible:
+ # If the streaming panel did not showed up
+ # reopen it before routing the output to the stream area
+ self.show_subprocess_panel(show_notice=False)
+ if self._stream_panel_visible:
+ target_panel = self.stream_pannel
+
+ force_follow_main = False
+ if target_panel is self.main_pannel:
+ force_follow_main = self.main_pannel.should_follow_output()
+
# 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):
+
+ # Keep tool/executor output hidden in main panel while stream panel remains
+ # visible after stream completion. Flushes on Ctrl+C panel close
+ if (
+ target_panel is self.main_pannel
+ and self._stream_panel_visible
+ and not self._is_stream_task_active()
+ and self._route_logs_to_stream_panel == 0
+ and self.should_defer_line_until_stream_panel_close(line_processed)
+ ):
+ self._deferred_main_lines_after_stream.append(text)
+ continue
+
+ if not target_panel.append_line(text, force_follow_output=force_follow_main):
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
@@ -754,7 +1162,11 @@ async def on_input_submitted(self, event: Input.Submitted) -> None:
# Console not ready yet
return
- cmd = event.value.strip()
+ # Strip '<' and '>' so inputs like '' (ROS topic names) are not
+ # consumed by the Textual markup parser, which would drop the token from
+ # the echoed line and raise "'' is not a valid color" errors when
+ # the same text flows through manager/plan logs.
+ cmd = event.value.strip().replace("<", "").replace(">", "")
if not cmd:
# Empty command
return
@@ -766,8 +1178,13 @@ async def on_input_submitted(self, event: Input.Submitted) -> None:
# Not the command input box
return
- # Get the user input and strip leading/trailing spaces
- user_input = (event.value or "").strip()
+ # Already sanitized above; reuse to keep echo, history, and query aligned
+ user_input = cmd
+
+ # Defensive recovery: if no stream is active but routing stayed enabled,
+ # clear only routing (keep panel visible until Ctrl+C)
+ if (not self._is_stream_task_active()) and self._route_logs_to_stream_panel > 0:
+ self._route_logs_to_stream_panel = 0
# The the user_input in the history navigation list (used when the up, down keys are pressed)
self.history.append(user_input)
@@ -999,7 +1416,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
@@ -1057,10 +1474,18 @@ def action_stop_streaming_task(self) -> None:
Binding("ctrl+c", "stop_streaming_task", ...),
"""
- if self.stream_task is not None and not self.stream_task.done():
- # Cancel the streaming task
- self.stream_task.cancel() # Triggers CancelledError in the task
- self.stream_task = None
+
+ if self._is_stream_task_active():
+ # Cancel the active streaming task/future.
+ try:
+ self.stream_task.cancel() # Triggers CancelledError / cancelled flag
+ except Exception:
+ pass
+ self.reset_stream_state()
+
+ elif self.stream_pannel is not None and (self._stream_panel_visible or self._route_logs_to_stream_panel > 0):
+ # No active stream, but stale stream UI/routing is visible
+ self.reset_stream_state()
else:
# No streaming task running, just notify the user
@@ -1129,7 +1554,7 @@ def init_manager(self) -> None:
self.logger.log_console(f"Initializing Manager '{ConsoleManager.__name__}'...")
- 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 '{self.model.replace('ollama-', '')}'")
# Update right panel info
@@ -1174,6 +1599,7 @@ def main() -> None:
parser.add_argument(
"-i", "--iterative", action="store_true", default=False, help="Enable Iterative Manager (default: off)"
)
+ parser.add_argument("--debug", action="store_true", default=False, help="Enable debug logs (default: off)")
args = parser.parse_args()
@@ -1183,6 +1609,7 @@ def main() -> None:
model=args.model,
k=args.k,
iterative=args.iterative,
+ debug=args.debug,
)
console.run_console()
diff --git a/src/vulcanai/console/logger.py b/src/vulcanai/console/logger.py
index a6b6f88..4ec0e33 100644
--- a/src/vulcanai/console/logger.py
+++ b/src/vulcanai/console/logger.py
@@ -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",
@@ -107,6 +107,20 @@ def parse_rich_markup(self, msg: str) -> str:
msg = re.sub(r"<(/?)(#[0-9a-fA-F]{6})>", r"[\1\2]", msg)
return msg
+ def _apply_color_to_each_line(self, msg: str, color_begin: str, color_end: str) -> str:
+ """Wrap each logical line with color tags, preserving original line breaks."""
+ if msg == "":
+ return f"{color_begin}{msg}{color_end}"
+
+ colored_lines = []
+ for line in msg.splitlines(keepends=True):
+ # Keep newlines outside style tags so each line has a complete ... pair.
+ line_content = line.rstrip("\r\n")
+ line_break = line[len(line_content) :]
+ colored_lines.append(f"{color_begin}{line_content}{color_end}{line_break}")
+
+ return "".join(colored_lines)
+
def process_msg(self, msg: str, prefix: str = "", color: str = "") -> str:
"""Process the message by adding prefix, applying color formatting and rich markup if enabled."""
color_begin = color_end = color
@@ -116,7 +130,9 @@ def process_msg(self, msg: str, prefix: str = "", color: str = "") -> str:
color_begin = f"<{color}>"
color_end = f"{color}>"
- msg = f"{prefix}{color_begin}{msg}{color_end}"
+ msg = self._apply_color_to_each_line(msg, color_begin, color_end)
+
+ msg = f"{prefix}{msg}"
return self.parse_rich_markup((self.parse_color(msg)))
@@ -158,11 +174,18 @@ def log_tool(self, msg: str, tool_name: str = "", error: bool = False, color: st
processed_msg = self.process_msg(msg, prefix=prefix, color=color)
self.sink.write(processed_msg)
- def log_registry(self, msg: str, error: bool = False, color: str = ""):
+ return processed_msg
+
+ def log_registry(self, msg: str, error: bool = False, color: str = "", indent: int = 0):
+ # indent_str = " " * indent
+ indent_str = " " * indent
if error:
- prefix = "[error][REGISTRY] [ERROR][/error] "
+ prefix = f"[error][REGISTRY] [ERROR][/error]{indent_str} "
+ elif indent > 0:
+ prefix = f"{indent_str} "
+ msg = f"{msg}"
else:
- prefix = "[registry][REGISTRY][/registry] "
+ prefix = f"[registry][REGISTRY][/registry]{indent_str} "
processed_msg = self.process_msg(msg, prefix=prefix, color=color)
self.sink.write(processed_msg)
diff --git a/src/vulcanai/console/modal_screens.py b/src/vulcanai/console/modal_screens.py
index 829ad13..fbbc47a 100644
--- a/src/vulcanai/console/modal_screens.py
+++ b/src/vulcanai/console/modal_screens.py
@@ -14,7 +14,9 @@
from textual import events
from textual.app import ComposeResult
+from textual.binding import Binding
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
+from textual.content import Content
from textual.screen import ModalScreen
from textual.widgets import Button, Checkbox, Input, Label, RadioButton, RadioSet
@@ -144,6 +146,28 @@ async def on_key(self, event: events.Key) -> None:
class CheckListModal(ModalScreen[list[str] | None]):
+ class NavigableCheckbox(Checkbox):
+ BINDINGS = [
+ Binding("enter", "submit_selection", "Submit", show=False),
+ Binding("space", "toggle_button", "Toggle", show=False),
+ Binding("down", "next_checkbox", "Next checkbox", show=False),
+ Binding("up", "previous_checkbox", "Previous checkbox", show=False),
+ ]
+
+ def action_submit_selection(self) -> None:
+ self.screen.dismiss_selected()
+
+ def action_next_checkbox(self) -> None:
+ self.screen.action_next_checkbox()
+
+ def action_previous_checkbox(self) -> None:
+ self.screen.action_previous_checkbox()
+
+ BINDINGS = [
+ Binding("down", "next_checkbox", "Next checkbox", show=False),
+ Binding("up", "previous_checkbox", "Previous checkbox", show=False),
+ ]
+
CSS = """
CheckListModal {
align: center middle;
@@ -169,46 +193,193 @@ class CheckListModal(ModalScreen[list[str] | None]):
/* no max-height, no overflow-y here */
}
+ .group-child {
+ margin-left: 4;
+ margin-right: 2;
+ padding-right: 2;
+ }
+
.btns {
- height: 3; /* give buttons row a fixed height */
- padding-top: 1;
+ height: auto;
+ width: 100%;
+ margin-top: 1;
+ padding: 0;
content-align: right middle;
+ align-horizontal: right;
+ }
+
+ .btns Button {
+ padding: 0 2;
+ margin-left: 1;
}
"""
- def __init__(self, lines: list[str], active_tools_num: int = 0) -> None:
+ def __init__(self, grouped: list, active_tools: set, default_tools: set[str] | None = None) -> None:
super().__init__()
- self.lines = list(lines)
- self.active_tools_num = active_tools_num
+ self.grouped = grouped # [(prefix, [subtools]) | (name, None), ...]
+ self.active_tools = active_tools
+ self.default_tools = default_tools or set()
+ self._parent_to_children: dict[str, list[str]] = {}
+ self._child_to_parent: dict[str, str] = {}
+ self._id_to_tool: dict[str, str] = {} # cb_id -> full tool name (children & standalone only)
def compose(self) -> ComposeResult:
with Vertical(classes="dialog"):
yield Label("Pick tools you want to enable", classes="title")
- # SCROLLABLE CHECKBOX LIST
with VerticalScroll(classes="checkbox-list"):
- for i, line in enumerate(self.lines, start=1):
- yield Checkbox(line, value=i <= self.active_tools_num, id=f"cb{i}")
+ idx = 0
+ for group_name, subtools in self.grouped:
+ if subtools is None:
+ # Standalone tool
+ cb_id = f"cb{idx}"
+ self._id_to_tool[cb_id] = group_name
+ yield self.NavigableCheckbox(
+ group_name,
+ value=group_name in self.active_tools,
+ id=cb_id,
+ )
+ idx += 1
+ else:
+ # Group parent
+ parent_id = f"cb{idx}"
+ idx += 1
+ child_ids = []
+ for subtool in subtools:
+ child_id = f"cb{idx}"
+ full_name = f"{group_name}_{subtool}"
+ self._id_to_tool[child_id] = full_name
+ child_ids.append(child_id)
+ idx += 1
+
+ self._parent_to_children[parent_id] = child_ids
+ for cid in child_ids:
+ self._child_to_parent[cid] = parent_id
+
+ all_active = all(f"{group_name}_{s}" in self.active_tools for s in subtools)
+ yield self.NavigableCheckbox(group_name, value=all_active, id=parent_id)
+
+ for subtool, child_id in zip(subtools, child_ids):
+ full_name = f"{group_name}_{subtool}"
+ yield self.NavigableCheckbox(
+ subtool,
+ value=full_name in self.active_tools,
+ id=child_id,
+ classes="group-child",
+ )
- # Buttons
with Horizontal(classes="btns"):
+ yield Button("Toggle Default Tools", variant="default", id="toggle-default")
+ yield Button("Toggle All Tools", variant="default", id="toggle-all")
yield Button("Cancel", variant="default", id="cancel")
yield Button("Submit", variant="primary", id="submit")
+ def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
+ cb_id = event.checkbox.id
+
+ if cb_id in self._parent_to_children:
+ # Parent toggled -> set all children to same value
+ with self.prevent(Checkbox.Changed):
+ for child_id in self._parent_to_children[cb_id]:
+ self.query_one(f"#{child_id}", Checkbox).value = event.value
+
+ elif cb_id in self._child_to_parent:
+ # Child toggled -> update parent (checked only when ALL children checked)
+ parent_id = self._child_to_parent[cb_id]
+ all_checked = all(self.query_one(f"#{cid}", Checkbox).value for cid in self._parent_to_children[parent_id])
+ with self.prevent(Checkbox.Changed):
+ self.query_one(f"#{parent_id}", Checkbox).value = all_checked
+
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "submit":
- boxes = list(self.query(Checkbox))
- selected = [self.lines[i] for i, cb in enumerate(boxes) if cb.value]
- self.dismiss(selected)
+ self.dismiss_selected()
+ elif event.button.id == "toggle-all":
+ self._toggle_tool_subset(set(self._id_to_tool.values()))
+ elif event.button.id == "toggle-default":
+ self._toggle_tool_subset(self.default_tools)
elif event.button.id == "cancel":
self.dismiss(None)
+ def dismiss_selected(self) -> None:
+ selected = [
+ tool_name for cb_id, tool_name in self._id_to_tool.items() if self.query_one(f"#{cb_id}", Checkbox).value
+ ]
+ self.dismiss(selected)
+
+ def _set_tool_subset(self, tool_names: set[str], enabled: bool) -> None:
+ matched_tool_names = {tool_name for tool_name in tool_names if tool_name in self._id_to_tool.values()}
+ if not matched_tool_names:
+ return
+
+ with self.prevent(Checkbox.Changed):
+ for cb_id, tool_name in self._id_to_tool.items():
+ if tool_name in matched_tool_names:
+ self.query_one(f"#{cb_id}", Checkbox).value = enabled
+
+ for parent_id, child_ids in self._parent_to_children.items():
+ all_checked = all(self.query_one(f"#{cid}", Checkbox).value for cid in child_ids)
+ self.query_one(f"#{parent_id}", Checkbox).value = all_checked
+
+ def _toggle_tool_subset(self, tool_names: set[str]) -> None:
+ matched_tool_names = {tool_name for tool_name in tool_names if tool_name in self._id_to_tool.values()}
+ if not matched_tool_names:
+ return
+
+ should_enable = not all(
+ self.query_one(f"#{cb_id}", Checkbox).value
+ for cb_id, tool_name in self._id_to_tool.items()
+ if tool_name in matched_tool_names
+ )
+ self._set_tool_subset(matched_tool_names, should_enable)
+
+ def _move_checkbox_focus(self, step: int) -> None:
+ checkboxes = list(self.query(Checkbox))
+ if not checkboxes:
+ return
+
+ if isinstance(self.focused, Checkbox) and self.focused in checkboxes:
+ current_index = checkboxes.index(self.focused)
+ target_index = max(0, min(len(checkboxes) - 1, current_index + step))
+ else:
+ target_index = 0 if step > 0 else len(checkboxes) - 1
+
+ self.set_focus(checkboxes[target_index])
+
+ def action_next_checkbox(self) -> None:
+ self._move_checkbox_focus(1)
+
+ def action_previous_checkbox(self) -> None:
+ self._move_checkbox_focus(-1)
+
def on_mount(self) -> None:
first_cb = self.query_one(Checkbox)
self.set_focus(first_cb)
class RadioListModal(ModalScreen[str | None]):
+ class SquareRadioButton(RadioButton):
+ # BUTTON_INNER = '●'
+ @property
+ def _button(self) -> Content:
+ button_style = self.get_visual_style("toggle--button")
+ symbol = "☒" if self.value else "☐"
+ return Content.assemble(
+ (" ", button_style),
+ (symbol, button_style),
+ (" ", button_style),
+ )
+
+ class SuggestionRadioSet(RadioSet):
+ BINDINGS = [
+ Binding("down,right", "next_button", "Next option", show=False),
+ Binding("enter", "submit_selection", "Submit", show=False),
+ Binding("space", "toggle_button", "Toggle", show=False),
+ Binding("up,left", "previous_button", "Previous option", show=False),
+ ]
+
+ def action_submit_selection(self) -> None:
+ self.screen.dismiss_selected()
+
CSS = """
RadioListModal {
align: center middle;
@@ -218,7 +389,6 @@ class RadioListModal(ModalScreen[str | None]):
width: 60%;
max-width: 90%;
height: 40%;
- border: round $accent;
padding: 1 2;
background: $panel;
}
@@ -233,26 +403,36 @@ class RadioListModal(ModalScreen[str | None]):
}
.btns {
- height: 3;
- padding-top: 1;
- content-align: right middle;
+ height: auto;
+ width: 100%;
+ margin-top: 1;
+ padding: 0;
+ content-align: center middle;
+ align-horizontal: center;
+ }
+
+ .btns Button {
+ padding: 0 1;
}
"""
- def __init__(self, lines: list[str], default_index: int = 0) -> None:
+ def __init__(self, lines: list[str], category: str = "", input_string: str = "", default_index: int = 0) -> None:
super().__init__()
self.lines = lines
+ self.category = category
+ self.input_string = input_string
self.default_index = default_index
def compose(self) -> ComposeResult:
+ dialog_msg = f"{self.category} '{self.input_string}' does not exist. Choose a suggestion:"
with Vertical(classes="dialog"):
- yield Label("Pick one option", classes="title")
+ yield Label(dialog_msg, classes="title")
# One-select radio list
with VerticalScroll(classes="radio-list"):
- with RadioSet(id="radio-set"):
+ with self.SuggestionRadioSet(id="radio-set"):
for i, line in enumerate(self.lines):
- yield RadioButton(line, id=f"rb{i}", value=(i == self.default_index))
+ yield self.SquareRadioButton(line, id=f"rb{i}", value=(i == self.default_index))
# Buttons
with Horizontal(classes="btns"):
@@ -260,14 +440,23 @@ def compose(self) -> ComposeResult:
yield Button("Submit", variant="primary", id="submit")
def on_mount(self) -> None:
- first_rb = self.query_one(RadioButton)
- self.set_focus(first_rb)
+ radio_set = self.query_one("#radio-set", RadioSet)
+ self.set_focus(radio_set)
+
+ def dismiss_selected(self) -> None:
+ radioset = self.query_one("#radio-set", RadioSet)
+ selected = radioset.pressed_index
+ highlighted = getattr(radioset, "_selected", None)
+
+ # Arrow navigation changes the highlighted row before the radio value updates.
+ if highlighted is not None and highlighted != selected:
+ selected = highlighted
+
+ if selected >= 0:
+ self.dismiss(selected)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "submit":
- radioset = self.query_one("#radio-set", RadioSet)
- selected = radioset.pressed_index
- if selected is not None:
- self.dismiss(selected)
+ self.dismiss_selected()
else:
self.dismiss(None)
diff --git a/src/vulcanai/console/utils.py b/src/vulcanai/console/utils.py
index 1d00da9..e5076b5 100644
--- a/src/vulcanai/console/utils.py
+++ b/src/vulcanai/console/utils.py
@@ -1,4 +1,4 @@
-# Copyright 2025 Proyectos y Sistemas de Mantenimiento SL (eProsima).
+# Copyright 2026 Proyectos y Sistemas de Mantenimiento SL (eProsima).
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -12,16 +12,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-
-import asyncio
-import difflib
-import heapq
-import subprocess
import sys
import threading
-import time
-
-from textual.markup import escape # To remove potential errors in textual terminal
class SpinnerHook:
@@ -156,207 +148,3 @@ def common_prefix(strings: str) -> str:
common_prefix = tmp
return common_prefix, commands
-
-
-async def run_streaming_cmd_async(
- console, args: list[str], max_duration: float = 60, max_lines: int = 1000, echo: bool = True, tool_name=""
-) -> str:
- # Unpack the command
- cmd, *cmd_args = args
-
- # Create the subprocess
- process = await asyncio.create_subprocess_exec(
- cmd,
- *cmd_args,
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.STDOUT,
- )
-
- assert process.stdout is not None
-
- start_time = time.monotonic()
- line_count = 0
-
- try:
- # Subprocess main loop. Read line by line
- async for raw_line in process.stdout:
- line = raw_line.decode(errors="ignore").rstrip("\n")
-
- # Print the line
- if echo:
- line_processed = escape(line)
- console.add_line(line_processed)
-
- # Count the line
- line_count += 1
- if max_lines is not None and line_count >= max_lines:
- console.logger.log_tool(f"[tool]Stopping:[/tool] Reached max_lines = {max_lines}", tool_name=tool_name)
- console.set_stream_task(None)
- process.terminate()
- break
-
- # Check duration
- if max_duration and (time.monotonic() - start_time) >= max_duration:
- console.logger.log_tool(
- f"[tool]Stopping:[/tool] Exceeded max_duration = {max_duration}s", tool_name=tool_name
- )
- console.set_stream_task(None)
- process.terminate()
- break
-
- except asyncio.CancelledError:
- # Task was cancelled → stop the subprocess
- console.logger.log_tool("[tool]Cancellation received:[/tool] terminating subprocess...", tool_name=tool_name)
- process.terminate()
- raise
- # Not necessary, textual terminal get the keyboard input
- except KeyboardInterrupt:
- # Ctrl+C pressed → stop subprocess
- console.logger.log_tool("[tool]Ctrl+C received:[/tool] terminating subprocess...", tool_name=tool_name)
- process.terminate()
-
- finally:
- try:
- await asyncio.wait_for(process.wait(), timeout=3.0)
- except asyncio.TimeoutError:
- console.logger.log_tool("Subprocess didn't exit in time → killing it.", tool_name=tool_name, error=True)
- process.kill()
- await process.wait()
-
- return "Process stopped due to Ctrl+C"
-
-
-def execute_subprocess(console, tool_name, base_args, max_duration, max_lines):
- stream_task = None
-
- def _launcher() -> None:
- nonlocal stream_task
- # This always runs in the Textual event-loop thread
- loop = asyncio.get_running_loop()
- stream_task = loop.create_task(
- run_streaming_cmd_async(
- console,
- base_args,
- max_duration=max_duration,
- max_lines=max_lines,
- tool_name=tool_name, # tool_header_str
- )
- )
-
- def _on_done(task: asyncio.Task) -> None:
- if task.cancelled():
- # Normal path → don't log as an error
- # If you want a message, call UI methods directly here,
- # not via console.write (see Fix 2)
- return
-
- try:
- task.result()
- except Exception as e:
- console.logger.log_msg(f"Echo task error: {e!r}\n", error=True)
- # result["output"] = False
- return
-
- stream_task.add_done_callback(_on_done)
-
- try:
- # Are we already in the Textual event loop thread?
- asyncio.get_running_loop()
- except RuntimeError:
- # No loop here → probably ROS thread. Bounce into Textual thread.
- console.app.call_from_thread(_launcher)
- else:
- # We *are* in the loop → just launch directly.
- _launcher()
-
- # Store the task in the console to be able to cancel it later
- console.set_stream_task(stream_task)
- console.logger.log_tool("[tool]Subprocess created![tool]", tool_name=tool_name)
-
-
-def run_oneshot_cmd(args: list[str]) -> str:
- try:
- return subprocess.check_output(args, stderr=subprocess.STDOUT, text=True)
-
- except subprocess.CalledProcessError as e:
- raise Exception(f"Failed to run '{' '.join(args)}': {e.output}")
-
-
-def suggest_string(console, tool_name, string_name, input_string, real_string_list):
- ret = None
-
- def _similarity(a: str, b: str) -> float:
- """Return a similarity score between 0 and 1."""
- return difflib.SequenceMatcher(None, a, b).ratio()
-
- def _get_suggestions(real_string_list_comp: list[str], string_comp: str) -> tuple[str, list[str]]:
- """
- Function used to search for the most "similar" string in a list.
-
- Used in ROS2 cli commands to used the "simmilar" in case
- the queried topic does not exists.
-
- Example:
- real_string_list_comp = [
- "/parameter_events",
- "/rosout",
- "/turtle1/cmd_vel",
- "/turtle1/color_sensor",
- "/turtle1/pose",
- ]
- string_comp = "trtle1"
-
- @return
- str: the most "similar" string
- list[str] a sorted list by a similitud value
- """
-
- topic_list_pq = []
- n = len(string_comp)
-
- for string in real_string_list_comp:
- m = len(string)
- # Calculate the similitud
- score = _similarity(string_comp, string)
- # Give more value for the nearest size comparations.
- score -= abs(n - m) * 0.005
- # Max-heap (via negative score)
- heapq.heappush(topic_list_pq, (-score, string))
-
- # Pop ordered list
- ret_list: list[str] = []
- _, most_topic_similar = heapq.heappop(topic_list_pq)
-
- ret_list.append(most_topic_similar)
-
- while topic_list_pq:
- _, topic = heapq.heappop(topic_list_pq)
- ret_list.append(topic)
-
- return most_topic_similar, ret_list
-
- if input_string not in real_string_list:
- console.logger.log_tool(f'{string_name}: "{input_string}" does not exists', tool_name=tool_name)
-
- # Get the suggestions list sorted by similitud value
- _, topic_sim_list = _get_suggestions(real_string_list, input_string)
-
- # Open the ModalScreen
- console.open_radiolist(topic_sim_list, f"{tool_name}")
-
- # Wait for the user to select and item in the
- # RadioList ModalScreen
- console.suggestion_index_changed.wait()
-
- # Check if the user cancelled the suggestion
- if console.suggestion_index >= 0:
- ret = topic_sim_list[console.suggestion_index]
-
- # Reset suggestion index
- console.suggestion_index = -1
- console.suggestion_index_changed.clear()
-
- return ret
-
-
-# endregion
diff --git a/src/vulcanai/console/widget_custom_log_text_area.py b/src/vulcanai/console/widget_custom_log_text_area.py
index dd60f4c..345779c 100644
--- a/src/vulcanai/console/widget_custom_log_text_area.py
+++ b/src/vulcanai/console/widget_custom_log_text_area.py
@@ -16,11 +16,14 @@
import re
import threading
from collections import defaultdict, deque
+from contextlib import contextmanager
import pyperclip
from rich.style import Style
from textual.widgets import TextArea
+from vulcanai.console.logger import VulcanAILogger
+
class CustomLogTextArea(TextArea):
"""
@@ -33,7 +36,7 @@ class CustomLogTextArea(TextArea):
"""
BINDINGS = [
- ("f3", "copy_selection", "Copy selection"),
+ ("f4", "copy_selection", "Copy selection"),
]
# Maximum number of lines to keep in the log
@@ -45,7 +48,10 @@ class CustomLogTextArea(TextArea):
TAG_TOKEN_RE = re.compile(r"?[^>]+>")
def __init__(self, **kwargs):
- super().__init__(read_only=True, **kwargs)
+ # Disable the TextArea cursor in this read-only log panel.
+ # This prevents Textual from auto-scrolling to keep cursor/selection visible
+ # every time new text is inserted.
+ super().__init__(read_only=True, show_cursor=False, **kwargs)
# Lock used to avoid data races in 'self._lines_styles'
# when VulcanAI and ROS threads writes at the same time
@@ -65,9 +71,69 @@ def __init__(self, **kwargs):
# A row with N styles # (e.g.: N = 2: two colors, one bold and color)
# will have N entries.
self._lines_styles = deque(maxlen=self.MAX_LINES)
+ # Sticky terminal-like follow state
+ # When True, new output should keep the viewport anchored to the end
+ self._follow_output = True
+ # Suppress follow-state updates while we are applying programmatic scrolls
+ self._suspend_follow_tracking = 0
# region UTILS
+ def is_near_vertical_scroll_end(self, tolerance: int = 1) -> bool:
+ """
+ Return True if the viewport is at, or very close to, the vertical end.
+
+ A small tolerance avoids false negatives after layout changes where
+ scroll position can be off by one line.
+ """
+ if not self.size:
+ return True
+ return (self.max_scroll_y - self.scroll_offset.y) <= max(0, tolerance)
+
+ def should_follow_output(self) -> bool:
+ """
+ Return True when the log should keep following new output.
+ """
+ return self._follow_output
+
+ def _update_follow_output_from_viewport(self, tolerance: int = 1) -> None:
+ """
+ Refresh sticky follow state from the current viewport position.
+ """
+ self._follow_output = self.is_near_vertical_scroll_end(tolerance=tolerance)
+
+ @contextmanager
+ def _suspend_follow_output_tracking(self):
+ """
+ Temporarily ignore viewport changes triggered by programmatic scrolling.
+ """
+ self._suspend_follow_tracking += 1
+ try:
+ yield
+ finally:
+ self._suspend_follow_tracking = max(0, self._suspend_follow_tracking - 1)
+
+ def _scroll_to_output_end(self) -> None:
+ """
+ Anchor the viewport to the end and keep follow mode enabled.
+ """
+ self._follow_output = True
+ with self._suspend_follow_output_tracking():
+ self.scroll_end(animate=False, immediate=True, x_axis=False)
+
+ def _restore_scroll_position(self, x: int, y: int) -> None:
+ """
+ Restore the viewport to a previous position and keep follow mode disabled.
+ """
+ self._follow_output = False
+ with self._suspend_follow_output_tracking():
+ self.scroll_to(x=x, y=y, animate=False, immediate=True, force=True)
+
+ def _watch_scroll_y(self) -> None:
+ super()._watch_scroll_y()
+ if self._suspend_follow_tracking == 0:
+ self._update_follow_output_from_viewport()
+
def _trim_highlights(self) -> None:
"""
Function used to trim the CustomLogTextArea to the maximum number of lines.
@@ -198,7 +264,7 @@ def _ensure_style_token(self, style: str) -> str:
# Gray color is not supported
if st == "gray":
- color = "#8D8D8D"
+ color = "#A5A2A2"
token_parts.append("hex_" + color[1:])
else:
# Assume named color like "red", "yellow"
@@ -223,7 +289,7 @@ def _ensure_style_token(self, style: str) -> str:
# region LOG
- def append_line(self, text: str) -> bool:
+ def append_line(self, text: str, force_follow_output: bool = False) -> bool:
"""
Function used to append a new line to the CustomLogTextArea.
@@ -279,30 +345,44 @@ def append_line(self, text: str) -> bool:
# [EXECUTOR] Invoking 'move_turtle' with args: ...
# [ROS] [INFO] Publishing message 1 to ...
with self._lock:
- # Append via document API to keep row tracking consistent
- # Only add a newline before the new line if there is already content
- insert_text = ("\n" if self.document.text else "") + plain
- self.insert(insert_text, location=self.document.end)
-
- # Track styles for the new line (always at the end)
- self._line_count += 1
-
- if self._line_count > self.MAX_LINES:
- self._highlights.pop(self._line_count - self.MAX_LINES, None)
-
- # Store styles
- # Each line may have multiple styles (spans)
- current_line = []
- for start, end, token in spans:
- current_line.append((start, end, token))
-
- self._lines_styles.append(current_line)
-
- # Trim now
- self._trim_highlights()
-
- # Scroll to end
- self.scroll_end(animate=False)
+ # Terminal-like behavior:
+ # keep following output only if the user was already following output.
+ should_follow_output = force_follow_output or self.should_follow_output()
+ previous_scroll_x = self.scroll_offset.x
+ previous_scroll_y = self.scroll_offset.y
+
+ with self._suspend_follow_output_tracking():
+ # Append via document API to keep row tracking consistent
+ # Only add a newline before the new line if there is already content
+ insert_text = ("\n" if self.document.text else "") + plain
+ self.insert(insert_text, location=self.document.end)
+
+ # Track styles for the new line (always at the end)
+ self._line_count += 1
+
+ if self._line_count > self.MAX_LINES:
+ self._highlights.pop(self._line_count - self.MAX_LINES, None)
+
+ # Store styles
+ # Each line may have multiple styles (spans)
+ current_line = []
+ for start, end, token in spans:
+ current_line.append((start, end, token))
+
+ self._lines_styles.append(current_line)
+
+ # Trim now
+ self._trim_highlights()
+
+ # Scroll to end only when the user was already at the bottom.
+ if should_follow_output:
+ self._scroll_to_output_end()
+ # Ensure we stay anchored after any pending layout updates.
+ self.call_after_refresh(self._scroll_to_output_end)
+ else:
+ # Keep user viewport stable while new lines arrive in the background
+ self._restore_scroll_position(previous_scroll_x, previous_scroll_y)
+ self.call_after_refresh(self._restore_scroll_position, previous_scroll_x, previous_scroll_y)
# Rebuild highlights and refresh
self._rebuild_highlights()
@@ -320,27 +400,34 @@ def delete_last_row(self) -> None:
# No lines, does nothing.
return
+ should_follow_output = self.should_follow_output()
+
last_row = self._line_count - 1
if last_row > 0:
# Delete the newline before the last line + the line itself
- self.replace(
- "",
- start=(last_row - 1, len(self.document.get_line(last_row - 1))),
- end=(last_row, len(self.document.get_line(last_row))),
- )
+ with self._suspend_follow_output_tracking():
+ self.replace(
+ "",
+ start=(last_row - 1, len(self.document.get_line(last_row - 1))),
+ end=(last_row, len(self.document.get_line(last_row))),
+ )
else:
# Only one line
- self.replace(
- "",
- start=(0, 0),
- end=(0, len(self.document.get_line(0))),
- )
+ with self._suspend_follow_output_tracking():
+ self.replace(
+ "",
+ start=(0, 0),
+ end=(0, len(self.document.get_line(0))),
+ )
# Decrease line count and remove styles
self._line_count -= 1
self._lines_styles.pop()
+ if should_follow_output:
+ self._scroll_to_output_end()
+
# Rebuild highlights and refresh
self._rebuild_highlights()
self.refresh()
@@ -359,10 +446,12 @@ def clear_console(self) -> None:
# Clear the document
self._line_count = 0
self._lines_styles.clear()
+ self._follow_output = True
# Refresh and clear the TextArea
self.refresh()
- self.clear()
+ with self._suspend_follow_output_tracking():
+ self.clear()
def action_copy_selection(self) -> None:
"""
@@ -374,6 +463,12 @@ def action_copy_selection(self) -> None:
self.notify("No text selected to copy!")
return
- # Copy to clipboard, using pyperclip library
- pyperclip.copy(self.selected_text)
- self.notify("Selected area copied to clipboard!")
+ try:
+ # Copy to clipboard, using pyperclip library
+ pyperclip.copy(self.selected_text)
+ self.notify("Selected area copied to clipboard!")
+ except Exception as e:
+ error_color = VulcanAILogger.vulcanai_theme["error"]
+ self.append_line(f"<{error_color}>Clipboard error: {e}{error_color}>")
+ self.notify(f"Clipboard error: {e}")
+ return
diff --git a/src/vulcanai/console/widget_spinner.py b/src/vulcanai/console/widget_spinner.py
index a2019ed..8da665a 100644
--- a/src/vulcanai/console/widget_spinner.py
+++ b/src/vulcanai/console/widget_spinner.py
@@ -46,9 +46,16 @@ def _log_is_filling_space(self) -> bool:
"""
visible = max(1, self.logcontent.size.height)
lines = getattr(self.logcontent.document, "line_count", 0)
- return lines > visible
+ return lines >= visible
def start(self, text: str = "Querying LLM...") -> None:
+ # Keep the log anchored at bottom if the user was already following output.
+ if hasattr(self.logcontent, "should_follow_output"):
+ was_at_bottom = self.logcontent.should_follow_output()
+ elif hasattr(self.logcontent, "is_near_vertical_scroll_end"):
+ was_at_bottom = self.logcontent.is_near_vertical_scroll_end()
+ else:
+ was_at_bottom = self.logcontent.is_vertical_scroll_end
self._spinner.text = Text(text, style="#0d87c0")
self.display = True
self.styles.height = 1
@@ -59,17 +66,35 @@ def start(self, text: str = "Querying LLM...") -> None:
else:
self._forced_compact = False
self.refresh(layout=True)
+ if was_at_bottom:
+ self.logcontent.scroll_end(animate=False, immediate=True, x_axis=False)
+ # Re-anchor after layout has settled.
+ self.call_after_refresh(self.logcontent.scroll_end, animate=False, immediate=True, x_axis=False)
self._timer.resume()
def stop(self) -> None:
+ # Keep the log anchored at bottom if the user was already following output.
+ if hasattr(self.logcontent, "should_follow_output"):
+ was_at_bottom = self.logcontent.should_follow_output()
+ elif hasattr(self.logcontent, "is_near_vertical_scroll_end"):
+ was_at_bottom = self.logcontent.is_near_vertical_scroll_end()
+ else:
+ was_at_bottom = self.logcontent.is_vertical_scroll_end
self._timer.pause()
self.display = False
self.styles.height = 0
if self._forced_compact:
- self.logcontent.styles.height = "auto"
+ # Keep the log area in the same flexible layout mode used by the console.
+ # Restoring to "auto" can cause the stream panel to stop appearing on
+ # subsequent runs when the log grows.
+ self.logcontent.styles.height = "1fr"
self._forced_compact = False
self.refresh(layout=True)
self.logcontent.refresh(layout=True)
+ if was_at_bottom:
+ self.logcontent.scroll_end(animate=False, immediate=True, x_axis=False)
+ # Re-anchor after layout has settled.
+ self.call_after_refresh(self.logcontent.scroll_end, animate=False, immediate=True, x_axis=False)
self.update("")
diff --git a/src/vulcanai/core/executor.py b/src/vulcanai/core/executor.py
index 9b3010b..884239a 100644
--- a/src/vulcanai/core/executor.py
+++ b/src/vulcanai/core/executor.py
@@ -25,8 +25,11 @@
TYPE_CAST = {
"float": float,
"int": int,
+ "integer": int,
"bool": lambda v: v if isinstance(v, bool) else str(v).strip().lower() in ("1", "true", "yes", "on"),
+ "boolean": lambda v: v if isinstance(v, bool) else str(v).strip().lower() in ("1", "true", "yes", "on"),
"str": str,
+ "string": str,
}
@@ -150,7 +153,8 @@ def _run_step(self, step: Step, bb: Blackboard, parallel: bool = False) -> bool:
# Bind args with blackboard placeholders
tool = self.registry.tools.get(step.tool)
input_schema = tool.input_schema if tool else []
- args = self._bind_args(step.args, input_schema, bb)
+ input_defaults = getattr(tool, "input_defaults", {}) if tool else {}
+ args = self._bind_args(step.args, input_schema, bb, input_defaults)
attempts = step.retry + 1 if step.retry else 1
for i in range(attempts):
@@ -213,13 +217,36 @@ def _make_bb_subs(self, expr: str, bb: Blackboard) -> str:
self.logger.log_executor(f"Blackboard substitution failed: {expr} ({e})", error=True)
return expr
- def _bind_args(self, args: List[ArgValue], schema: List[Tuple[str, str]], bb: Blackboard) -> List[ArgValue]:
- """Replace {{bb.key}} placeholders with actual values."""
- bound = []
+ def _bind_args(
+ self,
+ args: List[ArgValue],
+ schema: List[Tuple[str, str]],
+ bb: Blackboard,
+ input_defaults: Optional[Dict[str, Any]] = None,
+ ) -> List[ArgValue]:
+ """Replace {{bb.key}} placeholders with actual values and fill omitted defaults in schema order"""
+
+ bound_by_key = {}
for arg in args:
bound_arg = self._make_bb_subs(arg.val, bb)
arg.val = self._coerce_to_schema(schema, arg.key, bound_arg)
- bound.append(arg)
+ bound_by_key[arg.key] = arg
+
+ bound = []
+ for key, _ in schema:
+ if key in bound_by_key:
+ bound.append(bound_by_key.pop(key))
+ continue
+ if not input_defaults or key not in input_defaults:
+ continue
+ default_value = input_defaults[key]
+ if isinstance(default_value, str):
+ default_value = self._make_bb_subs(default_value, bb)
+ bound.append(ArgValue(key=key, val=self._coerce_to_schema(schema, key, default_value)))
+
+ # Preserve any unexpected args after the schema-ordered ones
+ bound.extend(bound_by_key.values())
+
return bound
def _get_from_bb(self, path: str, bb: Blackboard) -> Any:
@@ -246,6 +273,8 @@ def _get_from_bb(self, path: str, bb: Blackboard) -> Any:
def _coerce_to_schema(self, schema: List[Tuple[str, str]], key: str, arg: str) -> Any:
spec = dict(schema)
t = spec.get(key, "str")
+ if isinstance(t, str) and t.endswith("?"):
+ t = t[:-1]
try:
out = TYPE_CAST[t](arg)
except Exception:
@@ -277,9 +306,9 @@ def _call_tool(
msg += "'{"
for key, value in arg_dict.items():
if first:
- msg += f"[validator]'{key}'[/validator]: " + f"[registry]'{value}'[/registry]"
+ msg += f"[tool]'{key}'[/tool]: " + f"[registry]'{value}'[/registry]"
else:
- msg += f", [validator]'{key}'[/validator]: " + f"[registry]'{value}'[/registry]"
+ msg += f", [tool]'{key}'[/tool]: " + f"[registry]'{value}'[/registry]"
first = False
msg += "}'"
self.logger.log_executor(msg)
@@ -315,15 +344,6 @@ def _call_tool(
+ "with result:"
)
- if isinstance(result, dict):
- for key, value in result.items():
- if key == "ros2":
- continue
- self.logger.log_msg(f"{key}")
- self.logger.log_msg(value)
- else:
- self.logger.log_msg(result)
-
return True, result
except concurrent.futures.TimeoutError:
self.logger.log_executor(
diff --git a/src/vulcanai/core/manager.py b/src/vulcanai/core/manager.py
index 66936d3..41a8cde 100644
--- a/src/vulcanai/core/manager.py
+++ b/src/vulcanai/core/manager.py
@@ -25,6 +25,21 @@
class ToolManager:
"""Manages the LLM Agent and calls the executor with the LLM output."""
+ _literal_identifier_input_keys = {
+ "action_name",
+ "file_path",
+ "frame_id",
+ "interface_name",
+ "msg_type",
+ "node_name",
+ "package_name",
+ "param_name",
+ "service_name",
+ "topic_name",
+ "type_name",
+ }
+ _literal_identifier_input_suffixes = ("_name", "_path", "_type")
+
def __init__(
self,
model: str,
@@ -33,12 +48,13 @@ def __init__(
k: int = 10,
hist_depth: int = 3,
logger: Optional[VulcanAILogger] = None,
+ default_tools: bool = True,
):
# Logger default to a stdout logger if none is provided (StdoutLogSink)
self.logger = logger or VulcanAILogger.default()
self.llm = Agent(model, logger=self.logger)
self.k = k
- self.registry = registry or ToolRegistry(logger=self.logger)
+ self.registry = registry or ToolRegistry(logger=self.logger, default_tools=default_tools)
self.validator = validator
self.executor = PlanExecutor(self.registry, logger=self.logger)
self.bb = Blackboard()
@@ -93,6 +109,7 @@ def handle_user_request(self, user_text: str, context: Dict[str, Any]) -> Dict[s
# Get plan from LLM
plan = self.get_plan_from_user_request(user_text, context)
if not plan:
+ self.logger.log_manager("No executable plan generated by the planner.", error=True)
return {"error": "No plan generated by LLM."}
# Validate plan if validator is available
if self.validator:
@@ -129,11 +146,30 @@ def get_plan_from_user_request(self, user_text: str, context: Dict[str, Any] = N
# Images should be a list of paths
images = context["images"]
+ is_direct_ros2_cli_request = self._is_direct_ros2_cli_request(user_text)
+ history = [] if is_direct_ros2_cli_request else self.history
+
# Query LLM
- plan = self.llm.inference_plan(system_prompt, user_prompt, images, self.history)
+ plan = self.llm.inference_plan(system_prompt, user_prompt, images, history)
+
+ if is_direct_ros2_cli_request and not self._is_single_step_plan(plan):
+ self.logger.log_manager(
+ "Planner returned multiple steps for a direct ROS 2 command. Requesting a single-step plan.",
+ error=True,
+ )
+ retry_prompt = (
+ user_prompt
+ + "\n\nImportant: the current user request is already an exact `ros2 ...` command."
+ + " Return exactly ONE PlanNode with exactly ONE Step using the single best matching tool."
+ + " Do not include alternatives, helper steps, previous commands, or multiple guesses."
+ )
+ plan = self.llm.inference_plan(system_prompt, retry_prompt, images, [])
+ if not self._is_single_step_plan(plan):
+ raise ValueError("Planner returned multiple steps for a direct ROS 2 command.")
+
self.logger.log_manager(f"Plan received:\n{plan}")
# Save to history
- if plan:
+ if plan and not is_direct_ros2_cli_request:
self._add_to_history(user_prompt, plan.summary)
return plan
@@ -168,18 +204,19 @@ def _build_prompt(self, user_text: str, ctx: Dict[str, Any]) -> Tuple[str, str]:
if not tools:
self.logger.log_manager("No tools available in the registry.", error=True)
return "", ""
- tool_descriptions = []
- for tool in tools:
- tool_descriptions.append(
- f"- *{tool.name}*: {tool.description}\n"
- f" Inputs: {tool.input_schema}\n"
- f" Outputs: {tool.output_schema}\n"
- )
- tools_text = "\n".join(tool_descriptions)
+
+ tools_text = self.render_tool_descriptions(tools)
user_context = self._parse_user_context()
user_prompt = "User request:\n" + user_text
- return self._get_prompt_template().format(tools_text=tools_text, user_context=user_context), user_prompt
+ return (
+ self._get_prompt_template().format(
+ tools_text=tools_text,
+ user_context=user_context,
+ identifier_rules=self._get_identifier_prompt_rules(),
+ ),
+ user_prompt,
+ )
def _add_to_history(self, user_text: str, plan_summary: str):
"""Add a new interaction to the history and trim if necessary."""
@@ -214,11 +251,107 @@ def update_k_index(self, new_k: int):
self.k = max(1, int(new_k))
self.logger.log_console(f"Updated k index to {new_k}")
+ @staticmethod
+ def _schema_type_parts(type_name: str) -> Tuple[str, bool]:
+ if isinstance(type_name, str) and type_name.endswith("?"):
+ return type_name[:-1], True
+ return type_name, False
+
+ @staticmethod
+ def _format_schema_value(value: Any) -> str:
+ if isinstance(value, str):
+ return value
+ return repr(value)
+
+ def _format_tool_inputs(self, tool) -> str:
+ if not tool.input_schema:
+ return "none"
+
+ input_defaults = getattr(tool, "input_defaults", {}) or {}
+ formatted_inputs = []
+ for key, type_name in tool.input_schema:
+ base_type, optional = self._schema_type_parts(type_name)
+ role = "optional" if optional else "required"
+ suffix = ""
+ if key in input_defaults:
+ suffix = f", default={self._format_schema_value(input_defaults[key])}"
+ formatted_inputs.append(f"{key} ({base_type}, {role}{suffix})")
+ return "; ".join(formatted_inputs)
+
+ @staticmethod
+ def _format_tool_outputs(tool) -> str:
+ if not tool.output_schema:
+ return "none"
+ return ", ".join(f"{key} ({type_name})" for key, type_name in tool.output_schema.items())
+
+ @staticmethod
+ def _is_direct_ros2_cli_request(user_text: str) -> bool:
+ return isinstance(user_text, str) and user_text.strip().startswith("ros2 ")
+
+ @staticmethod
+ def _is_single_step_plan(plan: GlobalPlan | None) -> bool:
+ if not isinstance(plan, GlobalPlan):
+ return False
+ return len(plan.plan) == 1 and len(plan.plan[0].steps) == 1
+
+ @classmethod
+ def _is_literal_identifier_input(cls, key: str) -> bool:
+ return key in cls._literal_identifier_input_keys or key.endswith(cls._literal_identifier_input_suffixes)
+
+ def _format_literal_input_guidance(self, tool) -> str:
+ literal_inputs = [key for key, _ in tool.input_schema if self._is_literal_identifier_input(key)]
+ if not literal_inputs:
+ return ""
+
+ input_list = ", ".join(literal_inputs)
+ return (
+ f"Literal inputs: {input_list}. Copy these values exactly from the user request. "
+ "Do not correct, expand, or autocomplete them; if they are partial or misspelled, "
+ "the tool may open an interactive suggestion modal."
+ )
+
+ @staticmethod
+ def _get_identifier_prompt_rules() -> str:
+ return (
+ "When the user provides identifier-like values such as topic, service, action, node, "
+ "parameter, package, or file names, copy them exactly as written into tool arguments.\n"
+ "Do not correct, expand, normalize, or autocomplete those values, even if they look partial or "
+ "misspelled.\n"
+ "If the user provides a partial or mistyped identifier such as `use_s`, keep it exactly as written "
+ "so the tool can open its suggestion modal.\n"
+ "If the user request is already an exact command such as a `ros2 ...` command, map it to exactly "
+ "ONE matching tool call.\n"
+ "For an exact command request, return exactly ONE PlanNode with exactly ONE Step and do not add "
+ "alternatives, helper steps, or previous command variants.\n"
+ )
+
+ def render_tool_descriptions(self, tools) -> str:
+ tool_descriptions = []
+ for tool in tools:
+ tool_description = (
+ f"- *{tool.name}*: {tool.description}\n"
+ f" Inputs: {self._format_tool_inputs(tool)}\n"
+ f" Outputs: {self._format_tool_outputs(tool)}\n"
+ )
+ literal_guidance = self._format_literal_input_guidance(tool)
+ if literal_guidance:
+ tool_description += f" {literal_guidance}\n"
+ tool_descriptions.append(tool_description)
+ return "\n".join(tool_descriptions)
+
def _get_prompt_template(self) -> str:
template = """
You are a planner assistant controlling a robotic system.
Your job is to take a user request and generate a valid execution plan, containing only ONE step.
Be sure to understand the text received and select the best action command from the available options.
+Never return only a summary: always produce at least one PlanNode with at least one Step.
+Inputs whose type ends with `?` are optional and may be omitted when the tool default behavior is appropriate.
+If an input is marked optional and the user did not ask for a specific value,
+omit that argument instead of inventing one.
+Do not guess placeholder values such as `1`, `1.0`, `10`, or empty strings for optional inputs.
+If the user asks to repeat the same action N times and a tool exposes an internal count or limit argument
+(for example `max_lines`), prefer a single Step using that argument instead of duplicating the same tool call.
+{identifier_rules}
{user_context}
## Available tools:
{tools_text}
diff --git a/src/vulcanai/core/manager_iterator.py b/src/vulcanai/core/manager_iterator.py
index 8414792..20f76bf 100644
--- a/src/vulcanai/core/manager_iterator.py
+++ b/src/vulcanai/core/manager_iterator.py
@@ -43,8 +43,17 @@ def __init__(
logger=None,
max_iters: int = 5,
step_timeout_ms: Optional[int] = None,
+ default_tools: bool = True,
):
- super().__init__(model, registry, validator, k, max(3, hist_depth), logger)
+ super().__init__(
+ model=model,
+ registry=registry,
+ validator=validator,
+ k=k,
+ hist_depth=max(3, hist_depth),
+ logger=logger,
+ default_tools=default_tools,
+ )
self.iter: int = 0
self.max_iters: int = int(max_iters)
@@ -210,25 +219,17 @@ def _build_prompt(self, user_text: str, ctx: Dict[str, Any]) -> Tuple[str, str]:
if not tools:
self.logger.log_manager("No tools available in the registry.", error=True)
return "", ""
- tool_descriptions = []
- for tool in tools:
- tool_descriptions.append(
- f"- *{tool.name}*: {tool.description}\n"
- f" Inputs: {tool.input_schema}\n"
- f" Outputs: {tool.output_schema}\n"
- )
- tools_text = "\n".join(tool_descriptions)
+ tools_text = self.render_tool_descriptions(tools)
bb_snapshot = self.bb.text_snapshot()
-
user_context = self._parse_user_context()
system_prompt = self._get_prompt_template()
system_prompt = system_prompt.format(
tools_text=tools_text,
user_context=user_context,
+ identifier_rules=self._get_identifier_prompt_rules(),
)
-
user_prompt = (
"## User Request: " + user_text + "\nContext:\n" + self._get_iter_context().format(bb_snapshot=bb_snapshot)
)
@@ -243,14 +244,7 @@ def _build_goal_prompt(self, user_text: str, ctx: Dict[str, Any]) -> Tuple[str,
"""
tools = self.registry.top_k(user_text, self.k, validation=True)
if tools:
- tool_descriptions = []
- for tool in tools:
- tool_descriptions.append(
- f"- *{tool.name}*: {tool.description}\n"
- f" Inputs: {tool.input_schema}\n"
- f" Outputs: {tool.output_schema}\n"
- )
- tools_text = "\n".join(tool_descriptions)
+ tools_text = self.render_tool_descriptions(tools)
else:
tools_text = "No available tools. Use blackboard"
@@ -348,11 +342,16 @@ def _get_prompt_template(self) -> str:
- If the last step failed, propose a different approach or tool/arguments and include a brief rationale of what will
be done differently (as a comment-like line in the Summary).
- Add only optional execution control parameters if strictly necessary or requested by the user.
+- If an input is marked optional and the user did not ask for a specific value, omit that argument instead of
+ inventing one.
+- If the user asks to repeat the same action N times and a tool exposes an internal count or limit argument
+ (for example `max_lines`), prefer a single Step using that argument instead of duplicating the same tool call.
- Use "{{{{bb.tool_name.key}}}}" to pass outputs from previous steps if relevant.
For example, if tool 'detect_object' outputs {{"pose": [1.0, 2.0]}},
you can pass it to navigate as:
args=[ArgValue(key="target", val="{{{{bb.detect_object.pose}}}}")]
+{identifier_rules}
{user_context}
## Available tools:
{tools_text}
diff --git a/src/vulcanai/core/manager_plan.py b/src/vulcanai/core/manager_plan.py
index 78b3cbb..2b89f4e 100644
--- a/src/vulcanai/core/manager_plan.py
+++ b/src/vulcanai/core/manager_plan.py
@@ -30,8 +30,17 @@ def __init__(
k: int = 5,
hist_depth: int = 3,
logger=None,
+ default_tools=True,
):
- super().__init__(model, registry=registry, validator=validator, k=k, hist_depth=hist_depth, logger=logger)
+ super().__init__(
+ model,
+ registry=registry,
+ validator=validator,
+ k=k,
+ hist_depth=hist_depth,
+ logger=logger,
+ default_tools=default_tools,
+ )
def _get_prompt_template(self) -> str:
"""
@@ -43,6 +52,14 @@ def _get_prompt_template(self) -> str:
containing one or more steps grouped into one or more PlanNodes.
Use PlanNodes to group steps that need to be executed together, either in sequence or in parallel, to
achieve sub-goals.
+Never return only a summary: always produce at least one PlanNode with at least one Step.
+Inputs whose type ends with `?` are optional and may be omitted when the tool default behavior is appropriate.
+If an input is marked optional and the user did not ask for a specific value,
+omit that argument instead of inventing one.
+Do not guess placeholder values such as `1`, `1.0`, `10`, or empty strings for optional inputs.
+If the user asks to repeat the same action N times and a tool exposes an internal count or limit argument
+(for example `max_lines`), prefer a single Step using that argument instead of duplicating the same tool call.
+{identifier_rules}
{user_context}
## Available tools:
{tools_text}
diff --git a/src/vulcanai/core/plan_types.py b/src/vulcanai/core/plan_types.py
index bdf915f..75095ef 100644
--- a/src/vulcanai/core/plan_types.py
+++ b/src/vulcanai/core/plan_types.py
@@ -32,7 +32,7 @@ class StepBase(BaseModel):
"""Atomic execution unit bound to a ITool."""
# Associated tool
- tool: str = None
+ tool: str = Field(..., description="Registered tool name")
# Tool arguments
args: List[ArgValue] = Field(
default_factory=list,
@@ -59,7 +59,7 @@ class PlanBase(BaseModel):
# SEQUENCE or PARALLEL execution of children
kind: Kind
# Child nodes
- steps: List[Step] = Field(default_factory=list)
+ steps: List[Step] = Field(..., min_length=1)
# Execution control
condition: Optional[str] = None
success_criteria: Optional[str] = None
@@ -79,7 +79,7 @@ class GlobalPlan(BaseModel):
"""GlobalPlan returned by the LLM with each step to be executed."""
# Top-level plan structure. Always executed sequentially.
- plan: List[PlanNode] = Field(default_factory=list)
+ plan: List[PlanNode] = Field(..., min_length=1)
# Brief summary of the plan
summary: Optional[str] = None
@@ -89,7 +89,7 @@ def __str__(self) -> str:
lines.append(f"- Plan Summary: {self.summary}\n")
color_tool = VulcanAILogger.vulcanai_theme["executor"]
- color_variable = VulcanAILogger.vulcanai_theme["validator"]
+ color_variable = VulcanAILogger.vulcanai_theme["tool"]
color_value = VulcanAILogger.vulcanai_theme["registry"]
color_error = VulcanAILogger.vulcanai_theme["error"]
diff --git a/src/vulcanai/core/validator.py b/src/vulcanai/core/validator.py
index 9cf5122..d8a1ac3 100644
--- a/src/vulcanai/core/validator.py
+++ b/src/vulcanai/core/validator.py
@@ -19,6 +19,14 @@
TYPE_ALIAS = {"int": int, "integer": int, "float": float, "bool": bool, "boolean": bool, "str": str, "string": str}
+def _is_optional_schema_type(type_name: str) -> bool:
+ return isinstance(type_name, str) and type_name.endswith("?")
+
+
+def _base_schema_type(type_name: str) -> str:
+ return type_name[:-1] if _is_optional_schema_type(type_name) else type_name
+
+
class PlanValidator:
"""Validates and optionally augments a plan before execution."""
@@ -37,28 +45,47 @@ def validate(self, plan: GlobalPlan):
"""
if not isinstance(plan, GlobalPlan):
raise ValueError("Provided plan is not a GlobalPlan instance.")
+ if not plan.plan:
+ raise ValueError("GlobalPlan has no PlanNodes defined.")
for node in plan.plan:
- if isinstance(node, PlanNode):
- if not node.steps:
- raise ValueError(f"PlanNode '{node.node_id}' has no steps defined.")
- for step in node.steps:
- self._validate_step(step)
+ if not isinstance(node, PlanNode):
+ raise ValueError("Provided node is not a PlanNode instance.")
+ if not node.steps:
+ raise ValueError(f"PlanNode '{node.kind}' has no steps defined.")
+ for step in node.steps:
+ self._validate_step(step)
def _validate_step(self, step: Step):
"""Validate a single step in the plan."""
if not isinstance(step, Step):
raise ValueError("Provided step is not a Step instance.")
+ if not getattr(step, "tool", None):
+ raise ValueError("Provided step has no tool defined.")
# Check tool exists and is registered
if step.tool not in self.registry.tools:
raise ValueError(f"Tool '{step.tool}' not found in registry.")
# Validate args against tool input schema
tool = self.registry.tools.get(step.tool)
if tool.input_schema:
- if len(step.args) != len(tool.input_schema):
+ required_keys = {key for key, type_name in tool.input_schema if not _is_optional_schema_type(type_name)}
+ provided_keys = {arg.key for arg in step.args}
+ schema_len = len(tool.input_schema)
+ required_len = len(required_keys)
+
+ if len(step.args) < required_len or len(step.args) > schema_len:
+ if required_len == schema_len:
+ raise ValueError(
+ f"Tool '{tool.name}' expects {schema_len} arguments, but {len(step.args)} were provided."
+ )
raise ValueError(
- f"Tool '{tool.name}' expects {len(tool.input_schema)} arguments,"
+ f"Tool '{tool.name}' expects between {required_len} and {schema_len} arguments,"
f" but {len(step.args)} were provided."
)
+
+ missing_required = sorted(required_keys - provided_keys)
+ if missing_required:
+ raise ValueError(f"Tool '{tool.name}' is missing required arguments: {', '.join(missing_required)}.")
+
for arg in step.args:
if arg.key not in {k for d in tool.input_schema for k in d}:
raise ValueError(f"Argument '{arg.key}' not defined in tool '{tool.name}' input schema.")
@@ -78,23 +105,20 @@ def _validate_step(self, step: Step):
# adhere to the schema
for schema in tool.input_schema:
if arg.key in schema:
- is_string_type = schema[1] in ["str", "string"]
+ is_string_type = _base_schema_type(schema[1]) in ["str", "string"]
if not is_string_type:
raise ValueError(
f"Argument '{arg.key}' of tool '{tool.name}' expects type"
- f" '{TYPE_ALIAS.get(schema[1])}', but got '{type(arg.val).__name__}'."
+ f" '{TYPE_ALIAS.get(_base_schema_type(schema[1]))}',"
+ f" but got '{type(arg.val).__name__}'."
)
# Check type if static value for non-string types
else:
expected_type = None
for schema in tool.input_schema:
if arg.key in schema:
- print(f"Schema for arg '{arg.key}' in tool '{tool.name}': {schema}") # Debug print
# Use TYPE_ALIAS to map string type names to actual types
- expected_type = TYPE_ALIAS.get(schema[1])
- print(
- f"Expected type for arg '{arg.key}' in tool '{tool.name}': {expected_type}"
- ) # Debug print
+ expected_type = TYPE_ALIAS.get(_base_schema_type(schema[1]))
break
if expected_type and not isinstance(arg.val, expected_type):
if expected_type is float and isinstance(arg.val, int):
diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py
new file mode 100644
index 0000000..ab983b3
--- /dev/null
+++ b/src/vulcanai/tools/default_tools.py
@@ -0,0 +1,2175 @@
+# Copyright 2026 Proyectos y Sistemas de Mantenimiento SL (eProsima).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+"""
+This file contains the default tools given by VulcanAI.
+It contains atomic tools used to call ROS2 CLI.
+"""
+
+import importlib
+import json
+import threading
+import time
+from concurrent.futures import Future
+
+from vulcanai import AtomicTool, vulcanai_tool
+from vulcanai.tools.utils import (
+ execute_subprocess,
+ log_tool_in_stream_and_main,
+ print_tool_output,
+ run_oneshot_cmd,
+ suggest_string,
+)
+
+# ROS2 imports
+try:
+ import rclpy
+ from rclpy.node import Node
+ from rclpy.task import Future
+except ImportError:
+ raise ImportError("Unable to load default tools because no ROS 2 installation was found.")
+
+try:
+ from rclpy.executors import SingleThreadedExecutor
+except ImportError:
+ SingleThreadedExecutor = None
+
+
+# This class contains a ROS 2 node that will be loaded if none is provided to launch ROS 2 default tools
+class ROS2DefaultToolNode(Node):
+ def __init__(self, name: str = "vulcanai_ros2_default_tools_node"):
+ self._vulcan_context = rclpy.context.Context()
+ self._vulcan_context.init()
+ # Parameter services are attached manually below so the helper node can
+ # answer `ros2 param *` requests while still keeping spin ownership local.
+ super().__init__(name, context=self._vulcan_context, start_parameter_services=False)
+ # Dictionary to store created clients
+ self._vulcan_clients = {}
+ # Dictionary to store created publishers
+ self._vulcan_publishers = {}
+
+ # Ensure entities creation is thread-safe.
+ self.node_lock = threading.Lock()
+ self._vulcanai_spin_stop = threading.Event()
+ self._vulcan_executor = None
+ if SingleThreadedExecutor is not None:
+ self._vulcan_executor = SingleThreadedExecutor(context=self.context)
+ self._vulcan_executor.add_node(self)
+
+ self._ensure_parameter_services()
+
+ # Runs in a background thread and keeps the node serviced
+ # That means subscriptions, service responses,
+ # and especially parameter-service callbacks can actually be processed
+ # Without something spinning the node, external ros2 CLI calls can hang waiting for a reply
+ self._vulcanai_spin_thread = threading.Thread(
+ target=self._spin_forever,
+ name=f"{name}_spin",
+ daemon=True,
+ )
+ self._vulcanai_spin_thread.start()
+
+ # region PRIVATE
+
+ def _ensure_parameter_services(self):
+ parameter_service = getattr(self, "_parameter_service", None)
+ if parameter_service is not None:
+ return parameter_service
+
+ from rclpy.parameter_service import ParameterService
+
+ parameter_service = ParameterService(self)
+ setattr(self, "_parameter_service", parameter_service)
+ return parameter_service
+
+ def _spin_forever(self):
+ while not self._vulcanai_spin_stop.is_set() and self.context.ok():
+ try:
+ self.spin_once(timeout_sec=0.1)
+ except Exception:
+ if self._vulcanai_spin_stop.is_set() or not self.context.ok():
+ break
+ time.sleep(0.05)
+
+ # endregion
+
+ # region PUBLIC
+
+ def spin_once(self, timeout_sec: float = 0.1):
+ spin_lock = _get_node_spin_lock(self)
+ if spin_lock is None:
+ if self._vulcan_executor is not None:
+ self._vulcan_executor.spin_once(timeout_sec=timeout_sec)
+ else:
+ rclpy.spin_once(self, timeout_sec=timeout_sec)
+ else:
+ with spin_lock:
+ if self._vulcan_executor is not None:
+ self._vulcan_executor.spin_once(timeout_sec=timeout_sec)
+ else:
+ rclpy.spin_once(self, timeout_sec=timeout_sec)
+
+ def get_client(self, srv_type, srv_name):
+ """
+ Get a cached client for the specified service type and name or
+ create a new one if it doesn't exist.
+ """
+ key = (srv_type, srv_name)
+ with self.node_lock:
+ if key not in self._vulcan_clients:
+ client = self.create_client(srv_type, srv_name)
+ self._vulcan_clients[key] = client
+ self.get_logger().info(f"Created new client for {srv_name}")
+ return self._vulcan_clients[key]
+
+ def get_publisher(self, msg_type, topic_name):
+ """
+ Get a cached publisher for the specified message type and topic name or
+ create a new one if it doesn't exist.
+ """
+ key = (msg_type, topic_name)
+ with self.node_lock:
+ if key not in self._vulcan_publishers:
+ publisher = self.create_publisher(msg_type, topic_name, 10)
+ self._vulcan_publishers[key] = publisher
+ self.get_logger().info(f"Created new publisher for {topic_name}")
+ return self._vulcan_publishers[key]
+
+ def wait_for_message(self, msg_type, topic: str, timeout_sec: float = None):
+ """
+ Block until a message is received or timeout expires.
+ Subscriptions are created on demand and destroyed after use to avoid
+ handling spins and callbacks in a separate thread.
+ """
+ future = Future()
+
+ def callback(msg):
+ if not future.done():
+ future.set_result(msg)
+
+ sub = self.create_subscription(msg_type, topic, callback, 10)
+
+ deadline = None if timeout_sec is None else time.monotonic() + timeout_sec
+ while not future.done():
+ if deadline is None:
+ spin_timeout = 0.1
+ else:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ spin_timeout = min(0.1, remaining)
+ self.spin_once(timeout_sec=spin_timeout)
+ self.destroy_subscription(sub)
+
+ if future.done():
+ return future.result()
+ return None
+
+ def destroy_node(self):
+ self._vulcanai_spin_stop.set()
+ spin_thread = getattr(self, "_vulcanai_spin_thread", None)
+ if spin_thread is not None and spin_thread.is_alive():
+ spin_thread.join(timeout=1.0)
+ try:
+ return super().destroy_node()
+ finally:
+ if self._vulcan_executor is not None:
+ try:
+ self._vulcan_executor.remove_node(self)
+ except Exception:
+ pass
+ self._vulcan_executor.shutdown(timeout_sec=1.0)
+ if self.context.ok():
+ self.context.try_shutdown()
+
+ # endregion
+
+
+"""
+Available ROS 2 CLI commands that can be run with the tools in this file:
+
+- ros2 node
+ Commands:
+ info Output information about a node
+ list Output a list of available nodes
+
+- ros2 topic
+ Commands:
+ bw Display bandwidth used by topic
+ delay Display delay of topic from timestamp in header
+ echo Output messages from a topic
+ find Output a list of available topics of a given type
+ hz Print the average receiving rate to screen
+ info Print information about a topic
+ list Output a list of available topics
+ pub Publish a message to a topic
+ type Print a topic's type
+
+- ros2 service
+ Commands:
+ call Call a service
+ echo Echo a service
+ find Output a list of available services of a given type
+ info Print information about a service
+ list Output a list of available services
+ type Output a service's type
+
+- ros2 action
+ Commands:
+ info Print information about an action
+ list Output a list of action names
+ send_goal Send an action goal
+ type Print a action's type
+ echo Echo a action
+ find Find actions from type
+
+
+- ros2 param
+ Commands:
+ delete Delete parameter
+ describe Show descriptive information about declared parameters
+ dump Show all of the parameters of a node in a YAML file format
+ get Get parameter
+ list Output a list of available parameters
+ load Load parameter file for a node
+ set Set parameter
+
+
+- ros2 pkg
+ Commands:
+ executables Output a list of package specific executables
+ list Output a list of available packages
+ prefix Output the prefix path of a package
+ xml Output the XML of the package manifest or a specific ta
+
+
+- ros2 interfaces
+ Commands:
+ list List all interface types available
+ package Output a list of available interface types within one package
+ packages Output a list of packages that provide interfaces
+"""
+
+
+def _require_console(bb):
+ console = bb.get("console", None)
+ if console is None:
+ raise Exception("Could not find console, aborting...")
+ return console
+
+
+def _get_node_spin_lock(node):
+ if node is None:
+ return None
+
+ spin_lock = getattr(node, "_vulcanai_spin_lock", None)
+ if spin_lock is None:
+ spin_lock = threading.Lock()
+ setattr(node, "_vulcanai_spin_lock", spin_lock)
+ return spin_lock
+
+
+def _node_spins_in_background(node) -> bool:
+ if node is None:
+ return False
+
+ spin_thread = getattr(node, "_vulcanai_spin_thread", None)
+ return spin_thread is not None and spin_thread.is_alive()
+
+
+def _normalize_command(command):
+ if command is None:
+ raise ValueError("`command` is required.")
+ return str(command).lower()
+
+
+def _run_ros2_node_command(console, tool_name: str, command: str, node_name: str = None):
+ command = _normalize_command(command)
+ result = {"output": ""}
+
+ node_name_list_str = run_oneshot_cmd(["ros2", "node", "list"])
+ node_name_list = node_name_list_str.splitlines()
+
+ if command == "list":
+ result["output"] = node_name_list_str
+ elif command == "info":
+ suggested_topic = suggest_string(console, tool_name, "Node", node_name, node_name_list)
+ if suggested_topic is not None:
+ node_name = suggested_topic
+
+ if not node_name:
+ raise ValueError("`command='{}'` requires `node_name`.".format("info"))
+
+ result["output"] = run_oneshot_cmd(["ros2", "node", "info", node_name])
+ else:
+ raise ValueError(f"Unknown command '{command}'. Expected one of: list, info.")
+
+ print_tool_output(console, result["output"], tool_name)
+ return result
+
+
+def _run_ros2_topic_command(
+ console,
+ tool_name: str,
+ command: str,
+ topic_name: str = None,
+ msg_type: str = None,
+ max_duration: float = None,
+ max_lines: int = None,
+):
+ command = _normalize_command(command)
+ result = {"output": ""}
+
+ topic_name_list_str = run_oneshot_cmd(["ros2", "topic", "list"])
+ topic_name_list = topic_name_list_str.splitlines()
+
+ if command == "find":
+ if not msg_type:
+ raise ValueError("`command='find'` requires `msg_type`.")
+ elif command != "list":
+ suggested_topic_name = suggest_string(console, tool_name, "Topic", topic_name, topic_name_list)
+ if suggested_topic_name is not None:
+ topic_name = suggested_topic_name
+
+ if not topic_name:
+ raise ValueError("`command='{}'` requires `topic_name`.".format(command))
+
+ if command == "list":
+ result["output"] = topic_name_list_str
+ elif command == "info":
+ result["output"] = run_oneshot_cmd(["ros2", "topic", "info", topic_name])
+ elif command == "find":
+ find_output = run_oneshot_cmd(["ros2", "topic", "find", msg_type])
+ find_topics = [line.strip() for line in find_output.splitlines() if line.strip()]
+ result["output"] = ", ".join(find_topics)
+ elif command == "type":
+ result["output"] = run_oneshot_cmd(["ros2", "topic", "type", topic_name])
+ elif command == "bw":
+ result["output"] = execute_subprocess(
+ console,
+ tool_name,
+ ["ros2", "topic", "bw", topic_name],
+ max_duration,
+ max_lines,
+ )
+ elif command == "delay":
+ result["output"] = execute_subprocess(
+ console,
+ tool_name,
+ ["ros2", "topic", "delay", topic_name],
+ max_duration,
+ max_lines,
+ )
+ elif command == "hz":
+ result["output"] = execute_subprocess(
+ console,
+ tool_name,
+ ["ros2", "topic", "hz", topic_name],
+ max_duration,
+ max_lines,
+ )
+ else:
+ raise ValueError(
+ f"Unknown command '{command}'. Expected one of: list, info, echo, bw, delay, hz, find, pub, type."
+ )
+
+ print_tool_output(console, result["output"], tool_name)
+ return result
+
+
+def _run_ros2_service_command(
+ console,
+ tool_name: str,
+ command: str,
+ service_name: str = None,
+ service_type: str = None,
+ call_args: str = None,
+ max_duration: float = None,
+ max_lines: int = None,
+):
+ command = _normalize_command(command)
+ result = {"output": ""}
+
+ service_name_list_str = run_oneshot_cmd(["ros2", "service", "list"])
+ service_name_list = service_name_list_str.splitlines()
+
+ if command == "find":
+ if not service_type:
+ raise ValueError("`command='find'` requires `service_type`.")
+ elif command != "list":
+ suggested_service_name = suggest_string(console, tool_name, "Service", service_name, service_name_list)
+ if suggested_service_name is not None:
+ service_name = suggested_service_name
+
+ if not service_name:
+ raise ValueError("`command='{}'` requires `service_name`.".format(command))
+
+ if command == "list":
+ result["output"] = service_name_list_str
+ elif command == "info":
+ result["output"] = run_oneshot_cmd(["ros2", "service", "info", service_name])
+ elif command == "type":
+ result["output"] = run_oneshot_cmd(["ros2", "service", "type", service_name]).strip()
+ elif command == "find":
+ result["output"] = run_oneshot_cmd(["ros2", "service", "find", service_type])
+ elif command == "call":
+ if call_args is None:
+ raise ValueError("`command='call'` requires `args`.")
+ if not service_type:
+ service_type = run_oneshot_cmd(["ros2", "service", "type", service_name]).strip()
+ result["output"] = run_oneshot_cmd(["ros2", "service", "call", service_name, service_type, call_args])
+ elif command == "echo":
+ result["output"] = execute_subprocess(
+ console,
+ tool_name,
+ ["ros2", "service", "echo", service_name],
+ max_duration,
+ max_lines,
+ )
+ else:
+ raise ValueError(f"Unknown command '{command}'. Expected one of: list, info, type, call, echo, find.")
+
+ print_tool_output(console, result["output"], tool_name)
+ return result
+
+
+def _run_ros2_action_command(
+ console,
+ tool_name: str,
+ command: str,
+ action_name: str = None,
+ action_type: str = None,
+ goal_args: str = None,
+):
+ command = _normalize_command(command)
+ result = {"output": ""}
+
+ action_name_list_str = run_oneshot_cmd(["ros2", "action", "list"])
+ action_name_list = action_name_list_str.splitlines()
+
+ if command != "list":
+ suggested_action_name = suggest_string(console, tool_name, "Action", action_name, action_name_list)
+ if suggested_action_name is not None:
+ action_name = suggested_action_name
+
+ if not action_name:
+ raise ValueError("`command='{}'` requires `action_name`.".format(command))
+
+ if command == "list":
+ result["output"] = action_name_list_str
+ elif command == "info":
+ result["output"] = run_oneshot_cmd(["ros2", "action", "info", action_name])
+ elif command == "type":
+ result["output"] = run_oneshot_cmd(["ros2", "action", "type", action_name])
+ elif command == "send_goal":
+ if not action_type:
+ action_type = run_oneshot_cmd(["ros2", "action", "type", action_name]).strip()
+ args_list = ["ros2", "action", "send_goal", action_name, action_type]
+ if goal_args is not None:
+ args_list.append(goal_args)
+ result["output"] = run_oneshot_cmd(args_list)
+ else:
+ raise ValueError(f"Unknown command '{command}'. Expected one of: list, info, type, send_goal.")
+
+ print_tool_output(console, result["output"], tool_name)
+ return result
+
+
+def _parse_ros2_param_list_output(output: str, node_name: str = None):
+ """
+ Parse `ros2 param list` output into plain parameter names.
+
+ The ROS 2 CLI can emit either a node-scoped block format or a flat list.
+ When `node_name` is provided, only parameters for that node are returned.
+ """
+ parsed_params = []
+ current_node = None
+ saw_node_header = False
+
+ for raw_line in output.splitlines():
+ stripped_line = raw_line.strip()
+ if not stripped_line:
+ continue
+
+ if stripped_line.endswith(":") and not raw_line[:1].isspace():
+ current_node = stripped_line[:-1]
+ saw_node_header = True
+ continue
+
+ if not saw_node_header or node_name is None or current_node == node_name:
+ parsed_params.append(stripped_line)
+
+ return parsed_params
+
+
+def _normalize_ros2_param_list_output(output: str, node_name: str = None):
+ """
+ Normalize `ros2 param list` output to the node-scoped block format.
+
+ Some ROS 2 distributions emit only plain parameter names for
+ `ros2 param list `, while others include a `:` header.
+ Returning a stable format keeps higher-level tooling and tests
+ consistent across environments.
+ """
+ if node_name is None or node_name in output:
+ return output
+
+ parsed_params = _parse_ros2_param_list_output(output, node_name=node_name)
+ if not parsed_params:
+ return output
+
+ normalized_lines = [f"{node_name}:"]
+ normalized_lines.extend(f" {param_name}" for param_name in parsed_params)
+ normalized_output = "\n".join(normalized_lines)
+ if output.endswith("\n"):
+ normalized_output += "\n"
+ return normalized_output
+
+
+def _run_ros2_param_command(
+ console,
+ tool_name: str,
+ command: str,
+ node_name: str = None,
+ param_name: str = None,
+ set_value: str = None,
+ file_path: str = None,
+):
+ command = _normalize_command(command)
+ result = {"output": ""}
+
+ requires_node_name = command != "list" or node_name is not None
+ if requires_node_name:
+ node_name_list_str = run_oneshot_cmd(["ros2", "node", "list"])
+ node_name_list = node_name_list_str.splitlines()
+ suggested_node_name = suggest_string(console, tool_name, "Node", node_name, node_name_list)
+ if suggested_node_name is not None:
+ node_name = suggested_node_name
+ if not node_name:
+ raise ValueError("`command='{}'` requires `node_name`.".format(command))
+
+ if command in ["get", "describe", "set", "delete"]:
+ param_name_list_str = run_oneshot_cmd(["ros2", "param", "list", node_name])
+ param_name_list = _parse_ros2_param_list_output(param_name_list_str, node_name=node_name)
+ suggested_param_name = suggest_string(console, tool_name, "Param", param_name, param_name_list)
+ if suggested_param_name is not None:
+ param_name = suggested_param_name
+ if not param_name:
+ raise ValueError("`command='{}'` requires `param_name`.".format(command))
+
+ if command == "list":
+ if node_name:
+ raw_output = run_oneshot_cmd(["ros2", "param", "list", node_name])
+ result["output"] = _normalize_ros2_param_list_output(raw_output, node_name=node_name)
+ else:
+ result["output"] = run_oneshot_cmd(["ros2", "param", "list"])
+ elif command == "get":
+ get_output = run_oneshot_cmd(["ros2", "param", "get", node_name, param_name])
+ if "parameter not set" in get_output.lower():
+ raise Exception(f"Parameter '{param_name}' is not set on node '{node_name}'.")
+ result["output"] = get_output
+ elif command == "describe":
+ result["output"] = run_oneshot_cmd(["ros2", "param", "describe", node_name, param_name])
+ elif command == "set":
+ if set_value is None:
+ raise ValueError("`command='set'` requires `set_value`.")
+ result["output"] = run_oneshot_cmd(["ros2", "param", "set", node_name, param_name, str(set_value)])
+ elif command == "delete":
+ result["output"] = run_oneshot_cmd(["ros2", "param", "delete", node_name, param_name])
+ elif command == "dump":
+ dump_output = run_oneshot_cmd(["ros2", "param", "dump", node_name])
+ if file_path:
+ with open(file_path, "w", encoding="utf-8") as output_file:
+ output_file.write(dump_output)
+ result["output"] = dump_output or f"Dumped parameters to {file_path}"
+ else:
+ result["output"] = dump_output
+ elif command == "load":
+ if not file_path:
+ raise ValueError("`command='load'` requires `file_path`.")
+ result["output"] = run_oneshot_cmd(["ros2", "param", "load", node_name, file_path])
+ else:
+ raise ValueError(
+ f"Unknown command '{command}'. Expected one of: list, get, describe, set, delete, dump, load."
+ )
+
+ print_tool_output(console, result["output"], tool_name)
+ return result
+
+
+def _run_ros2_pkg_command(console, tool_name: str, command: str):
+ command = _normalize_command(command)
+ result = {"output": ""}
+
+ if command == "list":
+ result["output"] = run_oneshot_cmd(["ros2", "pkg", "list"])
+ elif command == "executables":
+ result["output"] = run_oneshot_cmd(["ros2", "pkg", "executables"])
+ else:
+ raise ValueError(f"Unknown command '{command}'. Expected one of: list, executables, prefix, xml")
+
+ print_tool_output(console, result["output"], tool_name)
+ return result
+
+
+def _run_ros2_interface_command(console, tool_name: str, command: str, interface_name: str = None):
+ command = _normalize_command(command)
+ result = {"output": ""}
+
+ interface_name_list_str = run_oneshot_cmd(["ros2", "interface", "list"])
+ interface_name_list = interface_name_list_str.splitlines()
+
+ package_name_list_str = run_oneshot_cmd(["ros2", "interface", "packages"])
+ package_name_list = package_name_list_str.splitlines()
+
+ if command == "list":
+ result["output"] = interface_name_list_str
+ elif command == "packages":
+ result["output"] = package_name_list_str
+ elif command == "package":
+ package_name = interface_name
+ suggested_package_name = suggest_string(console, tool_name, "Interface", package_name, package_name_list)
+ if suggested_package_name is not None:
+ package_name = suggested_package_name
+ if not interface_name:
+ raise ValueError("`command='{}'` requires `interface_name`.".format(command))
+ # Keep existing command behavior for compatibility.
+ result["output"] = run_oneshot_cmd(["ros2", "interface", "package", package_name])
+ elif command == "show":
+ suggested_interface_name = suggest_string(console, tool_name, "Interface", interface_name, interface_name_list)
+ if suggested_interface_name is not None:
+ interface_name = suggested_interface_name
+ if not interface_name:
+ raise ValueError("`command='{}'` requires `interface_name`.".format(command))
+ # Keep existing command behavior for compatibility.
+ result["output"] = run_oneshot_cmd(["ros2", "interface", "show", interface_name])
+ else:
+ raise ValueError(
+ f"Unknown command '{command}'. Expected one of: list, info, echo, bw, delay, hz, find, pub, type."
+ )
+
+ print_tool_output(console, result["output"], tool_name)
+ return result
+
+
+# -----------------------------------------------------------------------------
+# ROS 2 wrappers kept for direct tests and compatibility
+# -----------------------------------------------------------------------------
+
+
+class Ros2NodeTool(AtomicTool):
+ name = "ros2_node"
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_node_command(console, self.name, kwargs.get("command"), node_name=kwargs.get("node_name"))
+
+
+class Ros2TopicTool(AtomicTool):
+ name = "ros2_topic"
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_topic_command(
+ console,
+ self.name,
+ kwargs.get("command"),
+ topic_name=kwargs.get("topic_name"),
+ msg_type=kwargs.get("msg_type"),
+ max_duration=kwargs.get("max_duration"),
+ max_lines=kwargs.get("max_lines"),
+ )
+
+
+class Ros2ServiceTool(AtomicTool):
+ name = "ros2_service"
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_service_command(
+ console,
+ self.name,
+ kwargs.get("command"),
+ service_name=kwargs.get("service_name"),
+ service_type=kwargs.get("service_type"),
+ call_args=kwargs.get("args"),
+ max_duration=kwargs.get("max_duration"),
+ max_lines=kwargs.get("max_lines"),
+ )
+
+
+class Ros2ActionTool(AtomicTool):
+ name = "ros2_action"
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_action_command(
+ console,
+ self.name,
+ kwargs.get("command"),
+ action_name=kwargs.get("action_name"),
+ action_type=kwargs.get("action_type"),
+ goal_args=kwargs.get("goal_args"),
+ )
+
+
+class Ros2ParamTool(AtomicTool):
+ name = "ros2_param"
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_param_command(
+ console,
+ self.name,
+ kwargs.get("command"),
+ node_name=kwargs.get("node_name"),
+ param_name=kwargs.get("param_name"),
+ set_value=kwargs.get("set_value"),
+ file_path=kwargs.get("file_path"),
+ )
+
+
+class Ros2PkgTool(AtomicTool):
+ name = "ros2_pkg"
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_pkg_command(console, self.name, kwargs.get("command"))
+
+
+class Ros2InterfaceTool(AtomicTool):
+ name = "ros2_interface"
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_interface_command(
+ console, self.name, kwargs.get("command"), interface_name=kwargs.get("interface_name")
+ )
+
+
+# ---------------------------------------------------------------------------
+# Strict per-subcommand ROS 2 tools (planner-facing)
+# ---------------------------------------------------------------------------
+
+
+@vulcanai_tool
+class Ros2NodeListTool(AtomicTool):
+ name = "ros2_node_list"
+ group_name = "ros2_node"
+ tool_description = "List all currently available ROS 2 nodes. "
+ description = (
+ "List all currently available ROS 2 nodes. "
+ "Equivalent to `ros2 node list`. "
+ "Use when the user wants to list, show, display, or print ROS 2 nodes."
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "nodes",
+ "list",
+ "node list",
+ "ros2 node list",
+ "list ros2 nodes",
+ "show ros2 nodes",
+ "print ros2 nodes",
+ "available nodes",
+ ]
+ input_schema = []
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_node_command(console, self.name, "list")
+
+
+@vulcanai_tool
+class Ros2NodeInfoTool(AtomicTool):
+ name = "ros2_node_info"
+ group_name = "ros2_node"
+ tool_description = "Show details for a specific ROS 2 node. "
+ description = (
+ "Show details for a specific ROS 2 node. "
+ "Equivalent to `ros2 node info [name]`."
+ "Use when the user wants to list, show, display, or print the information of a ROS 2 node."
+ )
+ tags = ["ros2", "ros 2", "info", "node info", "ros2 node info", "inspect node", "show node details"]
+ input_schema = [("node_name", "string")]
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_node_command(console, self.name, "info", node_name=kwargs.get("node_name"))
+
+
+@vulcanai_tool
+class Ros2TopicListTool(AtomicTool):
+ name = "ros2_topic_list"
+ group_name = "ros2_topic"
+ tool_description = "List all currently available ROS 2 topics. "
+ description = (
+ "List all currently available ROS 2 topics. "
+ "Equivalent to `ros2 topic list`. "
+ "Use when the user wants to list, show, display, or print ROS 2 topics."
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "topics",
+ "list",
+ "topic list",
+ "ros2 topic list",
+ "list ros2 topics",
+ "show ros2 topics",
+ "print ros2 topics",
+ "available topics",
+ ]
+ input_schema = []
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_topic_command(console, self.name, "list")
+
+
+@vulcanai_tool
+class Ros2TopicInfoTool(AtomicTool):
+ name = "ros2_topic_info"
+ group_name = "ros2_topic"
+ tool_description = "Show details for a specific ROS 2 topic. "
+ description = (
+ "Show details for a specific ROS 2 topic. "
+ "Equivalent to `ros2 topic info`. "
+ "Use when the user wants to list, show, display, or print the information of a ROS 2 topic."
+ )
+ tags = ["ros2", "ros 2", "info", "topic info", "ros2 topic info", "inspect topic", "show topic details"]
+ input_schema = [("topic_name", "string")]
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_topic_command(console, self.name, "info", topic_name=kwargs.get("topic_name"))
+
+
+@vulcanai_tool
+class Ros2TopicFindTool(AtomicTool):
+ name = "ros2_topic_find"
+ group_name = "ros2_topic"
+ tool_description = "Find ROS 2 topics by message type. "
+ description = (
+ "Find ROS 2 topics by message type. "
+ "Equivalent to `ros2 topic find [msg_type]`."
+ "Use when the user wants to find, show, display, or print the topic with a given ROS 2 topic type."
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "find",
+ "topic find",
+ "ros2 topic find",
+ "find topics by type",
+ "topics by message type",
+ "find ros2 topics",
+ "message type",
+ ]
+ input_schema = [("msg_type", "string")]
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_topic_command(console, self.name, "find", msg_type=kwargs.get("msg_type"))
+
+
+@vulcanai_tool
+class Ros2TopicTypeTool(AtomicTool):
+ name = "ros2_topic_type"
+ group_name = "ros2_topic"
+ tool_description = "Show the message type used by a ROS 2 topic. "
+ description = (
+ "Show the message type used by a ROS 2 topic. "
+ "Equivalent to `ros2 topic type [topic_name]`."
+ "Use when the user wants to get, show, display, or print the type of a ROS 2 topic."
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "type",
+ "topic type",
+ "ros2 topic type",
+ "topic message type",
+ "show topic type",
+ "get topic type",
+ "topic datatype",
+ ]
+ input_schema = [("topic_name", "string")]
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_topic_command(console, self.name, "type", topic_name=kwargs.get("topic_name"))
+
+
+@vulcanai_tool
+class Ros2TopicBwTool(AtomicTool):
+ name = "ros2_topic_bw"
+ group_name = "ros2_topic"
+ tool_description = "Stream and observe ROS 2 topic bandwidth. "
+ description = (
+ "Stream and observe ROS 2 topic bandwidth. "
+ "Equivalent to `ros2 topic bw`. "
+ "Use when the user wants to show, display, or print the bandwidth of a ROS 2 topic that is publishing. "
+ "If optional limits are omitted, it defaults to 60 seconds and 100 lines."
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "bw",
+ "bandwidth",
+ "topic bandwidth",
+ "ros2 topic bandwidth",
+ "inspect topic bandwidth",
+ "show topic bandwidth",
+ ]
+
+ input_schema = [
+ ("topic_name", "string"),
+ ("max_duration", "float?"), # optional duration
+ ("max_lines", "int?"), # optional number of lines
+ ]
+ input_defaults = {"max_duration": 60, "max_lines": 100}
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+
+ max_duration = kwargs.get("max_duration")
+ if max_duration is None or not isinstance(max_duration, (int, float)):
+ max_duration = self.input_defaults["max_duration"]
+
+ max_lines = kwargs.get("max_lines")
+ if max_lines is None or not isinstance(max_lines, (int, float)):
+ max_lines = self.input_defaults["max_lines"]
+
+ return _run_ros2_topic_command(
+ console,
+ self.name,
+ "bw",
+ topic_name=kwargs.get("topic_name"),
+ max_duration=max_duration,
+ max_lines=max_lines,
+ )
+
+
+@vulcanai_tool
+class Ros2TopicDelayTool(AtomicTool):
+ name = "ros2_topic_delay"
+ group_name = "ros2_topic"
+ tool_description = "Stream and observe ROS 2 topic delay. "
+ description = (
+ "Stream and observe ROS 2 topic delay. "
+ "Equivalent to `ros2 topic delay`. "
+ "Use when the user wants to show, display, or print the delay of a ROS 2 topic that is publishing."
+ )
+ tags = ["ros2", "ros 2", "delay", "topic delay", "ros2 topic delay", "inspect topic delay", "show topic delay"]
+ input_schema = [
+ ("topic_name", "string"),
+ ("max_duration", "float?"), # optional duration
+ ("max_lines", "int?"), # optional number of lines
+ ]
+ input_defaults = {"max_duration": 60, "max_lines": 100}
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+
+ # Streaming commands variables
+ max_duration = kwargs.get("max_duration")
+ if max_duration is None or not isinstance(max_duration, (int, float)):
+ max_duration = self.input_defaults["max_duration"]
+
+ max_lines = kwargs.get("max_lines")
+ if max_lines is None or not isinstance(max_lines, (int, float)):
+ max_lines = self.input_defaults["max_lines"]
+
+ return _run_ros2_topic_command(
+ console,
+ self.name,
+ "delay",
+ topic_name=kwargs.get("topic_name"),
+ max_duration=max_duration,
+ max_lines=max_lines,
+ )
+
+
+@vulcanai_tool
+class Ros2TopicHzTool(AtomicTool):
+ name = "ros2_topic_hz"
+ group_name = "ros2_topic"
+ tool_description = "Stream and observe ROS 2 topic average receiving rate. "
+ description = (
+ "Stream and observe ROS 2 topic average receiving rate. "
+ "Equivalent to `ros2 topic hz`. "
+ "Use when the user wants to show, display, or print "
+ "the average receiving rate of a ROS 2 topic that is publishing."
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "hzratereceiving rateaverage receiving ratetopic hz",
+ "ros2 topic hz",
+ "inspect topic hz",
+ "show topic hz",
+ ]
+ input_schema = [
+ ("topic_name", "string"),
+ ("max_duration", "float?"), # optional duration
+ ("max_lines", "int?"), # optional number of lines
+ ]
+ input_defaults = {"max_duration": 60, "max_lines": 100}
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+
+ # Streaming commands variables
+ max_duration = kwargs.get("max_duration")
+ if max_duration is None or not isinstance(max_duration, (int, float)):
+ max_duration = self.input_defaults["max_duration"]
+
+ max_lines = kwargs.get("max_lines")
+ if max_lines is None or not isinstance(max_lines, (int, float)):
+ max_lines = self.input_defaults["max_lines"]
+
+ return _run_ros2_topic_command(
+ console,
+ self.name,
+ "hz",
+ topic_name=kwargs.get("topic_name"),
+ max_duration=max_duration,
+ max_lines=max_lines,
+ )
+
+
+@vulcanai_tool
+class Ros2ServiceListTool(AtomicTool):
+ name = "ros2_service_list"
+ group_name = "ros2_service"
+ tool_description = "List all currently available ROS 2 services. "
+ description = (
+ "List all currently available ROS 2 services. "
+ "Equivalent to `ros2 service list`. "
+ "Use when the user wants to list, show, display, or print ROS 2 services."
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "services",
+ "list",
+ "service list",
+ "ros2 service list",
+ "list ros2 services",
+ "show ros2 services",
+ "print ros2 services",
+ "available services",
+ ]
+ input_schema = []
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_service_command(console, self.name, "list")
+
+
+@vulcanai_tool
+class Ros2ServiceInfoTool(AtomicTool):
+ name = "ros2_service_info"
+ group_name = "ros2_service"
+ tool_description = "Show details for a specific ROS 2 service. "
+ description = (
+ "Show details for a specific ROS 2 service. "
+ "Equivalent to `ros2 service info`. "
+ "Use when the user wants to list, show, display, or print the information of a ROS 2 service."
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "info",
+ "information",
+ "service info",
+ "ros2 service info",
+ "inspect service",
+ "show service details",
+ ]
+ input_schema = [("service_name", "string")]
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_service_command(console, self.name, "info", service_name=kwargs.get("service_name"))
+
+
+@vulcanai_tool
+class Ros2ServiceTypeTool(AtomicTool):
+ name = "ros2_service_type"
+ group_name = "ros2_service"
+ tool_description = "Show the message type used by a ROS 2 service. "
+ description = (
+ "Show the message type used by a ROS 2 service. "
+ "Equivalent to `ros2 service type [service_name]`."
+ "Use when the user wants to get, show, display, or print the type of a ROS 2 service."
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "type",
+ "service type",
+ "ros2 service type",
+ "service message type",
+ "show service type",
+ "get service type",
+ "service datatype",
+ ]
+ input_schema = [("service_name", "string")]
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_service_command(console, self.name, "type", service_name=kwargs.get("service_name"))
+
+
+@vulcanai_tool
+class Ros2ServiceFindTool(AtomicTool):
+ name = "ros2_service_find"
+ group_name = "ros2_service"
+ tool_description = "Find ROS 2 services by message type. "
+ description = (
+ "Find ROS 2 services by message type. "
+ "Equivalent to `ros2 service find [msg_type]`."
+ "Use when the user wants to find, show, display, or print the service with a given ROS 2 sevice type."
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "service find",
+ "ros2 service find",
+ "find services by type",
+ "services by message type",
+ "find ros2 services",
+ "message type",
+ ]
+ input_schema = [("service_type", "string")]
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_service_command(console, self.name, "find", service_type=kwargs.get("service_type"))
+
+
+@vulcanai_tool
+class Ros2ServiceCallTool(AtomicTool):
+ name = "ros2_service_call"
+ group_name = "ros2_service"
+ tool_description = "Call a ROS 2 service. "
+ description = (
+ "Call a ROS 2 service. "
+ "Equivalent to `ros2 service call [args]`."
+ "Use when the user wants to call a ROS 2 service ."
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "call",
+ "ros2 service call",
+ "call service",
+ "call ros2 service",
+ ]
+ input_schema = [
+ ("service_name", "string"), # service name
+ ("service_type", "string"), # service type
+ ("args", "string"), # args
+ ]
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_service_command(
+ console,
+ self.name,
+ "call",
+ service_name=kwargs.get("service_name"),
+ service_type=kwargs.get("service_type"),
+ call_args=kwargs.get("args"),
+ )
+
+
+@vulcanai_tool
+class Ros2ServiceEchoTool(AtomicTool):
+ name = "ros2_service_echo"
+ group_name = "ros2_service"
+ tool_description = "Stream and observe ROS 2 service traffic over time. "
+ description = (
+ "Stream and observe ROS 2 service traffic over time. "
+ "Equivalent to `ros2 service echo [service_name]`."
+ "Use when the user wants to show, display, or print the information a ROS 2 service is publishing."
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "service echo",
+ "ros2 service echo",
+ "show service traffic",
+ "stream service traffic",
+ "observe service",
+ "echo service",
+ ]
+ input_schema = [
+ ("service_name", "string"),
+ ("max_duration", "float?"), # optional time
+ ("max_lines", "int?"), # optional number of lines
+ ]
+ input_defaults = {"max_duration": 60, "max_lines": 100}
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+
+ # Streaming commands variables
+ max_duration = kwargs.get("max_duration")
+ if max_duration is None or not isinstance(max_duration, (int, float)):
+ max_duration = self.input_defaults["max_duration"]
+
+ max_lines = kwargs.get("max_lines")
+ if max_lines is None or not isinstance(max_lines, (int, float)):
+ max_lines = self.input_defaults["max_lines"]
+
+ return _run_ros2_service_command(
+ console,
+ self.name,
+ "echo",
+ service_name=kwargs.get("service_name"),
+ max_duration=max_duration,
+ max_lines=max_lines,
+ )
+
+
+@vulcanai_tool
+class Ros2ActionListTool(AtomicTool):
+ name = "ros2_action_list"
+ group_name = "ros2_action"
+ tool_description = "List all currently available ROS 2 actions. "
+ description = (
+ "List all currently available ROS 2 actions. "
+ "Equivalent to `ros2 action list`. "
+ "Use when the user wants to list, show, display, or print ROS 2 actions."
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "actions",
+ "action list",
+ "ros2 action list",
+ "list ros2 actions",
+ "show ros2 actions",
+ "print ros2 actions",
+ "available actions",
+ ]
+ input_schema = []
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_action_command(console, self.name, "list")
+
+
+@vulcanai_tool
+class Ros2ActionInfoTool(AtomicTool):
+ name = "ros2_action_info"
+ group_name = "ros2_action"
+ tool_description = "Show details for a specific ROS 2 action. "
+ description = (
+ "Show details for a specific ROS 2 action. "
+ "Equivalent to `ros2 action info`. "
+ "Use when the user wants to list, show, display, or print the information of a ROS 2 action."
+ )
+ tags = ["ros2", "ros 2", "action info", "ros2 action info", "inspect action", "show action details"]
+ input_schema = [("action_name", "string")]
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_action_command(console, self.name, "info", action_name=kwargs.get("action_name"))
+
+
+@vulcanai_tool
+class Ros2ActionTypeTool(AtomicTool):
+ name = "ros2_action_type"
+ group_name = "ros2_action"
+ tool_description = "Show the message type used by a ROS 2 action. "
+ description = (
+ "Show the message type used by a ROS 2 action. "
+ "Equivalent to `ros2 action type [action_name]`."
+ "Use when the user wants to get, show, display, or print the type of a ROS 2 action."
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "action type",
+ "ros2 action type",
+ "action message type",
+ "show action type",
+ "get action type",
+ "action datatype",
+ ]
+ input_schema = [("action_name", "string")]
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_action_command(console, self.name, "type", action_name=kwargs.get("action_name"))
+
+
+@vulcanai_tool
+class Ros2ActionSendGoalTool(AtomicTool):
+ name = "ros2_action_send_goal"
+ group_name = "ros2_action"
+ tool_description = "Send a goal to a ROS 2 action server. "
+ description = (
+ "Send a goal to a ROS 2 action server. "
+ "Equivalent to `ros2 action send_goal [action_name] [action_type]`."
+ "Use when the user wants to send a ROS 2 action goal"
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "goal",
+ "send goal",
+ "action send goal",
+ "ros2 action send_goal",
+ "send action goal",
+ "call action",
+ "trigger action",
+ ]
+ input_schema = [
+ ("action_name", "string"), # action name
+ ("action_type", "string"), # action type
+ ("goal_args", "string"), # goal arguments
+ ]
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_action_command(
+ console,
+ self.name,
+ "send_goal",
+ action_name=kwargs.get("action_name"),
+ action_type=kwargs.get("action_type"),
+ goal_args=kwargs.get("goal_args"),
+ )
+
+
+@vulcanai_tool
+class Ros2ParamListTool(AtomicTool):
+ name = "ros2_param_list"
+ group_name = "ros2_param"
+ tool_description = "List all currently available ROS 2 params. "
+ description = (
+ "List all currently available ROS 2 params. "
+ "Equivalent to `ros2 param list`. "
+ "Use when the user wants to list, show, display, or print ROS 2 params."
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "params",
+ "param list",
+ "ros2 param list",
+ "list ros2 params",
+ "show ros2 params",
+ "print ros2 params",
+ "available params",
+ ]
+ input_schema = [
+ ("node_name", "string?") # optional node name
+ ]
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_param_command(console, self.name, "list", node_name=kwargs.get("node_name"))
+
+
+@vulcanai_tool
+class Ros2ParamGetTool(AtomicTool):
+ name = "ros2_param_get"
+ group_name = "ros2_param"
+ tool_description = "Get the value of a parameter from a ROS 2 node. "
+ description = (
+ "Get the value of a parameter from a ROS 2 node. "
+ "Equivalent to `ros2 param get [node_name] [param_name]`."
+ "Use when the user want to get a Ros 2 parameter"
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "get",
+ "param get",
+ "ros2 param get",
+ "get parameter",
+ "read parameter",
+ "show parameter value",
+ ]
+ input_schema = [
+ ("node_name", "string"), # node name
+ ("param_name", "string"), # node param name
+ ]
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_param_command(
+ console,
+ self.name,
+ "get",
+ node_name=kwargs.get("node_name"),
+ param_name=kwargs.get("param_name"),
+ )
+
+
+@vulcanai_tool
+class Ros2ParamDescribeTool(AtomicTool):
+ name = "ros2_param_describe"
+ group_name = "ros2_param"
+ tool_description = "Show the definition and metadata of a ROS 2 parameter. "
+ description = (
+ "Show the definition and metadata of a ROS 2 parameter. "
+ "Equivalent to `ros2 param describe [node_name] [param_name]`."
+ "Use when the user want to show, display or print a desciptive information about a ROS 2 parameter"
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "describe",
+ "param describe",
+ "ros2 param describe",
+ "describe parameter",
+ "parameter metadata",
+ "parameter details",
+ ]
+ input_schema = [
+ ("node_name", "string"), # node name
+ ("param_name", "string"), # node param name
+ ]
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_param_command(
+ console,
+ self.name,
+ "describe",
+ node_name=kwargs.get("node_name"),
+ param_name=kwargs.get("param_name"),
+ )
+
+
+@vulcanai_tool
+class Ros2ParamSetTool(AtomicTool):
+ name = "ros2_param_set"
+ group_name = "ros2_param"
+ tool_description = "Set the value of a parameter on a ROS 2 node. "
+ description = (
+ "Set the value of a parameter on a ROS 2 node. "
+ "Equivalent to `ros2 param set [node_name] [param_name] [value]`."
+ "Use when the user want to set a ROS 2 parameter"
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "set",
+ "param set",
+ "ros2 param set",
+ "set parameter",
+ "update parameter",
+ "change parameter value",
+ ]
+ input_schema = [
+ ("node_name", "string"), # node name
+ ("param_name", "string"), # node param name
+ ("set_value", "string"), # new param value
+ ]
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_param_command(
+ console,
+ self.name,
+ "set",
+ node_name=kwargs.get("node_name"),
+ param_name=kwargs.get("param_name"),
+ set_value=kwargs.get("set_value"),
+ )
+
+
+@vulcanai_tool
+class Ros2ParamDeleteTool(AtomicTool):
+ name = "ros2_param_delete"
+ group_name = "ros2_param"
+ tool_description = "Delete a parameter from a ROS 2 node. "
+ description = (
+ "Delete a parameter from a ROS 2 node. "
+ "Equivalent to `ros2 param delete [node_name] [param_name]`."
+ "Used when the user want to delete a ROS 2 parameter"
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "delete",
+ "remove",
+ "param delete",
+ "ros2 param delete",
+ "delete parameter",
+ "remove parameter",
+ "unset parameter",
+ ]
+ input_schema = [
+ ("node_name", "string"), # node name
+ ("param_name", "string"), # node param name
+ ]
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_param_command(
+ console,
+ self.name,
+ "delete",
+ node_name=kwargs.get("node_name"),
+ param_name=kwargs.get("param_name"),
+ )
+
+
+@vulcanai_tool
+class Ros2ParamDumpTool(AtomicTool):
+ name = "ros2_param_dump"
+ group_name = "ros2_param"
+ tool_description = "Export all parameters from a ROS 2 node. "
+ description = (
+ "Export all parameters from a ROS 2 node. "
+ "Equivalent to `ros2 param dump [node_name]`."
+ "Used when the user want to show, display or print all the parameters of a ROS 2 node"
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "dump",
+ "yaml",
+ "param dump",
+ "ros2 param dump",
+ "export parameters",
+ "dump parameters",
+ "save node parameters",
+ ]
+ input_schema = [("node_name", "string")]
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_param_command(console, self.name, "dump", node_name=kwargs.get("node_name"))
+
+
+@vulcanai_tool
+class Ros2ParamLoadTool(AtomicTool):
+ name = "ros2_param_load"
+ group_name = "ros2_param"
+ tool_description = "Load parameters into a ROS 2 node from a file."
+ description = (
+ "Load parameters into a ROS 2 node from a file."
+ "Equivalent to `ros2 param load [node_name] [file_path]`."
+ "Used when the user want to load a parameter filer for a ROS 2 node"
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "load",
+ "file",
+ "param load",
+ "ros2 param load",
+ "load parameters",
+ "import parameters",
+ "load params from file",
+ ]
+ input_schema = [("node_name", "string"), ("file_path", "string")]
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_param_command(
+ console,
+ self.name,
+ "load",
+ node_name=kwargs.get("node_name"),
+ file_path=kwargs.get("file_path"),
+ )
+
+
+@vulcanai_tool
+class Ros2PkgListTool(AtomicTool):
+ name = "ros2_pkg_list"
+ group_name = "ros2_pkg"
+ tool_description = "List all currently available ROS 2 pkgs. "
+ description = (
+ "List all currently available ROS 2 pkgs. "
+ "Equivalent to `ros2 pkg list`. "
+ "Use when the user wants to list, show, display, or print ROS 2 packages."
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "pkg",
+ "pkg list",
+ "ros2 pkg list",
+ "list ros2 pkgs",
+ "show ros2 pkgs",
+ "print ros2 pkgs",
+ "available pkgs",
+ ]
+ input_schema = []
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_pkg_command(console, self.name, "list")
+
+
+@vulcanai_tool
+class Ros2PkgExecutablesTool(AtomicTool):
+ name = "ros2_pkg_executables"
+ group_name = "ros2_pkg"
+ tool_description = "List executable entry points provided by ROS 2 packages. "
+ description = (
+ "List executable entry points provided by ROS 2 packages. "
+ "Equivalent to `ros2 pkg executables`."
+ "Used when the user show, display or print a list of ROS 2 package specific executables"
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "exec",
+ "executables",
+ "pkg executables",
+ "ros2 pkg executables",
+ "list package executables",
+ "show executables",
+ "package binaries",
+ ]
+ input_schema = []
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_pkg_command(console, self.name, "executables")
+
+
+@vulcanai_tool
+class Ros2InterfaceListTool(AtomicTool):
+ name = "ros2_interface_list"
+ group_name = "ros2_interface"
+ tool_description = "List all currently available ROS 2 interfaces. "
+ description = (
+ "List all currently available ROS 2 interfaces. "
+ "Equivalent to `ros2 interface list`. "
+ "Use when the user wants to list, show, display, or print ROS 2 interfaces."
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "interfaces",
+ "interface list",
+ "ros2 serviinterfacece list",
+ "list ros2 interfaces",
+ "show ros2 interfaces",
+ "print ros2 interfaces",
+ "available interfaces",
+ ]
+ input_schema = []
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_interface_command(console, self.name, "list")
+
+
+@vulcanai_tool
+class Ros2InterfacePackagesTool(AtomicTool):
+ name = "ros2_interface_packages"
+ group_name = "ros2_interface"
+ tool_description = "List ROS 2 packages that provide interfaces."
+ description = (
+ "List ROS 2 packages that provide interfaces."
+ "Equivalent to `ros2 interface packages`."
+ "Used when the user wants to list, show, display or print the packages of all ROS 2 interfaces"
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "list",
+ "pkgs",
+ "packages",
+ "interface packages",
+ "ros2 interface packages",
+ "list interface packages",
+ "packages with interfaces",
+ ]
+ input_schema = []
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_interface_command(console, self.name, "packages")
+
+
+@vulcanai_tool
+class Ros2InterfacePackageTool(AtomicTool):
+ name = "ros2_interface_package"
+ group_name = "ros2_interface"
+ tool_description = "List the interfaces provided by a ROS 2 package. "
+ description = (
+ "List the interfaces provided by a ROS 2 package. "
+ "Equivalent to `ros2 interface package [package_name]`."
+ "Used when the user wants to list, show, display or print the ROS 2 interfaces of a package"
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "interfacesinterface package",
+ "ros2 interface package",
+ "list package interfaces",
+ "show package interfaces",
+ "package interfaces",
+ ]
+ input_schema = [("interface_name", "string")]
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_interface_command(console, self.name, "package", interface_name=kwargs.get("interface_name"))
+
+
+@vulcanai_tool
+class Ros2InterfaceShowTool(AtomicTool):
+ name = "ros2_interface_show"
+ group_name = "ros2_interface"
+ tool_description = "Show the definition of a ROS 2 interface. "
+ description = (
+ "Show the definition of a ROS 2 interface. "
+ "Equivalent to `ros2 interface show [interface_name]`."
+ "Used when the user wants show, display or print the ROS 2 interface information"
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "info",
+ "information",
+ "interface show",
+ "ros2 interface show",
+ "show interface",
+ "interface definition",
+ "display interface",
+ ]
+ input_schema = [("interface_name", "string")]
+ output_schema = {"output": "string"}
+
+ def run(self, **kwargs):
+ console = _require_console(self.bb)
+ return _run_ros2_interface_command(console, self.name, "show", interface_name=kwargs.get("interface_name"))
+
+
+def import_msg_type(type_str: str, node):
+ """
+ Dynamically import a ROS 2 message type from its string identifier.
+
+ This function resolves a ROS 2 message type expressed as a string
+ (e.g. `"std_msgs/msg/String"`) into the corresponding Python message class
+ (`std_msgs.msg.String`).
+ """
+ info_list = type_str.split("/")
+
+ if len(info_list) != 3:
+ pkg = "std_msgs"
+ msg_name = info_list[-1]
+ node.get_logger().warn(
+ f"Cannot import ROS message type '{type_str}'. " + "Adding default pkg 'std_msgs' instead."
+ )
+ else:
+ pkg, _, msg_name = info_list
+
+ module = importlib.import_module(f"{pkg}.msg")
+
+ return getattr(module, msg_name)
+
+
+@vulcanai_tool
+class Ros2PublishTool(AtomicTool):
+ name = "ros_publish"
+ tool_description = "Stream and publish ROS 2 topic. "
+ description = (
+ "Stream and publish ROS 2 topic. "
+ "Equivalent to `ros2 topic pub`. "
+ "Use when the user wants to write or publish a message in a ROS 2 topic. "
+ "To publish N messages, call this tool once with `max_lines=N` instead of repeating the tool N times. "
+ "If optional limits are omitted, it defaults to 60 seconds, 100 messages, "
+ "and `std_msgs/msg/String` with a 0.1 second period."
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "write",
+ "writer",
+ "pub",
+ "publish",
+ "publisher",
+ "topic pub",
+ "ros2 topic pub",
+ "pub topic",
+ "write topic",
+ "repeat",
+ "count",
+ "times",
+ ]
+
+ input_schema = [
+ # ros2 topic pub
+ ("topic", "string"), # e.g. "/chatter"
+ ("msg_type", "string?"), # (optional) e.g. "std_msgs/msg/String" or "my_pkg/msg/CustomMsg"
+ ("message_data", "string?"), # (optional) payload - string for std_msgs/String or JSON for custom types
+ # Configuration of the publisher/execution
+ ("max_lines", "int?"), # (optional) number of messages to publish
+ ("max_duration", "float?"), # (optional) stop after this seconds
+ ("period_sec", "float?"), # (optional) delay between publishes (in seconds)
+ ]
+ input_defaults = {
+ "msg_type": "std_msgs/msg/String",
+ "max_lines": 100,
+ "max_duration": 60,
+ "period_sec": 0.1,
+ }
+ output_schema = {
+ "published": "bool",
+ "count": "int",
+ "topic": "string",
+ "output": "string",
+ }
+
+ def msg_from_dict(self, msg, values: dict):
+ """
+ Populate a ROS 2 message instance from a Python dictionary.
+
+ This function recursively assigns values from a dictionary to the
+ corresponding fields of a ROS 2 message instance.
+
+ Supports:
+ - Primitive fields (int, float, bool, string)
+ - Nested ROS 2 messages
+
+ """
+ for field, value in values.items():
+ attr = getattr(msg, field)
+ if hasattr(attr, "__slots__"):
+ self.msg_from_dict(attr, value)
+ else:
+ setattr(msg, field, value)
+
+ def run(self, **kwargs):
+ # Ros2 node to create the Publisher and print the log information
+ node = self.bb.get("main_node", None)
+ if node is None:
+ raise Exception("Could not find shared node, aborting...")
+ # Optional console handle to route logs to the subprocess panel.
+ console = self.bb.get("console", None)
+
+ result = {
+ "published": "False",
+ "count": "0",
+ "topic": "",
+ "output": "",
+ }
+
+ panel_enabled = console is not None and hasattr(console, "show_subprocess_panel")
+ if panel_enabled:
+ console.call_from_thread(console.show_subprocess_panel)
+ if hasattr(console, "change_route_logs"):
+ console.call_from_thread(console.change_route_logs, True)
+
+ topic_name = kwargs.get("topic", "/chatter")
+ # Support both 'message_data' (new) and 'message' (deprecated)
+ message_data = kwargs.get("message_data", kwargs.get("message", "Hello from VulcanAI PublishTool!"))
+ msg_type_str = kwargs.get("msg_type", self.input_defaults["msg_type"])
+
+ max_duration = kwargs.get("max_duration", self.input_defaults["max_duration"])
+ try:
+ max_duration = float(max_duration) if max_duration is not None else self.input_defaults["max_duration"]
+ except (TypeError, ValueError):
+ max_duration = self.input_defaults["max_duration"]
+ if max_duration <= 0:
+ max_duration = self.input_defaults["max_duration"]
+
+ max_lines = kwargs.get("max_lines", self.input_defaults["max_lines"])
+ try:
+ max_lines = int(max_lines) if max_lines is not None else self.input_defaults["max_lines"]
+ except (TypeError, ValueError):
+ max_lines = self.input_defaults["max_lines"]
+
+ period_sec = kwargs.get("period_sec", self.input_defaults["period_sec"])
+ try:
+ period_sec = float(period_sec) if period_sec is not None else self.input_defaults["period_sec"]
+ except (TypeError, ValueError):
+ period_sec = self.input_defaults["period_sec"]
+ if period_sec < 0.0:
+ period_sec = self.input_defaults["period_sec"]
+
+ qos_depth = 10
+
+ if console is None:
+ print("[ERROR] Console node is None")
+
+ return result
+
+ published_msgs = []
+ output_lines = []
+ publisher = None
+ cancel_token = None
+
+ try:
+ if not topic_name:
+ console.call_from_thread(console.logger.log_msg, "[ROS] [ERROR] No topic provided.")
+ return result
+
+ result["topic"] = topic_name
+
+ if max_lines is not None and max_lines <= 0:
+ # No messages to publish
+ console.call_from_thread(
+ console.logger.log_msg, "[ROS] [WARN] max_lines <= 0, nothing to publish."
+ )
+ return result
+
+ MsgType = import_msg_type(msg_type_str, node)
+ publisher = node.create_publisher(MsgType, topic_name, qos_depth)
+ cancel_token = Future()
+ console.set_stream_task(cancel_token)
+ log_tool_in_stream_and_main(
+ console,
+ "[tool]Publisher created![tool]",
+ tool_name=self.name,
+ )
+
+ start_time = time.monotonic()
+ next_publish_time = start_time
+ published_count = 0
+
+ while True:
+ if cancel_token.cancelled():
+ console.logger.log_tool("[tool]Ctrl+C received:[/tool] stopping publish...", tool_name=self.name)
+ break
+ if max_lines is not None and published_count >= max_lines:
+ console.logger.log_tool(
+ f"[tool]Stopping:[/tool] Reached max_lines = {max_lines}", tool_name=self.name
+ )
+ break
+ if max_duration is not None and (time.monotonic() - start_time) >= max_duration:
+ console.logger.log_tool(
+ f"[tool]Stopping:[/tool] Exceeded max_duration = {max_duration}s",
+ tool_name=self.name,
+ )
+ break
+
+ if period_sec and period_sec > 0.0:
+ remaining_wait = next_publish_time - time.monotonic()
+ if remaining_wait > 0.0:
+ time.sleep(remaining_wait)
+ if max_duration is not None and (time.monotonic() - start_time) >= max_duration:
+ console.logger.log_tool(
+ f"[tool]Stopping:[/tool] Exceeded max_duration = {max_duration}s",
+ tool_name=self.name,
+ )
+ break
+
+ msg = MsgType()
+
+ # Try to populate message based on message type
+ if hasattr(msg, "data"):
+ # Standard message type with a 'data' field (e.g., std_msgs/msg/String)
+ msg.data = message_data
+ else:
+ # Custom message type - parse message_data as JSON
+ try:
+ payload = json.loads(message_data)
+ self.msg_from_dict(msg, payload)
+ except json.JSONDecodeError as e:
+ console.call_from_thread(
+ console.logger.log_msg,
+ "[ROS] [ERROR] Failed to parse message_data as JSON for custom type"
+ + f"'{msg_type_str}': {e}",
+ )
+ return result
+
+ if hasattr(msg, "data"):
+ publish_line = f"[ROS] [INFO] Publishing: '{msg.data}'"
+ console.call_from_thread(console.logger.log_msg, f"{publish_line}")
+ else:
+ publish_line = f"[ROS] [INFO] Publishing custom message to '{topic_name}'"
+ console.call_from_thread(
+ console.logger.log_msg,
+ f"{publish_line}",
+ )
+ output_lines.append(publish_line)
+ publisher.publish(msg)
+ published_msgs.append(msg.data if hasattr(msg, "data") else str(msg))
+ published_count += 1
+
+ # Publishing does not require a blocking spin.
+ # When the helper node already owns a background spinner,
+ # competing for the same executor lock here can starve
+ # this loop long enough for 'max_duration' to win before 'max_lines'
+ if not _node_spins_in_background(node):
+ if hasattr(node, "spin_once"):
+ node.spin_once(timeout_sec=0.0)
+ else:
+ spin_lock = _get_node_spin_lock(node)
+ if spin_lock is None:
+ rclpy.spin_once(node, timeout_sec=0.0)
+ else:
+ with spin_lock:
+ rclpy.spin_once(node, timeout_sec=0.0)
+
+ if period_sec and period_sec > 0.0:
+ next_publish_time += period_sec
+
+ finally:
+ console.set_stream_task(None)
+ if panel_enabled:
+ if hasattr(console, "change_route_logs"):
+ console.call_from_thread(console.change_route_logs, False)
+ if publisher is not None:
+ try:
+ node.destroy_publisher(publisher)
+ except Exception:
+ pass
+
+ result["output"] = "\n".join(output_lines)
+
+ if published_msgs is not None:
+ result["published"] = "True"
+ result["count"] = len(published_msgs)
+
+ print_tool_output(console, result["output"], self.name)
+
+ return result
+
+
+@vulcanai_tool
+class Ros2SubscribeTool(AtomicTool):
+ name = "ros_subscribe"
+ tool_description = "Stream and observe ROS 2 topic. "
+ description = (
+ "Stream and observe ROS 2 topic. "
+ "Equivalent to `ros2 topic echo`. "
+ "Use when the user wants to show, display, print or listen what is being published in a ROS 2 topic. "
+ "If optional limits are omitted, it defaults to 60 seconds and 100 lines."
+ )
+ tags = [
+ "ros2",
+ "ros 2",
+ "echo",
+ "subscribe",
+ "subs",
+ "listen",
+ "topic echo",
+ "ros2 topic echo",
+ "inspect topic",
+ "show topic",
+ "display topic",
+ "print topic",
+ ]
+
+ input_schema = [
+ ("topic", "string"), # topic name
+ ("max_duration", "float?"), # (optional) stop after this seconds
+ ("max_lines", "int?"), # (optional) stop after this number of messages
+ ]
+ input_defaults = {"max_duration": 60, "max_lines": 100}
+ output_schema = {
+ "subscribed": "bool",
+ "count": "int",
+ "topic": "string",
+ "output": "string",
+ }
+
+ def run(self, **kwargs):
+ # Ros2 node to create the Publisher and print the log information
+ node = self.bb.get("main_node", None)
+ if node is None:
+ raise Exception("Could not find shared node, aborting...")
+ # Optional console handle to support Ctrl+C cancellation.
+ console = self.bb.get("console", None)
+
+ result = {
+ "subscribed": "False",
+ "count": "0",
+ "topic": "",
+ "output": "",
+ }
+
+ topic_name = kwargs.get("topic", None)
+ max_duration = kwargs.get("max_duration")
+ try:
+ max_duration = float(max_duration) if max_duration is not None else None
+ except (TypeError, ValueError):
+ max_duration = None
+ if max_duration is None or max_duration <= 0:
+ max_duration = self.input_defaults["max_duration"]
+
+ max_lines = kwargs.get("max_lines")
+ try:
+ max_lines = int(max_lines) if max_lines is not None else None
+ except (TypeError, ValueError):
+ max_lines = None
+ if max_lines is None or max_lines <= 0:
+ max_lines = self.input_defaults["max_lines"]
+
+ # Start line (stream panel/main panel with tool color)
+ console.logger.log_tool("[tool]Subscriber created![/tool]", tool_name=self.name)
+
+ # "--field data" prints only the data field from each message
+ # instead of the full YAML message
+ # "--no-arr" do not print array fields of messages
+ base_args = ["ros2", "topic", "echo", topic_name, "--field", "data", "--no-arr"]
+ ret = execute_subprocess(console, self.name, base_args, max_duration, max_lines, log_created=False)
+
+ ret_lines = [line for line in ret.splitlines() if line.strip()] if isinstance(ret, str) and ret else []
+ if max_lines is not None:
+ ret_lines = ret_lines[:max_lines]
+
+ result["output"] = "\n".join(ret_lines)
+
+ if ret is not None:
+ result["subscribed"] = "True"
+ result["count"] = len(ret_lines)
+ result["topic"] = topic_name
+
+ print_tool_output(console, result["output"], self.name)
+
+ return result
diff --git a/src/vulcanai/tools/tool_registry.py b/src/vulcanai/tools/tool_registry.py
index 901f442..5b6e47f 100644
--- a/src/vulcanai/tools/tool_registry.py
+++ b/src/vulcanai/tools/tool_registry.py
@@ -38,6 +38,10 @@ class HelpTool(ITool):
"""A tool that provides help information."""
name = "help"
+ tool_description = (
+ "Provides help information for using the library. It can list all available tools or"
+ " give info about the usage of a specific tool if 'tool_name' is provided as an argument."
+ )
description = (
"Provides help information for using the library. It can list all available tools or"
" give info about the usage of a specific tool if 'tool_name' is provided as an argument."
@@ -78,7 +82,7 @@ def run(self, **kwargs):
class ToolRegistry:
"""Holds all known tools and performs vector search over metadata."""
- def __init__(self, embedder=None, logger=None):
+ def __init__(self, embedder=None, logger=None, default_tools=True):
# Logging function from the class VulcanConsole
self.logger = logger or VulcanAILogger.default()
# Dictionary of tools (name -> tool instance)
@@ -97,7 +101,15 @@ def __init__(self, embedder=None, logger=None):
# Validation tools list to retrieve validation tools separately
self.validation_tools: List[str] = []
- def register_tool(self, tool: ITool, solve_deps: bool = True):
+ # Default tools
+ if default_tools:
+ try:
+ self.discover_tools_from_entry_points("ros2_default_tools")
+ except ImportError as e:
+ self.logger.log_msg(f"[error]{e}[/error]")
+ raise
+
+ def register_tool(self, tool: ITool, solve_deps: bool = True, log: bool = True):
"""Register a single tool instance."""
# Avoid duplicates
if tool.name in self.tools:
@@ -108,7 +120,8 @@ def register_tool(self, tool: ITool, solve_deps: bool = True):
self.validation_tools.append(tool.name)
emb = self.embedder.embed(self._doc(tool))
self._index.append((tool.name, emb))
- self.logger.log_registry(f"Registered tool: [registry]{tool.name}[/registry]")
+ if log:
+ self.logger.log_registry(f"Registered tool: [registry]{tool.name}[/registry]")
self.help_tool.available_tools = self.tools
if solve_deps:
# Get class of tool
@@ -157,6 +170,7 @@ def deactivate_tool(self, tool_name) -> bool:
def register(self):
"""Register all loaded classes marked with @vulcanai_tool."""
+ before = set(self.tools.keys())
composite_classes = []
for module in self._loaded_modules:
for name in dir(module):
@@ -166,11 +180,18 @@ def register(self):
if issubclass(tool, CompositeTool):
composite_classes.append(tool)
else:
- self.register_tool(tool(), solve_deps=False)
+ self.register_tool(tool(), solve_deps=False, log=False)
# Register composite tools after atomic ones to resolve dependencies
for tool_cls in composite_classes:
tool = tool_cls()
- self.register_tool(tool, solve_deps=True)
+ self.register_tool(tool, solve_deps=True, log=False)
+
+ newly_registered = [name for name in self.tools if name not in before]
+ self._log_tools_grouped(newly_registered)
+
+ def _get_tool_by_name(self, tool_name: str) -> ITool | None:
+ """Return a tool whether it is currently active or deactivated."""
+ return self.tools.get(tool_name) or self.deactivated_tools.get(tool_name)
def _resolve_dependencies(self, tool: CompositeTool):
"""Resolve and attach dependencies for a CompositeTool."""
@@ -272,4 +293,64 @@ def top_k(self, query: str, k: int = 5, validation: bool = False) -> list[ITool]
@staticmethod
def _doc(tool: ITool) -> str:
# Text used for embeddings
- return f"{tool.name}\n{tool.description}\n{tool.tags}\n{tool.input_schema}\n"
+ input_defaults = getattr(tool, "input_defaults", {}) or {}
+ inputs = []
+ for key, type_name in tool.input_schema:
+ optional = isinstance(type_name, str) and type_name.endswith("?")
+ base_type = type_name[:-1] if optional else type_name
+ role = "optional" if optional else "required"
+ default = f", default={input_defaults[key]}" if key in input_defaults else ""
+ inputs.append(f"{key}:{base_type}:{role}{default}")
+ return f"{tool.name}\n{tool.description}\n{tool.tags}\n{inputs}\n"
+
+ def group_tool_names(self, tool_names: list) -> list:
+ """Group tool names by each tools `group_name` attribute.
+
+ Tools that declare a `group_name` are collected under that group; tools
+ without the attribute are emitted standalone. Group metadata is looked
+ up from both active and deactivated tools so `/edit_tools` can preserve
+ group structure when an entire group is disabled. Registration order is
+ preserved, with each group emitted on the first occurrence of one of
+ its members.
+
+ Returns a list of (name, subtools) tuples:
+ - For groups: (group_name, [sorted subtool display names])
+ - For standalone tools: (tool_name, None)
+ """
+ result = []
+ group_tools: dict = {}
+ order = []
+
+ for name in tool_names:
+ tool = self._get_tool_by_name(name)
+ group = getattr(tool, "group_name", None) if tool is not None else None
+ if group:
+ if group not in group_tools:
+ group_tools[group] = []
+ order.append(("group", group))
+ display = name[len(group) + 1 :] if name.startswith(group + "_") else name
+ group_tools[group].append(display)
+ else:
+ order.append(("tool", name))
+
+ for kind, item in order:
+ if kind == "tool":
+ result.append((item, None))
+ else:
+ result.append((item, sorted(group_tools[item])))
+
+ return result
+
+ def _log_tools_grouped(self, tool_names: list):
+ """Log a batch of newly registered tools, grouped by common prefix."""
+ grouped = self.group_tool_names(tool_names)
+ for entry_name, subtools in grouped:
+ if subtools is None:
+ self.logger.log_registry(f"Registered tool: [registry]{entry_name}[/registry]")
+ else:
+ self.logger.log_registry(f"Registered group of tools: [registry]{entry_name}[/registry]")
+ for subtool in subtools:
+ self.logger.log_registry(
+ f"Registered tool: [registry]{subtool}[/registry]",
+ indent=1,
+ )
diff --git a/src/vulcanai/tools/tools.py b/src/vulcanai/tools/tools.py
index dc1b325..323837b 100644
--- a/src/vulcanai/tools/tools.py
+++ b/src/vulcanai/tools/tools.py
@@ -24,13 +24,20 @@ class ITool(ABC):
# Name given to the tool
name: str
- # Brief description of the tool's purpose
+ # Optional brief description of the tools.
+ # Used in the '/tools' command to display the info for the user. If this variable is not present in the tool
+ # the command fallback to the 'description' variable used by the LLM.
+ tool_description: Optional[str] = ""
+ # Detailed tool description used by the LLM during plan generation.
description: str
# List of keywords associated with the tool
tags: list[str] = []
# JSON schemas for input validation and output formatting.
# Only used for documentation and LLM prompt generation.
input_schema: List[Tuple[str, str]] = [] # List of (key, type) pairs, simulating a ArgValue list
+ # Optional default values for documented inputs.
+ # Used when optional arguments are omitted from a plan
+ input_defaults: Dict[str, Any] = {}
output_schema: Dict[str, str] = {}
# Validation tool
is_validation_tool: bool = False
diff --git a/src/vulcanai/tools/utils.py b/src/vulcanai/tools/utils.py
new file mode 100644
index 0000000..d5f8677
--- /dev/null
+++ b/src/vulcanai/tools/utils.py
@@ -0,0 +1,369 @@
+# Copyright 2026 Proyectos y Sistemas de Mantenimiento SL (eProsima).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+import asyncio
+import difflib
+import heapq
+import subprocess
+import threading
+import time
+
+from textual.markup import escape # To remove potential errors in textual terminal
+
+
+async def run_streaming_cmd_async(
+ console,
+ args: list[str],
+ max_duration: float | None = None,
+ max_lines: int | None = None,
+ echo: bool = True,
+ tool_name="",
+) -> str:
+ # Unpack the command
+ cmd, *cmd_args = args
+
+ captured_lines: list[str] = []
+ process = None
+ try:
+ # Create the subprocess
+ process = await asyncio.create_subprocess_exec(
+ cmd,
+ *cmd_args,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.STDOUT,
+ )
+
+ assert process.stdout is not None
+
+ start_time = time.monotonic()
+ line_count = 0
+
+ # Subprocess main loop. Read line by line
+ async for raw_line in process.stdout:
+ line = raw_line.decode(errors="ignore").rstrip("\n")
+ captured_line = line
+ display_line = line
+
+ # Print the line
+ if echo:
+ if args[:3] == ["ros2", "topic", "echo"] and line:
+ msg = line.strip()
+ if msg == "---":
+ continue
+ msg = msg.strip("'\"")
+ captured_line = f"[ROS] [INFO] I heard: '{msg}'"
+ display_line = f"{captured_line}"
+
+ captured_lines.append(captured_line)
+ line_processed = display_line if display_line != captured_line else escape(display_line)
+ if hasattr(console, "add_subprocess_line"):
+ console.add_subprocess_line(line_processed)
+ else:
+ console.add_line(line_processed)
+
+ # Count the line
+ line_count += 1
+ if max_lines is not None and line_count >= max_lines:
+ log_tool_in_stream_and_main(
+ console,
+ f"[tool]Stopping:[/tool] Reached max_lines = {max_lines}",
+ tool_name=tool_name,
+ )
+ console.set_stream_task(None)
+ process.terminate()
+ break
+
+ # Check duration
+ if max_duration is not None and (time.monotonic() - start_time) >= max_duration:
+ log_tool_in_stream_and_main(
+ console, f"[tool]Stopping:[/tool] Exceeded max_duration = {max_duration}s", tool_name=tool_name
+ )
+ console.set_stream_task(None)
+ process.terminate()
+ break
+
+ except asyncio.CancelledError:
+ # Task was cancelled → stop the subprocess
+ log_tool_in_stream_and_main(
+ console,
+ "[tool]Cancellation received:[/tool] terminating subprocess...",
+ tool_name=tool_name,
+ )
+ if process is not None:
+ process.terminate()
+
+ # Not necessary, textual terminal get the keyboard input
+ except KeyboardInterrupt:
+ # Ctrl+C pressed → stop subprocess
+ log_tool_in_stream_and_main(
+ console,
+ "[tool]Ctrl+C received:[/tool] terminating subprocess...",
+ tool_name=tool_name,
+ )
+ if process is not None:
+ process.terminate()
+
+ finally:
+ try:
+ if process is not None:
+ await asyncio.wait_for(process.wait(), timeout=3.0)
+ except asyncio.TimeoutError:
+ log_tool_in_stream_and_main(
+ console,
+ "Subprocess didn't exit in time → killing it.",
+ tool_name=tool_name,
+ error=True,
+ )
+ if process is not None:
+ process.kill()
+ await process.wait()
+ finally:
+ # Keep Ctrl+C target in sync with subprocess lifecycle.
+ console.set_stream_task(None)
+ return "\n".join(captured_lines)
+
+
+def execute_subprocess(console, tool_name, base_args, max_duration, max_lines, log_created: bool = True):
+ stream_task = None
+ route_enabled = False
+ done_event = threading.Event()
+ result = {"output": ""}
+
+ def _launcher() -> None:
+ nonlocal stream_task, route_enabled
+ if hasattr(console, "show_subprocess_panel"):
+ console.show_subprocess_panel()
+
+ if hasattr(console, "change_route_logs"):
+ console.change_route_logs(True)
+ route_enabled = True
+
+ # This always runs in the Textual event-loop thread
+ try:
+ loop = asyncio.get_running_loop()
+ stream_task = loop.create_task(
+ run_streaming_cmd_async(
+ console,
+ base_args,
+ max_duration=max_duration,
+ max_lines=max_lines,
+ tool_name=tool_name, # tool_header_str
+ )
+ )
+ # Keep the real task reference so Ctrl+C can cancel it.
+ console.set_stream_task(stream_task)
+
+ def _on_done(task: asyncio.Task) -> None:
+ nonlocal route_enabled
+ try:
+ if not task.cancelled():
+ result["output"] = task.result() or ""
+ except Exception as e:
+ console.logger.log_msg(f"Echo task error: {e!r}\n", error=True)
+ finally:
+ if route_enabled and hasattr(console, "change_route_logs"):
+ console.change_route_logs(False)
+ route_enabled = False
+ done_event.set()
+
+ stream_task.add_done_callback(_on_done)
+
+ except Exception:
+ if route_enabled and hasattr(console, "change_route_logs"):
+ console.change_route_logs(False)
+ route_enabled = False
+ done_event.set()
+ raise
+
+ # `/rerun` workers can have their own asyncio loop in a non-UI thread.
+ # Route UI/task creation to Textual app thread unless we are already there.
+ if threading.current_thread() is threading.main_thread():
+ _launcher()
+ else:
+ # `console.app` is your Textual App instance.
+ console.app.call_from_thread(_launcher)
+
+ if log_created:
+ console.logger.log_tool("[tool]Subprocess created![tool]", tool_name=tool_name)
+ # Wait for streaming command to finish and return collected lines.
+ # In UI thread we avoid blocking to prevent deadlocks.
+ if threading.current_thread() is threading.main_thread():
+ return ""
+ done_event.wait()
+ return result["output"]
+
+
+def run_oneshot_cmd(args: list[str]) -> str:
+ try:
+ return subprocess.check_output(args, stderr=subprocess.STDOUT, text=True)
+
+ except subprocess.CalledProcessError as e:
+ raise Exception(f"Failed to run '{' '.join(args)}': {e.output}")
+
+
+def suggest_string(console, tool_name, string_name, input_string, real_string_list):
+ if input_string is None:
+ return None
+
+ def _normalize_strings(values: list[str]) -> list[str]:
+ ret_normalized = []
+ seen_values = set()
+
+ for value in values:
+ cleaned_value = value.strip()
+ if not cleaned_value or cleaned_value in seen_values:
+ continue
+
+ seen_values.add(cleaned_value)
+ ret_normalized.append(cleaned_value)
+
+ return ret_normalized
+
+ input_string = input_string.strip()
+ real_string_list = _normalize_strings(real_string_list)
+ ret = input_string
+
+ def _similarity(a: str, b: str) -> float:
+ """Return a similarity score between 0 and 1."""
+ return difflib.SequenceMatcher(None, a, b).ratio()
+
+ def _get_suggestions(real_string_list_comp: list[str], string_comp: str) -> tuple[str, list[str]]:
+ """
+ Function used to search for the most "similar" string in a list.
+
+ Used in ROS2 cli commands to used the "simmilar" in case
+ the queried topic does not exists.
+
+ Example:
+ real_string_list_comp = [
+ "/parameter_events",
+ "/rosout",
+ "/turtle1/cmd_vel",
+ "/turtle1/color_sensor",
+ "/turtle1/pose",
+ ]
+ string_comp = "trtle1"
+
+ @return
+ str: the most "similar" string
+ list[str] a sorted list by a similitud value
+ """
+
+ topic_list_pq = []
+ n = len(string_comp)
+
+ for string in real_string_list_comp:
+ m = len(string)
+ # Calculate the similitud
+ score = _similarity(string_comp, string)
+ # Give more value for the nearest size comparations.
+ score -= abs(n - m) * 0.005
+ # Max-heap (via negative score)
+ heapq.heappush(topic_list_pq, (-score, string))
+
+ # Pop ordered list
+ ret_list: list[str] = []
+ _, most_topic_similar = heapq.heappop(topic_list_pq)
+
+ ret_list.append(most_topic_similar)
+
+ while topic_list_pq:
+ _, topic = heapq.heappop(topic_list_pq)
+ ret_list.append(topic)
+
+ return most_topic_similar, ret_list
+
+ # Add '/' for Topic, service, action, node
+ ros_categories_list = ["Topic", "Service", "Action", "Node"]
+ if string_name in ros_categories_list and len(input_string) > 0 and input_string[0] != "/":
+ input_string = f"/{input_string}"
+ ret = input_string
+
+ if input_string not in real_string_list:
+ console.logger.log_tool(f'{string_name}: "{input_string}" does not exists', tool_name=tool_name)
+
+ if not real_string_list:
+ return ret
+
+ # Get the suggestions list sorted by similitud value
+ _, topic_sim_list = _get_suggestions(real_string_list, input_string)
+
+ # Open the ModalScreen
+ console.open_radiolist(topic_sim_list, tool_name, string_name, input_string)
+
+ # Wait for the user to select and item in the
+ # RadioList ModalScreen
+ console.suggestion_index_changed.wait()
+
+ # Check if the user cancelled the suggestion
+ if console.suggestion_index >= 0:
+ ret = topic_sim_list[console.suggestion_index].strip()
+
+ # Reset suggestion index
+ console.suggestion_index = -1
+ console.suggestion_index_changed.clear()
+
+ return ret
+
+
+def last_output_lines(console, tool_name: str, output: str, n_lines: int = 10) -> str:
+ """
+ Keep only the last `max_lines` lines in tool output and log this behavior.
+ """
+ lines = output.splitlines()
+ if console is not None and hasattr(console, "logger"):
+ console.logger.log_tool(
+ f"Returning only the last {n_lines} lines in result['output'].",
+ tool_name=tool_name,
+ )
+ return "\n".join(lines[-n_lines:])
+
+
+def print_tool_output(console, result, name):
+ console.logger.log_tool("[tool]Output:[tool]", tool_name=name)
+ console.logger.log_msg(result, color="gray")
+ console.logger.log_msg("\n")
+
+
+def log_tool_in_stream_and_main(console, msg: str, tool_name: str = "", error: bool = False, color: str = "") -> None:
+ """
+ Log a tool message in both Logs (streaming and main)
+ """
+ processed_msg = console.logger.log_tool(msg, tool_name=tool_name, error=error, color=color)
+
+ route_state = getattr(console, "_route_logs_to_stream_panel", 0)
+ if isinstance(route_state, bool):
+ route_enabled = route_state
+ else:
+ route_enabled = route_state > 0
+
+ if not route_enabled:
+ return
+
+ main_panel = getattr(console, "main_pannel", None)
+ if main_panel is None:
+ return
+
+ app_thread_id = getattr(console, "_thread_id", None)
+ on_app_thread = app_thread_id is None or threading.get_ident() == app_thread_id
+ force_follow_main = False
+
+ for line in processed_msg.splitlines():
+ if on_app_thread:
+ main_panel.append_line(line, force_follow_main)
+ elif hasattr(console, "call_from_thread"):
+ console.call_from_thread(main_panel.append_line, line, force_follow_main)
+ else:
+ main_panel.append_line(line, force_follow_main)
diff --git a/tests/unittest/test_console_regressions.py b/tests/unittest/test_console_regressions.py
new file mode 100644
index 0000000..4714e2e
--- /dev/null
+++ b/tests/unittest/test_console_regressions.py
@@ -0,0 +1,299 @@
+# Copyright 2026 Proyectos y Sistemas de Mantenimiento SL (eProsima).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import asyncio
+import importlib
+import os
+import sys
+import types
+import unittest
+from types import SimpleNamespace
+from unittest.mock import Mock, PropertyMock, patch
+
+from textual.geometry import Offset
+
+
+class _DummySentenceTransformer:
+ def __init__(self, *args, **kwargs):
+ pass
+
+ def encode(self, text, convert_to_numpy=True):
+ return None
+
+ def similarity(self, a, b):
+ return None
+
+
+sys.modules.setdefault("sentence_transformers", types.SimpleNamespace(SentenceTransformer=_DummySentenceTransformer))
+
+
+CURRENT_DIR = os.path.dirname(__file__)
+SRC_DIR = os.path.abspath(os.path.join(CURRENT_DIR, os.path.pardir, os.path.pardir, "src"))
+if SRC_DIR not in sys.path:
+ sys.path.insert(0, SRC_DIR)
+
+
+class _CaptureSink:
+ def __init__(self):
+ self.messages = []
+
+ def write(self, msg: str, color: str = "") -> None:
+ self.messages.append((msg, color))
+
+
+class _FakeInput:
+ def __init__(self):
+ self.id = "cmd"
+ self.value = ""
+ self.focused = False
+
+ def focus(self):
+ self.focused = True
+
+
+class TestConsoleRegressions(unittest.IsolatedAsyncioTestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.console_mod = importlib.import_module("vulcanai.console.console")
+ cls.logger_mod = importlib.import_module("vulcanai.console.logger")
+ cls.modal_screens_mod = importlib.import_module("vulcanai.console.modal_screens")
+ cls.log_text_area_mod = importlib.import_module("vulcanai.console.widget_custom_log_text_area")
+
+ def setUp(self):
+ self._default_logger_instance = self.logger_mod.VulcanAILogger._default_instance
+ self._rich_markup_enabled = self.logger_mod.VulcanAILogger._rich_markup
+
+ def tearDown(self):
+ self.logger_mod.VulcanAILogger._default_instance = self._default_logger_instance
+ self.logger_mod.VulcanAILogger._rich_markup = self._rich_markup_enabled
+
+ def test_tools_command_escapes_tool_summaries(self):
+ """`/tools` should escape tool descriptions that contain markup-like tokens."""
+ console = self.console_mod.VulcanConsole(default_tools=False)
+ sink = _CaptureSink()
+ console.logger = self.logger_mod.VulcanAILogger(sink=sink)
+
+ topic_tool = SimpleNamespace(
+ name="ros2_topic_type",
+ tool_description="Equivalent to `ros2 topic type [topic_name]`.",
+ description="fallback",
+ )
+ console.manager = SimpleNamespace(
+ k=7,
+ registry=SimpleNamespace(
+ tools={"ros2_topic_type": topic_tool},
+ group_tool_names=lambda names: [("ros2_topic", ["type"])],
+ ),
+ )
+ console.commands = {"/tools": console.cmd_tools}
+
+ console.handle_command("/tools")
+
+ rendered = "\n".join(msg for msg, _ in sink.messages)
+ self.assertNotIn("Error:", rendered)
+ self.assertIn(r"\[topic_name]", rendered)
+
+ async def test_on_input_submitted_removes_angle_brackets_from_queries(self):
+ """User queries should be sanitized before echo/history/task scheduling."""
+ console = self.console_mod.VulcanConsole(default_tools=False)
+ console.is_ready = True
+ console.logger = Mock()
+ console._update_history_panel = Mock()
+ console._is_stream_task_active = Mock(return_value=False)
+ console._route_logs_to_stream_panel = 0
+
+ input_widget = _FakeInput()
+ console.query_one = Mock(return_value=input_widget)
+
+ captured = {}
+
+ async def fake_queriestrap(user_input):
+ captured["query"] = user_input
+
+ console.queriestrap = fake_queriestrap
+
+ raw_query = "show topic <>"
+ event = SimpleNamespace(value=raw_query, input=input_widget)
+
+ await console.on_input_submitted(event)
+ await asyncio.sleep(0)
+
+ self.assertIn("query", captured)
+ self.assertNotIn("<", captured["query"])
+ self.assertNotIn(">", captured["query"])
+ self.assertEqual(console.history[-1], captured["query"])
+ console.logger.log_user.assert_called_once_with(captured["query"])
+ self.assertEqual(input_widget.value, "")
+ self.assertTrue(input_widget.focused)
+
+ async def test_modal_enter_binding(self):
+ """Suggestion modal should accept the highlighted item when Enter is used."""
+ modal = self.modal_screens_mod.RadioListModal(["alpha", "beta", "gamma"])
+ modal.dismiss = Mock()
+ modal.query_one = Mock(return_value=SimpleNamespace(pressed_index=0, _selected=2))
+
+ modal.dismiss_selected()
+
+ modal.dismiss.assert_called_once_with(2)
+ bindings = {
+ (binding.key, binding.action)
+ for binding in self.modal_screens_mod.RadioListModal.SuggestionRadioSet.BINDINGS
+ }
+ self.assertIn(("enter", "submit_selection"), bindings)
+ self.assertIn(("space", "toggle_button"), bindings)
+
+ def test_log_text_area_keeps_following_output_when_sticky_follow_is_enabled(self):
+ """Appending output should stay anchored even if a one-shot end check would fail."""
+ widget = self.log_text_area_mod.CustomLogTextArea()
+ widget.is_near_vertical_scroll_end = Mock(return_value=False)
+ widget.scroll_end = Mock()
+ widget.scroll_to = Mock()
+ widget.call_after_refresh = Mock()
+ widget.refresh = Mock()
+
+ widget.append_line("hello")
+
+ self.assertEqual(widget.document.text, "hello")
+ self.assertTrue(widget.should_follow_output())
+ widget.scroll_end.assert_called_once_with(animate=False, immediate=True, x_axis=False)
+ widget.scroll_to.assert_not_called()
+ widget.call_after_refresh.assert_called_once_with(widget._scroll_to_output_end)
+
+ def test_log_text_area_preserves_viewport_when_follow_is_disabled(self):
+ """Appending output should not yank the viewport when the user scrolled up."""
+ widget = self.log_text_area_mod.CustomLogTextArea()
+ widget._follow_output = False
+ widget.scroll_end = Mock()
+ widget.scroll_to = Mock()
+ widget.call_after_refresh = Mock()
+ widget.refresh = Mock()
+
+ with patch.object(type(widget), "scroll_offset", new_callable=PropertyMock, return_value=Offset(3, 7)):
+ widget.append_line("hello")
+
+ self.assertFalse(widget.should_follow_output())
+ widget.scroll_end.assert_not_called()
+ widget.scroll_to.assert_called_once_with(x=3, y=7, animate=False, immediate=True, force=True)
+ widget.call_after_refresh.assert_called_once_with(widget._restore_scroll_position, 3, 7)
+
+ def test_log_text_area_follow_state_updates_when_user_moves_viewport(self):
+ """Returning to the end should resume sticky follow mode."""
+ widget = self.log_text_area_mod.CustomLogTextArea()
+ widget._follow_output = False
+ widget.is_near_vertical_scroll_end = Mock(return_value=True)
+
+ widget._update_follow_output_from_viewport()
+ self.assertTrue(widget.should_follow_output())
+
+ widget.is_near_vertical_scroll_end.return_value = False
+ widget._update_follow_output_from_viewport()
+ self.assertFalse(widget.should_follow_output())
+
+ def test_cmd_edit_tools_passes_default_tool_subset_to_modal(self):
+ """`/edit_tools` should expose built-in default tools to the checklist modal."""
+
+ class DefaultTool:
+ pass
+
+ class CustomTool:
+ pass
+
+ DefaultTool.__module__ = "vulcanai.tools.default_tools"
+ CustomTool.__module__ = "demo.custom_tools"
+
+ default_active = DefaultTool()
+ default_inactive = DefaultTool()
+ custom_tool = CustomTool()
+
+ tool_lookup = {
+ "ros2_topic_list": default_active,
+ "ros2_node_info": default_inactive,
+ "custom_tool": custom_tool,
+ }
+ registry = SimpleNamespace(
+ tools={"ros2_topic_list": default_active, "custom_tool": custom_tool},
+ deactivated_tools={"ros2_node_info": default_inactive},
+ group_tool_names=Mock(return_value=[("custom_tool", None)]),
+ _get_tool_by_name=lambda name: tool_lookup[name],
+ )
+
+ console = self.console_mod.VulcanConsole(default_tools=False)
+ console.manager = SimpleNamespace(registry=registry)
+ console.open_checklist = Mock()
+
+ console.cmd_edit_tools([])
+
+ registry.group_tool_names.assert_called_once_with(["custom_tool", "ros2_node_info", "ros2_topic_list"])
+ console.open_checklist.assert_called_once_with(
+ [("custom_tool", None)],
+ {"ros2_topic_list", "custom_tool"},
+ {"ros2_topic_list", "ros2_node_info"},
+ )
+
+ def test_tool_toggle_log_message_marks_default_tools(self):
+ """Activation logs should label built-in tools as default tools."""
+
+ class DefaultTool:
+ pass
+
+ class CustomTool:
+ pass
+
+ DefaultTool.__module__ = "vulcanai.tools.default_tools"
+ CustomTool.__module__ = "demo.custom_tools"
+
+ registry = SimpleNamespace(
+ _get_tool_by_name=lambda name: {
+ "ros2_topic_list": DefaultTool(),
+ "custom_tool": CustomTool(),
+ }.get(name)
+ )
+
+ console = self.console_mod.VulcanConsole(default_tools=False)
+ console.manager = SimpleNamespace(registry=registry)
+
+ self.assertEqual(
+ console._tool_toggle_log_message("ros2_topic_list", activated=True),
+ "Activated default tool 'ros2_topic_list'",
+ )
+ self.assertEqual(
+ console._tool_toggle_log_message("ros2_topic_list", activated=False),
+ "Deactivated default tool 'ros2_topic_list'",
+ )
+ self.assertEqual(
+ console._tool_toggle_log_message("custom_tool", activated=True),
+ "Activated tool 'custom_tool'",
+ )
+
+ def test_add_line_uses_sticky_follow_state_for_main_log(self):
+ """Main log writes should rely on the shared sticky follow state."""
+ console = self.console_mod.VulcanConsole(default_tools=False)
+ console.main_pannel = SimpleNamespace(
+ should_follow_output=Mock(return_value=True),
+ append_line=Mock(return_value=True),
+ )
+ console.stream_pannel = None
+ console.logger = Mock()
+ console._route_logs_to_stream_panel = 0
+ console._stream_panel_visible = False
+ console._is_stream_task_active = Mock(return_value=False)
+
+ console.add_line("hello")
+
+ console.main_pannel.should_follow_output.assert_called_once_with()
+ console.main_pannel.append_line.assert_called_once_with("hello", force_follow_output=True)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unittest/test_default_tools.py b/tests/unittest/test_default_tools.py
new file mode 100644
index 0000000..841725e
--- /dev/null
+++ b/tests/unittest/test_default_tools.py
@@ -0,0 +1,1531 @@
+# Copyright 2026 Proyectos y Sistemas de Mantenimiento SL (eProsima).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Unit tests for the ROS 2 default tools
+ - ros2_node
+ - ros2_topic
+ - ros2_service
+ - ros2_action
+ - ros2_param
+ - ros2 pkg
+ - ros2 interfaces
+
+These tests require a working ROS 2 environment (rclpy importable and
+ros2 CLI available). Tests are automatically skipped when ROS 2 is not
+installed.
+
+Tests call the tool's ``run()`` method directly with a minimal blackboard
+containing a mock console that implements the methods the tool relies on
+(logging, suggestion handling, subprocess panel, stream task).
+Background ``ros2 topic pub`` processes are used to create observable topics.
+"""
+
+import asyncio
+import importlib
+import os
+import signal
+import subprocess
+import sys
+import threading
+import time
+import unittest
+
+# -----------------------------------------------------------------------------
+# Skip entire module when ROS 2 is not available
+# -----------------------------------------------------------------------------
+try:
+ import rclpy # noqa: F401
+
+ ROS2_AVAILABLE = True
+except ImportError:
+ ROS2_AVAILABLE = False
+
+# Also check that the ros2 CLI is on PATH
+try:
+ subprocess.check_output(["ros2", "topic", "list"], stderr=subprocess.STDOUT, timeout=10)
+ ROS2_CLI_AVAILABLE = True
+except Exception:
+ ROS2_CLI_AVAILABLE = False
+
+
+# Make src-layout importable
+CURRENT_DIR = os.path.dirname(__file__)
+SRC_DIR = os.path.abspath(os.path.join(CURRENT_DIR, os.path.pardir, os.path.pardir, "src"))
+if SRC_DIR not in sys.path:
+ sys.path.insert(0, SRC_DIR)
+
+
+# -----------------------------------------------------------------------------
+# Mock console — implements only the interface used by tools and utils
+# -----------------------------------------------------------------------------
+class _MockLogger:
+ """Collects log calls for optional inspection."""
+
+ def __init__(self):
+ self.messages = []
+
+ def log_tool(self, msg, **kwargs):
+ self.messages.append(msg)
+
+ def log_msg(self, msg, **kwargs):
+ self.messages.append(msg)
+
+ def log_console(self, msg, **kwargs):
+ self.messages.append(msg)
+
+
+class _MockApp:
+ """Provides an asyncio event loop on a background thread,
+ mimicking Textual's ``app.call_from_thread()`` for
+ ``execute_subprocess``."""
+
+ def __init__(self):
+ self._loop = asyncio.new_event_loop()
+ self._thread = threading.Thread(target=self._loop.run_forever, daemon=True)
+ self._thread.start()
+
+ def call_from_thread(self, fn, *args, **kwargs):
+ self._loop.call_soon_threadsafe(fn, *args, **kwargs)
+
+ def stop(self):
+ self._loop.call_soon_threadsafe(self._loop.stop)
+ self._thread.join(timeout=5)
+ self._loop.close()
+
+
+class MockConsole:
+ """Implements the console interface expected by tools and their helpers.
+
+ Covers:
+ - ``logger`` — used for all tool logging
+ - ``app`` — used by execute_subprocess
+ (provides asyncio event loop)
+ - ``set_stream_task`` — used by execute_subprocess
+ - ``show/hide_subprocess_panel`` — used by execute_subprocess
+ - ``add_line / add_subprocess_line`` — used by run_streaming_cmd_async
+ - ``change_route_logs`` — used by streaming commands
+ - ``suggestion_index*`` — used by suggest_string when a topic
+ is not found (auto-selects first match)
+ - ``open_radiolist`` — used by suggest_string modal
+ """
+
+ def __init__(self):
+ self.logger = _MockLogger()
+ self.app = _MockApp()
+ # suggest_string support: auto-accept the first suggestion
+ self.suggestion_index = 0
+ self.suggestion_index_changed = threading.Event()
+ self.suggestion_index_changed.set() # unblock immediately
+ self._stream_task = None
+
+ def set_stream_task(self, task):
+ self._stream_task = task
+
+ def call_from_thread(self, fn, *args, **kwargs):
+ self.app.call_from_thread(fn, *args, **kwargs)
+
+ def show_subprocess_panel(self):
+ pass
+
+ def hide_subprocess_panel(self):
+ pass
+
+ def change_route_logs(self, value):
+ pass
+
+ def add_line(self, text):
+ pass
+
+ def add_subprocess_line(self, text):
+ pass
+
+ def open_radiolist(self, items, tool_name, string_name, input_string):
+ # Auto-select index 0 (the best match) without user interaction
+ pass
+
+ def stop(self):
+ self.app.stop()
+
+
+# -----------------------------------------------------------------------------
+# Helper: background ROS 2 publisher
+# -----------------------------------------------------------------------------
+def start_background_publisher(
+ topic: str,
+ msg_type: str = "std_msgs/msg/String",
+ rate: float = 10.0,
+ message: str = "hello_test",
+):
+ """Launch ``ros2 topic pub`` in a subprocess and return the Popen handle."""
+ proc = subprocess.Popen(
+ [
+ "ros2",
+ "topic",
+ "pub",
+ topic,
+ msg_type,
+ f"{{data: '{message}'}}",
+ "--rate",
+ str(rate),
+ ],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ )
+ # Give the publisher a moment to register with the ROS graph
+ time.sleep(2)
+ return proc
+
+
+def stop_background_publisher(proc: subprocess.Popen):
+ """Terminate a background publisher gracefully."""
+ if proc.poll() is None:
+ proc.send_signal(signal.SIGINT)
+ try:
+ proc.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait()
+ if proc.stdout is not None:
+ proc.stdout.close()
+ if proc.stderr not in (None, subprocess.STDOUT):
+ proc.stderr.close()
+
+
+def start_background_ros2_executable(package: str, executable: str, wait_sec: float = 2.0):
+ """Launch ``ros2 run `` in a subprocess."""
+ proc = subprocess.Popen(
+ ["ros2", "run", package, executable],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ )
+ time.sleep(wait_sec)
+ return proc
+
+
+def stop_background_process(proc: subprocess.Popen):
+ """Terminate a generic background process gracefully."""
+ if proc.poll() is None:
+ proc.send_signal(signal.SIGINT)
+ try:
+ proc.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait()
+ if proc.stdout is not None:
+ proc.stdout.close()
+ if proc.stderr not in (None, subprocess.STDOUT):
+ proc.stderr.close()
+
+
+# ===========================================================================
+# Test class TestRos2NodeTool
+# ===========================================================================
+@unittest.skipUnless(
+ ROS2_AVAILABLE and ROS2_CLI_AVAILABLE,
+ "ROS 2 (rclpy + ros2 CLI) not available — skipping",
+)
+class TestRos2NodeTool(unittest.TestCase):
+ """Direct tests for ``Ros2NodeTool.run()``."""
+
+ # -------------------------------------------------------------------------
+ # Fixtures
+ # -------------------------------------------------------------------------
+ @classmethod
+ def setUpClass(cls):
+ default_tools_mod = importlib.import_module("vulcanai.tools.default_tools")
+ cls.Ros2NodeTool = default_tools_mod.Ros2NodeTool
+ cls.ROS2DefaultToolNode = default_tools_mod.ROS2DefaultToolNode
+
+ def setUp(self):
+ self.console = MockConsole()
+ self.node = self.ROS2DefaultToolNode()
+ self.node_name = f"/{self.node.get_name()}"
+ self._bg_processes = []
+
+ def tearDown(self):
+ for proc in self._bg_processes:
+ stop_background_process(proc)
+ self._bg_processes.clear()
+ self.node.destroy_node()
+ self.console.stop()
+
+ @classmethod
+ def tearDownClass(cls):
+ if rclpy.ok():
+ rclpy.shutdown()
+
+ # -------------------------------------------------------------------------
+ # Helpers
+ # -------------------------------------------------------------------------
+ def _run_node(self, **kwargs):
+ tool = self.Ros2NodeTool()
+ tool.bb = {"console": self.console, "main_node": self.node}
+ return tool.run(**kwargs)
+
+ def _wait_for_node(self, timeout_sec: float = 5.0):
+ start_time = time.monotonic()
+ while (time.monotonic() - start_time) <= timeout_sec:
+ result = self._run_node(command="list")
+ if self.node_name in result["output"]:
+ return
+ time.sleep(0.2)
+ self.fail(f"Node '{self.node_name}' not visible within {timeout_sec} seconds")
+
+ # -------------------------------------------------------------------------
+ # Tests — Node name suggestion
+ # -------------------------------------------------------------------------
+ def test_node_name_suggestions(self):
+ """Misspelled node names should be corrected through suggestions."""
+ self._wait_for_node()
+ result = self._run_node(command="info", node_name=self.node.get_name())
+ self.assertIn("Subscribers:", result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 node list
+ # -------------------------------------------------------------------------
+ def test_node_list(self):
+ """`list` should include the default tool node name."""
+ self._wait_for_node()
+ result = self._run_node(command="list")
+ self.assertIn(self.node_name, result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 node info
+ # -------------------------------------------------------------------------
+ def test_node_info(self):
+ """`info` should provide standard node metadata."""
+ self._wait_for_node()
+ result = self._run_node(command="info", node_name=self.node_name)
+ self.assertIn("Subscribers:", result["output"])
+ self.assertIn("Publishers:", result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — Error
+ # -------------------------------------------------------------------------
+ def test_node_info_missing_name_raises(self):
+ """`info` with explicit None node_name should raise."""
+ with self.assertRaises((ValueError, TypeError)):
+ self._run_node(command="info", node_name=None)
+
+
+# ===========================================================================
+# Test class Ros2TopicTool
+# ===========================================================================
+@unittest.skipUnless(
+ ROS2_AVAILABLE and ROS2_CLI_AVAILABLE,
+ "ROS 2 (rclpy + ros2 CLI) not available — skipping",
+)
+class TestRos2TopicTool(unittest.TestCase):
+ """Direct tests for ``Ros2TopicTool.run()``.
+
+ Each test instantiates the tool, injects a blackboard with a
+ MockConsole and a ROS2DefaultToolNode, and calls ``run()`` directly.
+ """
+
+ # -------------------------------------------------------------------------
+ # Fixtures
+ # -------------------------------------------------------------------------
+ @classmethod
+ def setUpClass(cls):
+ """Import the default tools module and the ROS2DefaultToolNode."""
+ default_tools_mod = importlib.import_module("vulcanai.tools.default_tools")
+
+ cls.Ros2TopicTool = default_tools_mod.Ros2TopicTool
+ cls.ROS2DefaultToolNode = default_tools_mod.ROS2DefaultToolNode
+
+ def setUp(self):
+ self.console = MockConsole()
+ self.node = self.ROS2DefaultToolNode()
+ self._bg_publishers = []
+
+ def tearDown(self):
+ for proc in self._bg_publishers:
+ stop_background_publisher(proc)
+ self._bg_publishers.clear()
+ self.node.destroy_node()
+ self.console.stop()
+
+ @classmethod
+ def tearDownClass(cls):
+ if rclpy.ok():
+ rclpy.shutdown()
+
+ # -------------------------------------------------------------------------
+ # Helpers
+ # -------------------------------------------------------------------------
+ def _run_topic(self, **kwargs):
+ """Create a Ros2TopicTool, inject a blackboard, and call run()."""
+ tool = self.Ros2TopicTool()
+ tool.bb = {"console": self.console, "main_node": self.node}
+ return tool.run(**kwargs)
+
+ def _run_topic_threaded(self, **kwargs):
+ """Run the tool from a worker thread.
+
+ Streaming commands (bw, hz, delay) use ``execute_subprocess`` which
+ requires a non-main thread so it can call
+ ``console.app.call_from_thread()`` and then block on a done-event.
+ """
+ result = {}
+ error = {}
+
+ def worker():
+ try:
+ result["value"] = self._run_topic(**kwargs)
+ except Exception as e:
+ error["exc"] = e
+
+ t = threading.Thread(target=worker)
+ t.start()
+ t.join(timeout=30)
+ if "exc" in error:
+ raise error["exc"]
+ return result.get("value")
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 topic list
+ # -------------------------------------------------------------------------
+ def test_topic_list(self):
+ """`list` should succeed and contain /rosout."""
+ result = self._run_topic(command="list")
+
+ self.assertIn("output", result)
+ self.assertIn("/rosout", result["output"])
+
+ def test_topic_list_with_background_pub(self):
+ """After starting a background publisher, `list` should include
+ that topic."""
+ topic = "/vulcan_test_topic_list"
+ proc = start_background_publisher(topic)
+ self._bg_publishers.append(proc)
+
+ result = self._run_topic(command="list")
+
+ self.assertIn(topic, result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 topic info
+ # -------------------------------------------------------------------------
+ def test_topic_info(self):
+ """`info` on a published topic returns type and publisher count."""
+ topic = "/vulcan_test_topic_info"
+ proc = start_background_publisher(topic)
+ self._bg_publishers.append(proc)
+
+ result = self._run_topic(command="info", topic_name=topic)
+
+ self.assertIn("std_msgs/msg/String", result["output"])
+ self.assertIn("Publisher count:", result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 topic type
+ # -------------------------------------------------------------------------
+ def test_topic_type(self):
+ """`type` returns the message type of a topic."""
+ topic = "/vulcan_test_topic_type"
+ proc = start_background_publisher(topic)
+ self._bg_publishers.append(proc)
+
+ result = self._run_topic(command="type", topic_name=topic)
+
+ self.assertIn("std_msgs/msg/String", result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 topic find
+ # -------------------------------------------------------------------------
+ def test_topic_find(self):
+ """`find` locates topics by message type."""
+ topic = "/vulcan_test_topic_find"
+ proc = start_background_publisher(topic)
+ self._bg_publishers.append(proc)
+
+ result = self._run_topic(command="find", msg_type="std_msgs/msg/String")
+
+ self.assertIn(topic, result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 topic bw (streaming)
+ # -------------------------------------------------------------------------
+ def test_topic_bw(self):
+ """`bw` measures bandwidth on an active topic."""
+ topic = "/vulcan_test_topic_bw"
+ proc = start_background_publisher(topic, rate=10.0)
+ self._bg_publishers.append(proc)
+
+ result = self._run_topic_threaded(
+ command="bw",
+ topic_name=topic,
+ max_duration=5.0,
+ max_lines=20,
+ )
+
+ self.assertIsNotNone(result)
+ self.assertIn("output", result)
+ self.assertIsInstance(result["output"], str)
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 topic hz (streaming)
+ # -------------------------------------------------------------------------
+ def test_topic_hz(self):
+ """`hz` measures the publishing rate of a topic."""
+ topic = "/vulcan_test_topic_hz"
+ proc = start_background_publisher(topic, rate=10.0)
+ self._bg_publishers.append(proc)
+
+ result = self._run_topic_threaded(
+ command="hz",
+ topic_name=topic,
+ max_duration=5.0,
+ max_lines=20,
+ )
+
+ self.assertIsNotNone(result)
+ self.assertIn("output", result)
+ self.assertIsInstance(result["output"], str)
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 topic delay (streaming)
+ # -------------------------------------------------------------------------
+ def test_topic_delay(self):
+ """`delay` runs without error on an active topic.
+
+ Note: ``ros2 topic delay`` requires messages with a header stamp.
+ With ``std_msgs/msg/String`` (no header) the output may be empty
+ or contain a warning, but the command should not crash.
+ """
+ topic = "/vulcan_test_topic_delay"
+ proc = start_background_publisher(topic, rate=10.0)
+ self._bg_publishers.append(proc)
+
+ result = self._run_topic_threaded(
+ command="delay",
+ topic_name=topic,
+ max_duration=5.0,
+ max_lines=20,
+ )
+
+ self.assertIsNotNone(result)
+ self.assertIn("output", result)
+ self.assertIsInstance(result["output"], str)
+
+ # -------------------------------------------------------------------------
+ # Tests — streaming commands with custom max_duration / max_lines
+ # -------------------------------------------------------------------------
+ def test_topic_hz_custom_limits(self):
+ """`hz` respects custom max_duration and max_lines."""
+ topic = "/vulcan_test_topic_hz_limits"
+ proc = start_background_publisher(topic, rate=10.0)
+ self._bg_publishers.append(proc)
+
+ result = self._run_topic_threaded(
+ command="hz",
+ topic_name=topic,
+ max_duration=3.0,
+ max_lines=5,
+ )
+
+ self.assertIsNotNone(result)
+ self.assertIn("output", result)
+
+ # -------------------------------------------------------------------------
+ # Tests — error cases
+ # -------------------------------------------------------------------------
+ def test_topic_unknown_command_raises(self):
+ """An unknown subcommand should raise ValueError."""
+ with self.assertRaises((ValueError, TypeError)):
+ self._run_topic(command="nonexistent")
+
+ with self.assertRaises(ValueError):
+ self._run_topic(command="nonexistent", topic_name="/rosout")
+
+ def test_topic_info_missing_topic_name_raises(self):
+ """`info` without a topic_name should raise ValueError."""
+ with self.assertRaises((ValueError, TypeError)):
+ self._run_topic(command="info")
+
+ def test_topic_type_missing_topic_name_raises(self):
+ """`type` without a topic_name should raise ValueError."""
+ with self.assertRaises((ValueError, TypeError)):
+ self._run_topic(command="type")
+
+ def test_topic_find_missing_msg_type_raises(self):
+ """`find` without a msg_type should raise an exception."""
+ with self.assertRaises(Exception):
+ self._run_topic(command="find")
+
+
+# ===========================================================================
+# Test class Ros2ServiceTool
+# ===========================================================================
+@unittest.skipUnless(
+ ROS2_AVAILABLE and ROS2_CLI_AVAILABLE,
+ "ROS 2 (rclpy + ros2 CLI) not available — skipping",
+)
+class TestRos2ServiceTool(unittest.TestCase):
+ """Direct tests for ``Ros2ServiceTool.run()``."""
+
+ # -------------------------------------------------------------------------
+ # Fixtures
+ # -------------------------------------------------------------------------
+ @classmethod
+ def setUpClass(cls):
+ default_tools_mod = importlib.import_module("vulcanai.tools.default_tools")
+ cls.Ros2ServiceTool = default_tools_mod.Ros2ServiceTool
+ cls.ROS2DefaultToolNode = default_tools_mod.ROS2DefaultToolNode
+
+ def setUp(self):
+ self.console = MockConsole()
+ self.node = self.ROS2DefaultToolNode()
+ self.get_parameters_service = f"/{self.node.get_name()}/get_parameters"
+ self._bg_processes = []
+
+ def tearDown(self):
+ for proc in self._bg_processes:
+ stop_background_process(proc)
+ self._bg_processes.clear()
+ self.node.destroy_node()
+ self.console.stop()
+
+ @classmethod
+ def tearDownClass(cls):
+ if rclpy.ok():
+ rclpy.shutdown()
+
+ # -------------------------------------------------------------------------
+ # Helpers
+ # -------------------------------------------------------------------------
+ def _run_service(self, **kwargs):
+ tool = self.Ros2ServiceTool()
+ tool.bb = {"console": self.console, "main_node": self.node}
+ return tool.run(**kwargs)
+
+ def _run_service_threaded(self, **kwargs):
+ result = {}
+ error = {}
+
+ def worker():
+ try:
+ result["value"] = self._run_service(**kwargs)
+ except Exception as e:
+ error["exc"] = e
+
+ t = threading.Thread(target=worker)
+ t.start()
+ t.join(timeout=30)
+ if "exc" in error:
+ raise error["exc"]
+ return result.get("value")
+
+ def _wait_for_service(self, service_name: str, timeout_sec: float = 5.0):
+ start_time = time.monotonic()
+ while (time.monotonic() - start_time) <= timeout_sec:
+ result = self._run_service(command="list")
+ if service_name in result["output"]:
+ return
+ time.sleep(0.2)
+ self.fail(f"Service '{service_name}' not visible within {timeout_sec} seconds")
+
+ def _start_add_two_ints_service(self):
+ try:
+ executables = subprocess.check_output(
+ ["ros2", "pkg", "executables", "examples_rclpy_minimal_service"],
+ text=True,
+ timeout=10,
+ )
+ except Exception:
+ self.skipTest("examples_rclpy_minimal_service package is not available")
+ return
+
+ if " service" not in executables:
+ self.skipTest("examples_rclpy_minimal_service/service executable is not available")
+
+ proc = start_background_ros2_executable("examples_rclpy_minimal_service", "service", wait_sec=2.0)
+ self._bg_processes.append(proc)
+ self._wait_for_service("/add_two_ints", timeout_sec=8.0)
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 service list
+ # -------------------------------------------------------------------------
+ def test_service_list(self):
+ """`list` should include parameter services exposed by the tool node."""
+ self._wait_for_service(self.get_parameters_service)
+ result = self._run_service(command="list")
+
+ self.assertIn("output", result)
+ self.assertIn(self.get_parameters_service, result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 service info
+ # -------------------------------------------------------------------------
+ def test_service_info(self):
+ """`info` should report type metadata for a known service."""
+ self._wait_for_service(self.get_parameters_service)
+ result = self._run_service(command="info", service_name=self.get_parameters_service)
+
+ self.assertIn("Type:", result["output"])
+ self.assertIn("rcl_interfaces/srv/GetParameters", result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 service type
+ # -------------------------------------------------------------------------
+ def test_service_type(self):
+ """`type` should return the service type for a known service."""
+ self._wait_for_service(self.get_parameters_service)
+ result = self._run_service(command="type", service_name=self.get_parameters_service)
+
+ self.assertEqual("rcl_interfaces/srv/GetParameters", result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 service find
+ # -------------------------------------------------------------------------
+ def test_service_find(self):
+ """`find` should list services by service type."""
+ self._wait_for_service(self.get_parameters_service)
+ result = self._run_service(command="find", service_type="rcl_interfaces/srv/GetParameters")
+
+ self.assertIn(self.get_parameters_service, result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — explicit type
+ # -------------------------------------------------------------------------
+ def test_service_call_with_explicit_type(self):
+ """`call` should work with an explicit service type."""
+ self._start_add_two_ints_service()
+ result = self._run_service(
+ command="call",
+ service_name="/add_two_ints",
+ service_type="example_interfaces/srv/AddTwoInts",
+ args="{a: 2, b: 3}",
+ )
+ self.assertIn("sum=5", result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — autodetects type
+ # -------------------------------------------------------------------------
+ def test_service_call_auto_detects_type(self):
+ """`call` should auto-detect service type when omitted."""
+ self._start_add_two_ints_service()
+ result = self._run_service(
+ command="call",
+ service_name="/add_two_ints",
+ args="{a: 4, b: 5}",
+ )
+ self.assertIn("sum=9", result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — streaming
+ # -------------------------------------------------------------------------
+ def test_service_echo(self):
+ """`echo` should run and produce output for service event topic status."""
+ self._start_add_two_ints_service()
+ result = self._run_service_threaded(
+ command="echo",
+ service_name="/add_two_ints",
+ max_duration=5.0,
+ max_lines=1,
+ )
+ if result is None:
+ self.skipTest("ros2 service echo did not yield output in time")
+ return
+ self.assertIn("output", result)
+ self.assertIsInstance(result["output"], str)
+ self.assertNotEqual("", result["output"].strip())
+
+ # -------------------------------------------------------------------------
+ # Tests — errors
+ # -------------------------------------------------------------------------
+ def test_service_call_missing_args_raises(self):
+ """`call` without args should raise ValueError."""
+ self._wait_for_service(self.get_parameters_service)
+ with self.assertRaises(ValueError):
+ self._run_service(
+ command="call",
+ service_name=self.get_parameters_service,
+ service_type="rcl_interfaces/srv/GetParameters",
+ )
+
+ def test_service_info_missing_service_name_raises(self):
+ """`info` without a service_name should raise."""
+ with self.assertRaises((ValueError, TypeError)):
+ self._run_service(command="info")
+
+ def test_service_unknown_command_raises(self):
+ """Unknown service subcommands should raise ValueError."""
+ self._wait_for_service(self.get_parameters_service)
+ with self.assertRaises(ValueError):
+ self._run_service(command="nonexistent", service_name=self.get_parameters_service)
+
+
+# ===========================================================================
+# Test class TestRos2ActionTool
+# ===========================================================================
+@unittest.skipUnless(
+ ROS2_AVAILABLE and ROS2_CLI_AVAILABLE,
+ "ROS 2 (rclpy + ros2 CLI) not available — skipping",
+)
+class TestRos2ActionTool(unittest.TestCase):
+ """Direct tests for ``Ros2ActionTool.run()``."""
+
+ # -------------------------------------------------------------------------
+ # Fixtures
+ # -------------------------------------------------------------------------
+ @classmethod
+ def setUpClass(cls):
+ default_tools_mod = importlib.import_module("vulcanai.tools.default_tools")
+ cls.Ros2ActionTool = default_tools_mod.Ros2ActionTool
+ cls.ROS2DefaultToolNode = default_tools_mod.ROS2DefaultToolNode
+
+ def setUp(self):
+ self.console = MockConsole()
+ self.node = self.ROS2DefaultToolNode()
+ self._bg_processes = []
+
+ def tearDown(self):
+ for proc in self._bg_processes:
+ stop_background_process(proc)
+ self._bg_processes.clear()
+ self.node.destroy_node()
+ self.console.stop()
+
+ @classmethod
+ def tearDownClass(cls):
+ if rclpy.ok():
+ rclpy.shutdown()
+
+ # -------------------------------------------------------------------------
+ # Helpers
+ # -------------------------------------------------------------------------
+ def _run_action(self, **kwargs):
+ tool = self.Ros2ActionTool()
+ tool.bb = {"console": self.console, "main_node": self.node}
+ return tool.run(**kwargs)
+
+ def _wait_for_action(self, action_name: str, timeout_sec: float = 8.0):
+ start_time = time.monotonic()
+ while (time.monotonic() - start_time) <= timeout_sec:
+ result = self._run_action(command="list")
+ if action_name in result["output"]:
+ return
+ time.sleep(0.2)
+ self.fail(f"Action '{action_name}' not visible within {timeout_sec} seconds")
+
+ def _start_fibonacci_action_server(self):
+ try:
+ executables = subprocess.check_output(
+ ["ros2", "pkg", "executables", "examples_rclpy_minimal_action_server"],
+ text=True,
+ timeout=10,
+ )
+ except Exception:
+ self.skipTest("examples_rclpy_minimal_action_server package is not available")
+ return
+
+ if " server" not in executables:
+ self.skipTest("examples_rclpy_minimal_action_server/server executable is not available")
+
+ proc = start_background_ros2_executable("examples_rclpy_minimal_action_server", "server", wait_sec=2.5)
+ self._bg_processes.append(proc)
+ self._wait_for_action("/fibonacci", timeout_sec=8.0)
+
+ # -------------------------------------------------------------------------
+ # Tests — Action name suggestion
+ # -------------------------------------------------------------------------
+ def test_action_name_suggestions(self):
+ """Misspelled names should be corrected through suggestion flow."""
+ self._start_fibonacci_action_server()
+ result = self._run_action(command="info", action_name="fibonacc")
+ self.assertIn("Action: /fibonacci", result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 action list
+ # -------------------------------------------------------------------------
+ def test_action_list(self):
+ """`list` should pass through ros2 action list output."""
+ self._start_fibonacci_action_server()
+ result = self._run_action(command="list")
+ self.assertIn("/fibonacci", result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 action info
+ # -------------------------------------------------------------------------
+ def test_action_info(self):
+ """`info` should invoke ros2 action info for the given name."""
+ self._start_fibonacci_action_server()
+ result = self._run_action(command="info", action_name="/fibonacci")
+ self.assertIn("Action: /fibonacci", result["output"])
+ self.assertIn("Action servers:", result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 action type
+ # -------------------------------------------------------------------------
+ def test_action_type(self):
+ """`type` should invoke ros2 action type for the given action name."""
+ self._start_fibonacci_action_server()
+ result = self._run_action(command="type", action_name="/fibonacci")
+ self.assertIn("example_interfaces/action/Fibonacci", result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — error cases
+ # -------------------------------------------------------------------------
+ def test_action_info_missing_action_name_raises(self):
+ """`info` without action_name should raise."""
+ with self.assertRaises((ValueError, TypeError)):
+ self._run_action(command="info")
+
+ def test_action_type_missing_action_name_raises(self):
+ """`type` without action_name should raise."""
+ with self.assertRaises((ValueError, TypeError)):
+ self._run_action(command="type")
+
+ def test_action_unknown_command_raises(self):
+ """Unknown action subcommands should raise ValueError."""
+ self._start_fibonacci_action_server()
+ with self.assertRaises(ValueError):
+ self._run_action(command="nonexistent", action_name="/fibonacci")
+
+
+@unittest.skipUnless(
+ ROS2_AVAILABLE and ROS2_CLI_AVAILABLE,
+ "ROS 2 (rclpy + ros2 CLI) not available — skipping",
+)
+class TestRos2ParamTool(unittest.TestCase):
+ """Direct tests for ``Ros2ParamTool.run()``."""
+
+ # -------------------------------------------------------------------------
+ # Fixtures
+ # -------------------------------------------------------------------------
+ @classmethod
+ def setUpClass(cls):
+ default_tools_mod = importlib.import_module("vulcanai.tools.default_tools")
+ cls.Ros2ParamTool = default_tools_mod.Ros2ParamTool
+ cls.ROS2DefaultToolNode = default_tools_mod.ROS2DefaultToolNode
+
+ def setUp(self):
+ self.console = MockConsole()
+ self.node = self.ROS2DefaultToolNode()
+ self.node_name = f"/{self.node.get_name()}"
+ self._bg_processes = []
+
+ def tearDown(self):
+ for proc in self._bg_processes:
+ stop_background_process(proc)
+ self._bg_processes.clear()
+ self.node.destroy_node()
+ self.console.stop()
+
+ @classmethod
+ def tearDownClass(cls):
+ if rclpy.ok():
+ rclpy.shutdown()
+
+ # -------------------------------------------------------------------------
+ # Helpers
+ # -------------------------------------------------------------------------
+ def _run_param(self, **kwargs):
+ tool = self.Ros2ParamTool()
+ tool.bb = {"console": self.console, "main_node": self.node}
+ return tool.run(**kwargs)
+
+ def _run_param_without_param_suggestion(self, **kwargs):
+ """Run param tool preserving explicit param_name even if it's not a topic."""
+ self.console.suggestion_index = -1
+ self.console.suggestion_index_changed.set()
+ return self._run_param(**kwargs)
+
+ def _wait_for_node(self, timeout_sec: float = 5.0):
+ start_time = time.monotonic()
+ while (time.monotonic() - start_time) <= timeout_sec:
+ try:
+ node_list = subprocess.check_output(["ros2", "node", "list"], text=True)
+ except Exception:
+ node_list = ""
+ if self.node_name in node_list:
+ return
+ time.sleep(0.2)
+ self.fail(f"Node '{self.node_name}' not visible within {timeout_sec} seconds")
+
+ def _start_parameter_blackboard(self):
+ try:
+ executables = subprocess.check_output(
+ ["ros2", "pkg", "executables", "demo_nodes_cpp"],
+ text=True,
+ timeout=10,
+ )
+ except Exception:
+ self.skipTest("demo_nodes_cpp package is not available")
+ return
+
+ if " parameter_blackboard" not in executables:
+ self.skipTest("demo_nodes_cpp/parameter_blackboard executable is not available")
+
+ proc = start_background_ros2_executable("demo_nodes_cpp", "parameter_blackboard", wait_sec=2.0)
+ self._bg_processes.append(proc)
+ start_time = time.monotonic()
+ while (time.monotonic() - start_time) <= 8.0:
+ try:
+ node_list = subprocess.check_output(["ros2", "node", "list"], text=True, timeout=5)
+ except Exception:
+ node_list = ""
+ if "/parameter_blackboard" in node_list:
+ return
+ time.sleep(0.2)
+ self.fail("Node '/parameter_blackboard' not visible within timeout")
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 param list
+ # -------------------------------------------------------------------------
+ def test_param_list(self):
+ """`list` should return a non-empty output string."""
+ result = self._run_param(command="list")
+ self.assertIn("output", result)
+ self.assertIsInstance(result["output"], str)
+ self.assertNotEqual("", result["output"].strip())
+
+ def test_param_list_with_node_name_returns_node_list(self):
+ """When node_name is provided, `list` returns node listing."""
+ self._wait_for_node()
+ result = self._run_param(command="list", node_name=self.node_name)
+ self.assertIn(self.node_name, result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 param get
+ # -------------------------------------------------------------------------
+ def test_param_get(self):
+ """`get` should return the value of a parameter."""
+ self._start_parameter_blackboard()
+ param_name = "vulcan_test_param_get"
+ self._run_param_without_param_suggestion(
+ command="set",
+ node_name="/parameter_blackboard",
+ param_name=param_name,
+ set_value="456",
+ )
+ result = self._run_param_without_param_suggestion(
+ command="get",
+ node_name="/parameter_blackboard",
+ param_name=param_name,
+ )
+ self.assertIn("456", result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 param describe
+ # -------------------------------------------------------------------------
+ def test_param_describe(self):
+ """`describe` should return metadata for a known parameter."""
+ self._start_parameter_blackboard()
+ result = self._run_param_without_param_suggestion(
+ command="describe",
+ node_name="/parameter_blackboard",
+ param_name="use_sim_time",
+ )
+ self.assertIn("Type:", result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 param set
+ # -------------------------------------------------------------------------
+ def test_param_set(self):
+ """`set` should update a parameter on parameter_blackboard."""
+ self._start_parameter_blackboard()
+ param_name = "vulcan_test_param_set"
+ result = self._run_param_without_param_suggestion(
+ command="set",
+ node_name="/parameter_blackboard",
+ param_name=param_name,
+ set_value="123",
+ )
+ self.assertIn("Set parameter", result["output"])
+ get_result = self._run_param_without_param_suggestion(
+ command="get",
+ node_name="/parameter_blackboard",
+ param_name=param_name,
+ )
+ self.assertIn("123", get_result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 param delete
+ # -------------------------------------------------------------------------
+ def test_param_delete(self):
+ """`delete` should remove a previously set parameter."""
+ self._start_parameter_blackboard()
+ param_name = "vulcan_test_param_delete"
+ self._run_param_without_param_suggestion(
+ command="set",
+ node_name="/parameter_blackboard",
+ param_name=param_name,
+ set_value="789",
+ )
+ delete_result = self._run_param_without_param_suggestion(
+ command="delete",
+ node_name="/parameter_blackboard",
+ param_name=param_name,
+ )
+ self.assertNotEqual("", delete_result["output"].strip())
+
+ with self.assertRaises(Exception):
+ self._run_param_without_param_suggestion(
+ command="get",
+ node_name="/parameter_blackboard",
+ param_name=param_name,
+ )
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 param dump [file_path]
+ # -------------------------------------------------------------------------
+ def test_param_dump(self):
+ """`dump` should produce YAML for a running parameter node."""
+ self._start_parameter_blackboard()
+ result = self._run_param(command="dump", node_name="/parameter_blackboard")
+ self.assertIn("/parameter_blackboard:", result["output"])
+ self.assertIn("ros__parameters", result["output"])
+
+ def test_param_dump_with_output_file(self):
+ """`dump` with file path should create the output YAML file."""
+ self._start_parameter_blackboard()
+ dump_path = "/tmp/vulcan_param_dump.yaml"
+ try:
+ if os.path.exists(dump_path):
+ os.remove(dump_path)
+
+ result = self._run_param(command="dump", node_name="/parameter_blackboard", file_path=dump_path)
+
+ self.assertTrue(os.path.exists(dump_path))
+ with open(dump_path, encoding="utf-8") as f:
+ dumped_yaml = f.read()
+ self.assertIn("/parameter_blackboard:", dumped_yaml)
+ self.assertIn("ros__parameters", dumped_yaml)
+ self.assertTrue(result["output"].strip() or dump_path in result["output"])
+ finally:
+ if os.path.exists(dump_path):
+ os.remove(dump_path)
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 param load
+ # -------------------------------------------------------------------------
+ def test_param_load(self):
+ """`load` should restore parameters from a YAML dump file."""
+ self._start_parameter_blackboard()
+ param_name = "vulcan_test_param_load"
+ load_path = "/tmp/vulcan_param_dump_and_load.yaml"
+ try:
+ self._run_param_without_param_suggestion(
+ command="set",
+ node_name="/parameter_blackboard",
+ param_name=param_name,
+ set_value="321",
+ )
+ # -- DUMP -----------------
+ dump_result = self._run_param(command="dump", node_name="/parameter_blackboard")
+ with open(load_path, "w", encoding="utf-8") as f:
+ f.write(dump_result["output"])
+
+ self._run_param_without_param_suggestion(
+ command="set",
+ node_name="/parameter_blackboard",
+ param_name=param_name,
+ set_value="999",
+ )
+
+ # -- LOAD -----------------
+ load_result = self._run_param(
+ command="load",
+ node_name="/parameter_blackboard",
+ file_path=load_path,
+ )
+ self.assertNotEqual("", load_result["output"].strip())
+
+ get_result = self._run_param_without_param_suggestion(
+ command="get",
+ node_name="/parameter_blackboard",
+ param_name=param_name,
+ )
+ self.assertIn("321", get_result["output"])
+ finally:
+ if os.path.exists(load_path):
+ os.remove(load_path)
+
+ # -------------------------------------------------------------------------
+ # Tests — Error
+ # -------------------------------------------------------------------------
+ def test_param_set_missing_set_value_raises(self):
+ """`set` requires set_value."""
+ self._wait_for_node()
+ with self.assertRaises(ValueError):
+ self._run_param(command="set", node_name=self.node_name, param_name="/rosout")
+
+ def test_param_load_missing_file_path_raises(self):
+ """`load` requires file_path."""
+ self._wait_for_node()
+ with self.assertRaises(ValueError):
+ self._run_param(command="load", node_name=self.node_name)
+
+ def test_param_unknown_command_raises(self):
+ """Unknown param subcommands should raise ValueError."""
+ self._wait_for_node()
+ with self.assertRaises(ValueError):
+ self._run_param(command="nonexistent", node_name=self.node_name, param_name="/rosout")
+
+
+@unittest.skipUnless(
+ ROS2_AVAILABLE and ROS2_CLI_AVAILABLE,
+ "ROS 2 (rclpy + ros2 CLI) not available — skipping",
+)
+class TestRos2PkgTool(unittest.TestCase):
+ """Direct tests for ``Ros2PkgTool.run()``."""
+
+ # -------------------------------------------------------------------------
+ # Fixtures
+ # -------------------------------------------------------------------------
+ @classmethod
+ def setUpClass(cls):
+ default_tools_mod = importlib.import_module("vulcanai.tools.default_tools")
+ cls.Ros2PkgTool = default_tools_mod.Ros2PkgTool
+
+ def setUp(self):
+ self.console = MockConsole()
+
+ def tearDown(self):
+ self.console.stop()
+
+ # -------------------------------------------------------------------------
+ # Helpers
+ # -------------------------------------------------------------------------
+ def _run_pkg(self, **kwargs):
+ tool = self.Ros2PkgTool()
+ tool.bb = {"console": self.console}
+ return tool.run(**kwargs)
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 pkg list
+ # -------------------------------------------------------------------------
+ def test_pkg_list(self):
+ """`list` should pass through ros2 pkg list output."""
+ result = self._run_pkg(command="list")
+ self.assertIn("rclpy", result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 pkg executables
+ # -------------------------------------------------------------------------
+ def test_pkg_executables(self):
+ """`executables` should pass through ros2 pkg executables output."""
+ result = self._run_pkg(command="executables")
+ self.assertIn("demo_nodes_cpp talker", result["output"])
+ self.assertIn("demo_nodes_cpp listener", result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — Error
+ # -------------------------------------------------------------------------
+ def test_pkg_unknown_command_raises(self):
+ """Unknown pkg subcommands should raise ValueError."""
+ with self.assertRaises(ValueError):
+ self._run_pkg(command="nonexistent")
+
+
+@unittest.skipUnless(
+ ROS2_AVAILABLE and ROS2_CLI_AVAILABLE,
+ "ROS 2 (rclpy + ros2 CLI) not available — skipping",
+)
+class TestRos2InterfaceTool(unittest.TestCase):
+ """Direct tests for ``Ros2InterfaceTool.run()``."""
+
+ # -------------------------------------------------------------------------
+ # Fixtures
+ # -------------------------------------------------------------------------
+ @classmethod
+ def setUpClass(cls):
+ default_tools_mod = importlib.import_module("vulcanai.tools.default_tools")
+ cls.Ros2InterfaceTool = default_tools_mod.Ros2InterfaceTool
+
+ def setUp(self):
+ self.console = MockConsole()
+
+ def tearDown(self):
+ self.console.stop()
+
+ # -------------------------------------------------------------------------
+ # Helpers
+ # -------------------------------------------------------------------------
+ def _run_interface(self, **kwargs):
+ tool = self.Ros2InterfaceTool()
+ tool.bb = {"console": self.console}
+ return tool.run(**kwargs)
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 interface list
+ # -------------------------------------------------------------------------
+ def test_interface_without_name_returns_interface_list(self):
+ """When interface_name is None, tool should return interface list."""
+ result = self._run_interface(command="list", interface_name=None)
+ self.assertIn("std_msgs/msg/String", result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 interface packages
+ # -------------------------------------------------------------------------
+ def test_interface_packages_command_returns_packages(self):
+ """`packages` branch should return package names."""
+ result = self._run_interface(command="packages", interface_name="std_msgs/msg/String")
+ self.assertIn("std_msgs", result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 interface package
+ # -------------------------------------------------------------------------
+ def test_interface_package_branch_calls_current_cli(self):
+ """`package` should list interfaces provided by a package."""
+ result = self._run_interface(command="package", interface_name="std_msgs")
+ self.assertIn("std_msgs/msg/String", result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — ros2 interface show
+ # -------------------------------------------------------------------------
+ def test_interface_show_branch_calls_current_cli(self):
+ """`show` should return the interface definition."""
+ result = self._run_interface(command="show", interface_name="std_msgs/msg/String")
+ self.assertIn("string data", result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — Error
+ # -------------------------------------------------------------------------
+ def test_interface_unknown_command_raises(self):
+ """Unknown interface subcommands should raise ValueError."""
+ with self.assertRaises(ValueError):
+ self._run_interface(command="nonexistent", interface_name="std_msgs/msg/String")
+
+
+@unittest.skipUnless(
+ ROS2_AVAILABLE and ROS2_CLI_AVAILABLE,
+ "ROS 2 (rclpy + ros2 CLI) not available — skipping",
+)
+class TestRos2PublishTool(unittest.TestCase):
+ """Direct tests for ``Ros2PublishTool.run()``."""
+
+ # -------------------------------------------------------------------------
+ # Fixtures
+ # -------------------------------------------------------------------------
+ @classmethod
+ def setUpClass(cls):
+ default_tools_mod = importlib.import_module("vulcanai.tools.default_tools")
+ cls.Ros2PublishTool = default_tools_mod.Ros2PublishTool
+ cls.ROS2DefaultToolNode = default_tools_mod.ROS2DefaultToolNode
+
+ def setUp(self):
+ self.console = MockConsole()
+ self.node = self.ROS2DefaultToolNode()
+ self._bg_publishers = []
+
+ def tearDown(self):
+ for proc in self._bg_publishers:
+ stop_background_publisher(proc)
+ self._bg_publishers.clear()
+ self.node.destroy_node()
+ self.console.stop()
+
+ @classmethod
+ def tearDownClass(cls):
+ if rclpy.ok():
+ rclpy.shutdown()
+
+ # -------------------------------------------------------------------------
+ # Helpers
+ # -------------------------------------------------------------------------
+ def _run_publish(self, node=None, **kwargs):
+ tool = self.Ros2PublishTool()
+ tool.bb = {"console": self.console, "main_node": node if node is not None else self.node}
+ return tool.run(**kwargs)
+
+ # -------------------------------------------------------------------------
+ # Tests — Publish max_lines
+ # -------------------------------------------------------------------------
+ def test_publish_with_max_lines(self):
+ """Publisher should stop after max_lines and report count/output.
+
+ Keep this focused on the max_lines stop condition rather than wall-clock
+ timing. Small publish periods can be flaky in CI when an iteration is
+ delayed long enough for max_duration to win before the second publish.
+ """
+ result = self._run_publish(
+ topic="/vulcan_publish_tool_test",
+ message_data="hello_publish",
+ max_lines=2,
+ period_sec=0.0,
+ max_duration=10.0,
+ )
+
+ self.assertEqual("True", result["published"])
+ self.assertEqual(2, result["count"])
+ self.assertEqual("/vulcan_publish_tool_test", result["topic"])
+ self.assertIn("Publishing: 'hello_publish'", result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — Publish max_duration
+ # -------------------------------------------------------------------------
+ def test_publish_with_max_duration(self):
+ """Publisher should stop due to max_duration and report progress/output."""
+ max_lines = 100
+ max_duration = 3.0
+ result = self._run_publish(
+ topic="/vulcan_publish_tool_test",
+ message_data="hello_publish",
+ max_lines=max_lines,
+ period_sec=1.0,
+ max_duration=max_duration,
+ )
+
+ self.assertEqual("True", result["published"])
+ self.assertGreaterEqual(result["count"], 1)
+ self.assertLess(result["count"], max_lines)
+ self.assertEqual("/vulcan_publish_tool_test", result["topic"])
+ self.assertIn("Publishing: 'hello_publish'", result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — Errors
+ # -------------------------------------------------------------------------
+
+ # -- No topic ---------------------
+ def test_publish_with_empty_topic_returns_unpublished(self):
+ """Empty topic should return default unpublished result."""
+ result = self._run_publish(topic="", max_lines=1)
+ self.assertEqual("False", result["published"])
+ self.assertEqual("0", result["count"])
+
+ # -- Max lines <= 0 ---------------------
+ def test_publish_with_non_positive_max_lines_returns_unpublished(self):
+ """max_lines <= 0 should stop immediately with default result."""
+ result = self._run_publish(topic="/vulcan_publish_tool_test", max_lines=0)
+ self.assertEqual("False", result["published"])
+ self.assertEqual("0", result["count"])
+
+ # -- Message invalid --------------
+ def test_publish_custom_message_invalid_json(self):
+ """Invalid JSON for custom message type should return default result."""
+ try:
+ result = self._run_publish(
+ topic="/dummy_topic",
+ msg_type="geometry_msgs/msg/Twist",
+ message_data="{invalid_json",
+ max_lines=1,
+ )
+ except ModuleNotFoundError:
+ self.skipTest("geometry_msgs package is not available")
+ return
+
+ self.assertEqual("False", result["published"])
+ self.assertEqual("0", result["count"])
+ self.assertEqual("/dummy_topic", result["topic"])
+
+ # -- No main node -----------------
+ def test_publish_missing_main_node_raises(self):
+ """Tool should raise when blackboard has no shared node."""
+ tool = self.Ros2PublishTool()
+ tool.bb = {"console": self.console}
+ with self.assertRaises(Exception):
+ tool.run(topic="/vulcan_publish_tool_test")
+
+
+@unittest.skipUnless(
+ ROS2_AVAILABLE and ROS2_CLI_AVAILABLE,
+ "ROS 2 (rclpy + ros2 CLI) not available — skipping",
+)
+class TestRos2SubscribeTool(unittest.TestCase):
+ """Direct tests for ``Ros2SubscribeTool.run()``."""
+
+ # -------------------------------------------------------------------------
+ # Fixtures
+ # -------------------------------------------------------------------------
+ @classmethod
+ def setUpClass(cls):
+ default_tools_mod = importlib.import_module("vulcanai.tools.default_tools")
+ cls.Ros2SubscribeTool = default_tools_mod.Ros2SubscribeTool
+ cls.ROS2DefaultToolNode = default_tools_mod.ROS2DefaultToolNode
+
+ def setUp(self):
+ self.console = MockConsole()
+ self.node = self.ROS2DefaultToolNode()
+ self._bg_publishers = []
+
+ def tearDown(self):
+ for proc in self._bg_publishers:
+ stop_background_publisher(proc)
+ self._bg_publishers.clear()
+ self.node.destroy_node()
+ self.console.stop()
+
+ @classmethod
+ def tearDownClass(cls):
+ if rclpy.ok():
+ rclpy.shutdown()
+
+ # -------------------------------------------------------------------------
+ # Helpers
+ # -------------------------------------------------------------------------
+ def _run_subscribe(self, **kwargs):
+ tool = self.Ros2SubscribeTool()
+ tool.bb = {"console": self.console, "main_node": self.node}
+ return tool.run(**kwargs)
+
+ def _run_subscribe_threaded(self, **kwargs):
+ result = {}
+ error = {}
+
+ def worker():
+ try:
+ result["value"] = self._run_subscribe(**kwargs)
+ except Exception as e:
+ error["exc"] = e
+
+ t = threading.Thread(target=worker)
+ t.start()
+ t.join(timeout=30)
+ if "exc" in error:
+ raise error["exc"]
+ return result.get("value")
+
+ # -------------------------------------------------------------------------
+ # Tests — Subscribe max_lines
+ # -------------------------------------------------------------------------
+ def test_subscribe_with_max_lines(self):
+ """`run` should receive live topic data and count lines."""
+ topic = "/vulcan_test_subscribe"
+ proc = start_background_publisher(topic, message="hello_subscribe", rate=10)
+ self._bg_publishers.append(proc)
+
+ result = self._run_subscribe_threaded(topic=topic, max_duration=5.0, max_lines=5)
+
+ self.assertEqual("True", result["subscribed"])
+ self.assertEqual(5, result["count"])
+ self.assertEqual(topic, result["topic"])
+ self.assertIn("I heard", result["output"])
+
+ # -------------------------------------------------------------------------
+ # Tests — Subscribe (no publisher)
+ # -------------------------------------------------------------------------
+ def test_subscribe_empty_output(self):
+ """Empty subprocess output still returns subscribed state."""
+ result = self._run_subscribe_threaded(topic="/vulcan_test_no_pub", max_duration=1.0, max_lines=1)
+ self.assertEqual("True", result["subscribed"])
+ self.assertGreaterEqual(result["count"], 0)
+ self.assertIsInstance(result["output"], str)
+
+ # -------------------------------------------------------------------------
+ # Tests — Error
+ # -------------------------------------------------------------------------
+ def test_subscribe_missing_main_node_raises(self):
+ """Tool should raise when blackboard has no shared node."""
+ tool = self.Ros2SubscribeTool()
+ tool.bb = {"console": self.console}
+ with self.assertRaises(Exception):
+ tool.run(topic="/my_topic")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unittest/test_default_tools_optional_args.py b/tests/unittest/test_default_tools_optional_args.py
new file mode 100644
index 0000000..723c59a
--- /dev/null
+++ b/tests/unittest/test_default_tools_optional_args.py
@@ -0,0 +1,565 @@
+# Copyright 2026 Proyectos y Sistemas de Mantenimiento SL (eProsima).
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import importlib
+import os
+import sys
+import types
+import unittest
+from unittest.mock import Mock, patch
+
+
+class _DummySentenceTransformer:
+ def __init__(self, *args, **kwargs):
+ pass
+
+ def encode(self, text, convert_to_numpy=True):
+ return None
+
+ def similarity(self, a, b):
+ return None
+
+
+sys.modules.setdefault("sentence_transformers", types.SimpleNamespace(SentenceTransformer=_DummySentenceTransformer))
+
+
+def _install_rclpy_stubs():
+ rclpy_mod = types.ModuleType("rclpy")
+ rclpy_mod.ok = lambda: True
+ rclpy_mod.init = lambda: None
+ rclpy_mod.spin_until_future_complete = lambda *args, **kwargs: None
+ rclpy_mod.spin_once = lambda *args, **kwargs: None
+
+ node_mod = types.ModuleType("rclpy.node")
+
+ class Node:
+ def __init__(self, *args, **kwargs):
+ pass
+
+ node_mod.Node = Node
+
+ task_mod = types.ModuleType("rclpy.task")
+
+ class Future:
+ def __init__(self):
+ self._cancelled = False
+
+ def cancelled(self):
+ return self._cancelled
+
+ def cancel(self):
+ self._cancelled = True
+
+ task_mod.Future = Future
+
+ sys.modules["rclpy"] = rclpy_mod
+ sys.modules["rclpy.node"] = node_mod
+ sys.modules["rclpy.task"] = task_mod
+
+
+CURRENT_DIR = os.path.dirname(__file__)
+SRC_DIR = os.path.abspath(os.path.join(CURRENT_DIR, os.path.pardir, os.path.pardir, "src"))
+if SRC_DIR not in sys.path:
+ sys.path.insert(0, SRC_DIR)
+
+_install_rclpy_stubs()
+
+
+class _MockLogger:
+ def log_tool(self, *args, **kwargs):
+ pass
+
+ def log_msg(self, *args, **kwargs):
+ pass
+
+ def log_console(self, *args, **kwargs):
+ pass
+
+
+class _MockConsole:
+ def __init__(self):
+ self.logger = _MockLogger()
+ self.stream_task = None
+
+ def set_stream_task(self, task):
+ self.stream_task = task
+
+ def call_from_thread(self, fn, *args, **kwargs):
+ fn(*args, **kwargs)
+
+ def show_subprocess_panel(self):
+ pass
+
+ def change_route_logs(self, value):
+ pass
+
+ def add_line(self, text):
+ pass
+
+ def add_subprocess_line(self, text):
+ pass
+
+
+class _FakeNodeLogger:
+ def warn(self, *args, **kwargs):
+ pass
+
+
+class _FakePublisher:
+ def __init__(self):
+ self.messages = []
+
+ def publish(self, msg):
+ self.messages.append(msg)
+
+
+class _FakeNode:
+ def __init__(self):
+ self.publishers = []
+ self.destroyed_publishers = []
+ self.logger = _FakeNodeLogger()
+
+ def create_publisher(self, msg_type, topic_name, qos_depth):
+ publisher = _FakePublisher()
+ publisher.msg_type = msg_type
+ publisher.topic_name = topic_name
+ publisher.qos_depth = qos_depth
+ self.publishers.append(publisher)
+ return publisher
+
+ def destroy_publisher(self, publisher):
+ self.destroyed_publishers.append(publisher)
+
+ def get_logger(self):
+ return self.logger
+
+
+class _AliveSpinThread:
+ def is_alive(self):
+ return True
+
+
+class _BackgroundSpinningNode(_FakeNode):
+ def __init__(self):
+ super().__init__()
+ self._vulcanai_spin_thread = _AliveSpinThread()
+
+ def spin_once(self, timeout_sec=0.1):
+ raise AssertionError("publish loop should not manually spin a node with a background spinner")
+
+
+class _NeverCancelledFuture:
+ def cancelled(self):
+ return False
+
+
+class _StringMessage:
+ def __init__(self):
+ self.data = ""
+
+
+class _CustomMessage:
+ def __init__(self):
+ self.value = None
+
+
+class _DefaultToolsOptionalArgsBase(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.default_tools = importlib.import_module("vulcanai.tools.default_tools")
+
+ def setUp(self):
+ self.console = _MockConsole()
+ self.node = _FakeNode()
+
+ def _make_tool(self, tool_cls_name: str):
+ tool_cls = getattr(self.default_tools, tool_cls_name)
+ tool = tool_cls()
+ tool.bb = {"console": self.console, "main_node": self.node}
+ return tool
+
+
+# region TOPIC
+
+
+class TestStreamingToolOptionalArgs(_DefaultToolsOptionalArgsBase):
+ def _assert_topic_default(
+ self,
+ tool_cls_name: str,
+ command: str,
+ omitted_arg: str,
+ explicit_arg: str,
+ explicit_value,
+ expected_default,
+ ):
+ tool = self._make_tool(tool_cls_name)
+
+ with patch.object(self.default_tools, "_run_ros2_topic_command", return_value={"output": "ok"}) as runner:
+ result = tool.run(topic_name="/demo_topic", **{explicit_arg: explicit_value})
+
+ self.assertEqual(result, {"output": "ok"})
+ self.assertEqual(runner.call_args.args[:3], (self.console, tool.name, command))
+ self.assertEqual(runner.call_args.kwargs["topic_name"], "/demo_topic")
+ self.assertEqual(runner.call_args.kwargs[explicit_arg], explicit_value)
+ self.assertEqual(runner.call_args.kwargs[omitted_arg], expected_default)
+
+ def _assert_service_default(
+ self,
+ omitted_arg: str,
+ explicit_arg: str,
+ explicit_value,
+ expected_default,
+ ):
+ tool = self._make_tool("Ros2ServiceEchoTool")
+
+ with patch.object(self.default_tools, "_run_ros2_service_command", return_value={"output": "ok"}) as runner:
+ result = tool.run(service_name="/demo_service", **{explicit_arg: explicit_value})
+
+ self.assertEqual(result, {"output": "ok"})
+ self.assertEqual(runner.call_args.args[:3], (self.console, tool.name, "echo"))
+ self.assertEqual(runner.call_args.kwargs["service_name"], "/demo_service")
+ self.assertEqual(runner.call_args.kwargs[explicit_arg], explicit_value)
+ self.assertEqual(runner.call_args.kwargs[omitted_arg], expected_default)
+
+ def test_topic_bw_defaults_max_duration_when_omitted(self):
+ self._assert_topic_default("Ros2TopicBwTool", "bw", "max_duration", "max_lines", 7, 60)
+
+ def test_topic_bw_defaults_max_lines_when_omitted(self):
+ self._assert_topic_default("Ros2TopicBwTool", "bw", "max_lines", "max_duration", 3.5, 100)
+
+ def test_topic_delay_defaults_max_duration_when_omitted(self):
+ self._assert_topic_default("Ros2TopicDelayTool", "delay", "max_duration", "max_lines", 8, 60)
+
+ def test_topic_delay_defaults_max_lines_when_omitted(self):
+ self._assert_topic_default("Ros2TopicDelayTool", "delay", "max_lines", "max_duration", 4.5, 100)
+
+ def test_topic_hz_defaults_max_duration_when_omitted(self):
+ self._assert_topic_default("Ros2TopicHzTool", "hz", "max_duration", "max_lines", 9, 60)
+
+ def test_topic_hz_defaults_max_lines_when_omitted(self):
+ self._assert_topic_default("Ros2TopicHzTool", "hz", "max_lines", "max_duration", 5.5, 100)
+
+ def test_service_echo_defaults_max_duration_when_omitted(self):
+ self._assert_service_default("max_duration", "max_lines", 6, 60)
+
+ def test_service_echo_defaults_max_lines_when_omitted(self):
+ self._assert_service_default("max_lines", "max_duration", 2.5, 100)
+
+
+# endregion
+
+# region PARAM
+
+
+class TestParamListOptionalArgs(_DefaultToolsOptionalArgsBase):
+ def test_param_list_passes_none_when_node_name_is_omitted(self):
+ tool = self._make_tool("Ros2ParamListTool")
+
+ with patch.object(self.default_tools, "_run_ros2_param_command", return_value={"output": "ok"}) as runner:
+ result = tool.run()
+
+ self.assertEqual(result, {"output": "ok"})
+ self.assertEqual(runner.call_args.args[:3], (self.console, tool.name, "list"))
+ self.assertIsNone(runner.call_args.kwargs["node_name"])
+
+
+# endregion
+
+# region PUBLISH
+
+
+class TestPublishOptionalArgs(_DefaultToolsOptionalArgsBase):
+ def _run_publish(
+ self, tool, monotonic_values, message_cls=_StringMessage, future_cls=_NeverCancelledFuture, **kwargs
+ ):
+ imported_types = []
+ sleep_mock = Mock()
+ active_node = tool.bb["main_node"]
+
+ def fake_import_msg_type(type_str, node):
+ imported_types.append(type_str)
+ return message_cls
+
+ with (
+ patch.object(self.default_tools, "import_msg_type", side_effect=fake_import_msg_type),
+ patch.object(self.default_tools, "log_tool_in_stream_and_main"),
+ patch.object(self.default_tools, "print_tool_output"),
+ patch.object(self.default_tools, "_get_node_spin_lock", return_value=None),
+ patch.object(self.default_tools.rclpy, "spin_once", create=True),
+ patch.object(self.default_tools, "Future", future_cls),
+ patch.object(self.default_tools.time, "monotonic", side_effect=monotonic_values),
+ patch.object(self.default_tools.time, "sleep", sleep_mock),
+ ):
+ result = tool.run(**kwargs)
+
+ publisher = active_node.publishers[-1] if active_node.publishers else None
+ return result, publisher, imported_types, sleep_mock
+
+ def test_publish_defaults_msg_type_when_omitted(self):
+ tool = self._make_tool("Ros2PublishTool")
+
+ result, _, imported_types, _ = self._run_publish(
+ tool,
+ monotonic_values=[0.0, 0.0],
+ topic="/demo_publish",
+ message_data="hello",
+ max_lines=1,
+ max_duration=10.0,
+ period_sec=0.0,
+ )
+
+ self.assertEqual(result["published"], "True")
+ self.assertEqual(imported_types, ["std_msgs/msg/String"])
+
+ def test_publish_defaults_message_data_when_omitted(self):
+ tool = self._make_tool("Ros2PublishTool")
+
+ result, publisher, _, _ = self._run_publish(
+ tool,
+ monotonic_values=[0.0, 0.0],
+ topic="/demo_publish",
+ max_lines=1,
+ max_duration=10.0,
+ period_sec=0.0,
+ )
+
+ self.assertEqual(result["published"], "True")
+ self.assertEqual(len(publisher.messages), 1)
+ self.assertEqual(publisher.messages[0].data, "Hello from VulcanAI PublishTool!")
+
+ def test_publish_defaults_max_lines_when_omitted(self):
+ tool = self._make_tool("Ros2PublishTool")
+ tool.input_defaults = {**tool.input_defaults, "max_lines": 2}
+
+ result, publisher, _, _ = self._run_publish(
+ tool,
+ monotonic_values=[0.0, 0.0, 0.1],
+ topic="/demo_publish",
+ message_data="hello",
+ max_duration=10.0,
+ period_sec=0.0,
+ )
+
+ self.assertEqual(result["count"], 2)
+ self.assertEqual(len(publisher.messages), 2)
+
+ def test_publish_defaults_max_duration_when_omitted(self):
+ tool = self._make_tool("Ros2PublishTool")
+ tool.input_defaults = {**tool.input_defaults, "max_duration": 0.001}
+
+ result, publisher, _, _ = self._run_publish(
+ tool,
+ monotonic_values=[0.0, 0.0, 0.1],
+ topic="/demo_publish",
+ message_data="hello",
+ max_lines=50,
+ period_sec=0.0,
+ )
+
+ self.assertEqual(result["count"], 1)
+ self.assertEqual(len(publisher.messages), 1)
+
+ def test_publish_defaults_period_sec_when_omitted(self):
+ tool = self._make_tool("Ros2PublishTool")
+ tool.input_defaults = {**tool.input_defaults, "period_sec": 0.25}
+
+ result, publisher, _, sleep_mock = self._run_publish(
+ tool,
+ monotonic_values=[0.0, 0.0, 0.0, 0.0],
+ topic="/demo_publish",
+ message_data="hello",
+ max_lines=1,
+ max_duration=10.0,
+ )
+
+ self.assertEqual(result["count"], 1)
+ self.assertEqual(len(publisher.messages), 1)
+ sleep_mock.assert_not_called()
+
+ def test_publish_invalid_max_duration_falls_back_to_default(self):
+ tool = self._make_tool("Ros2PublishTool")
+ tool.input_defaults = {**tool.input_defaults, "max_duration": 0.001}
+
+ result, publisher, _, _ = self._run_publish(
+ tool,
+ monotonic_values=[0.0, 0.0, 0.1],
+ topic="/demo_publish",
+ message_data="hello",
+ max_lines=50,
+ max_duration="not_a_number",
+ period_sec=0.0,
+ )
+
+ self.assertEqual(result["count"], 1)
+ self.assertEqual(len(publisher.messages), 1)
+
+ def test_publish_invalid_max_lines_falls_back_to_default(self):
+ tool = self._make_tool("Ros2PublishTool")
+ tool.input_defaults = {**tool.input_defaults, "max_lines": 2}
+
+ result, publisher, _, _ = self._run_publish(
+ tool,
+ monotonic_values=[0.0, 0.0, 0.1],
+ topic="/demo_publish",
+ message_data="hello",
+ max_lines="not_an_int",
+ max_duration=10.0,
+ period_sec=0.0,
+ )
+
+ self.assertEqual(result["count"], 2)
+ self.assertEqual(len(publisher.messages), 2)
+
+ def test_publish_negative_period_sec_falls_back_to_default(self):
+ tool = self._make_tool("Ros2PublishTool")
+ tool.input_defaults = {**tool.input_defaults, "period_sec": 0.25}
+
+ result, publisher, _, sleep_mock = self._run_publish(
+ tool,
+ monotonic_values=[0.0] * 10,
+ topic="/demo_publish",
+ message_data="hello",
+ max_lines=2,
+ max_duration=10.0,
+ period_sec=-1.0,
+ )
+
+ self.assertEqual(result["count"], 2)
+ self.assertEqual(len(publisher.messages), 2)
+ sleep_mock.assert_called()
+
+ def test_publish_skips_manual_spin_when_node_already_spins_in_background(self):
+ tool = self._make_tool("Ros2PublishTool")
+ tool.bb = {"console": self.console, "main_node": _BackgroundSpinningNode()}
+
+ result, publisher, _, _ = self._run_publish(
+ tool,
+ monotonic_values=[0.0, 0.0, 0.1],
+ topic="/demo_publish",
+ message_data="hello",
+ max_lines=2,
+ max_duration=10.0,
+ period_sec=0.0,
+ )
+
+ self.assertEqual(result["count"], 2)
+ self.assertEqual(len(publisher.messages), 2)
+
+ def test_publish_valid_custom_message_json_populates_message(self):
+ tool = self._make_tool("Ros2PublishTool")
+
+ result, publisher, imported_types, _ = self._run_publish(
+ tool,
+ monotonic_values=[0.0, 0.0],
+ message_cls=_CustomMessage,
+ topic="/demo_publish",
+ msg_type="demo_msgs/msg/Custom",
+ message_data='{"value": 42}',
+ max_lines=1,
+ max_duration=10.0,
+ period_sec=0.0,
+ )
+
+ self.assertEqual(result["published"], "True")
+ self.assertEqual(imported_types, ["demo_msgs/msg/Custom"])
+ self.assertEqual(len(publisher.messages), 1)
+ self.assertEqual(publisher.messages[0].value, 42)
+
+
+# endregion
+
+# region SUBSCRIBE
+
+
+class TestSubscribeOptionalArgs(_DefaultToolsOptionalArgsBase):
+ def test_subscribe_defaults_max_duration_when_omitted(self):
+ tool = self._make_tool("Ros2SubscribeTool")
+
+ with (
+ patch.object(self.default_tools, "execute_subprocess", return_value="hello\nworld") as execute,
+ patch.object(self.default_tools, "print_tool_output"),
+ ):
+ result = tool.run(topic="/demo_topic", max_lines=2)
+
+ self.assertEqual(result["subscribed"], "True")
+ self.assertEqual(
+ execute.call_args.args[:3],
+ (self.console, tool.name, ["ros2", "topic", "echo", "/demo_topic", "--field", "data", "--no-arr"]),
+ )
+ self.assertEqual(execute.call_args.args[3], 60)
+ self.assertEqual(execute.call_args.args[4], 2)
+
+ def test_subscribe_defaults_max_lines_when_omitted(self):
+ tool = self._make_tool("Ros2SubscribeTool")
+
+ with (
+ patch.object(self.default_tools, "execute_subprocess", return_value="hello") as execute,
+ patch.object(self.default_tools, "print_tool_output"),
+ ):
+ result = tool.run(topic="/demo_topic", max_duration=2.5)
+
+ self.assertEqual(result["subscribed"], "True")
+ self.assertEqual(
+ execute.call_args.args[:3],
+ (self.console, tool.name, ["ros2", "topic", "echo", "/demo_topic", "--field", "data", "--no-arr"]),
+ )
+ self.assertEqual(execute.call_args.args[3], 2.5)
+ self.assertEqual(execute.call_args.args[4], 100)
+
+ def test_subscribe_invalid_limits_fall_back_to_defaults(self):
+ tool = self._make_tool("Ros2SubscribeTool")
+
+ with (
+ patch.object(self.default_tools, "execute_subprocess", return_value="hello") as execute,
+ patch.object(self.default_tools, "print_tool_output"),
+ ):
+ result = tool.run(topic="/demo_topic", max_duration="invalid", max_lines="invalid")
+
+ self.assertEqual(result["subscribed"], "True")
+ self.assertEqual(execute.call_args.args[3], 60)
+ self.assertEqual(execute.call_args.args[4], 100)
+
+ def test_subscribe_truncates_output_to_max_lines(self):
+ tool = self._make_tool("Ros2SubscribeTool")
+
+ with (
+ patch.object(self.default_tools, "execute_subprocess", return_value="one\ntwo\nthree") as execute,
+ patch.object(self.default_tools, "print_tool_output"),
+ ):
+ result = tool.run(topic="/demo_topic", max_duration=2.0, max_lines=2)
+
+ self.assertEqual(result["subscribed"], "True")
+ self.assertEqual(result["count"], 2)
+ self.assertEqual(result["output"], "one\ntwo")
+ self.assertEqual(execute.call_args.args[4], 2)
+
+ def test_subscribe_none_output_keeps_default_unsubscribed_result(self):
+ tool = self._make_tool("Ros2SubscribeTool")
+
+ with (
+ patch.object(self.default_tools, "execute_subprocess", return_value=None),
+ patch.object(self.default_tools, "print_tool_output"),
+ ):
+ result = tool.run(topic="/demo_topic", max_duration=1.0, max_lines=1)
+
+ self.assertEqual(result["subscribed"], "False")
+ self.assertEqual(result["count"], "0")
+ self.assertEqual(result["topic"], "")
+ self.assertEqual(result["output"], "")
+
+
+# endregion
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/unittest/test_executor.py b/tests/unittest/test_executor.py
index 54b2de3..8fc52ad 100644
--- a/tests/unittest/test_executor.py
+++ b/tests/unittest/test_executor.py
@@ -79,7 +79,7 @@ def similarity(self, a: np.ndarray, b: np.ndarray) -> float:
self.Embedder = LocalDummyEmbedder()
# Build registry and executor
- self.registry = self.ToolRegistry(embedder=self.Embedder)
+ self.registry = self.ToolRegistry(embedder=self.Embedder, default_tools=False)
# Define and register tools
class NavTool(self.AtomicTool):
@@ -187,7 +187,21 @@ class AddTool(self.AtomicTool):
def run(self, a: float, b: float):
return {"result": a + b}
+ class SubscribeLikeTool(self.AtomicTool):
+ name = "subscribe_like"
+ description = "Records subscription-like optional arguments."
+ input_schema = [("topic", "string"), ("max_duration", "float?"), ("max_lines", "int?")]
+ output_schema = {"topic": "string", "max_duration": "float", "max_lines": "int"}
+
+ def run(self, **kwargs):
+ return {
+ "topic": kwargs.get("topic"),
+ "max_duration": kwargs.get("max_duration"),
+ "max_lines": kwargs.get("max_lines"),
+ }
+
self.registry.register_tool(AddTool())
+ self.registry.register_tool(SubscribeLikeTool())
self.registry.register_tool(NavTool())
self.registry.register_tool(DetectTool())
self.registry.register_tool(SpeakTool())
@@ -238,6 +252,35 @@ def test_simple_plan_executes(self):
self.assertTrue(bb["go_to_pose"]["arrived"])
self.assertEqual(bb["detect_object"]["pose"], {"x": 4.0, "y": 2.0, "z": 0.0})
+ def test_optional_float_argument_preserves_fractional_value(self):
+ """Optional float arguments should keep fractional values through executor binding."""
+ plan = self.GlobalPlan(
+ summary="Preserve a fractional optional duration",
+ plan=[
+ self.PlanNode(
+ kind="SEQUENCE",
+ steps=[
+ self.Step(
+ tool="subscribe_like",
+ args=[
+ self.Arg(key="topic", val="/demo_topic"),
+ self.Arg(key="max_duration", val=0.5),
+ self.Arg(key="max_lines", val=2),
+ ],
+ ),
+ ],
+ )
+ ],
+ )
+
+ result = self.exec.run(plan, {})
+ self.assertTrue(result["success"])
+ bb = result["blackboard"]
+ self.assertEqual(bb["subscribe_like"]["topic"], "/demo_topic")
+ self.assertEqual(bb["subscribe_like"]["max_duration"], 0.5)
+ self.assertIsInstance(bb["subscribe_like"]["max_duration"], float)
+ self.assertEqual(bb["subscribe_like"]["max_lines"], 2)
+
def test_false_condition_skips_step(self):
"""
Test that a step with a False condition is skipped and a non-existing blackboard entry
@@ -459,7 +502,7 @@ def test_step_with_retries(self):
],
)
- r = self.ToolRegistry(embedder=self.Embedder)
+ r = self.ToolRegistry(embedder=self.Embedder, default_tools=False)
flaky_tool = self.FlakyTool()
r.register_tool(flaky_tool)
flaky_exec = self.PlanExecutor(r)
@@ -497,7 +540,7 @@ def test_step_with_criteria(self):
],
)
- r = self.ToolRegistry(embedder=self.Embedder)
+ r = self.ToolRegistry(embedder=self.Embedder, default_tools=False)
# First test with a value that does not meet criteria
r.register_tool(self.CriteriaTool(return_value=5))
criteria_exec = self.PlanExecutor(r)
@@ -509,7 +552,7 @@ def test_step_with_criteria(self):
self.assertEqual(bb["criteria_tool"]["value"], 5)
# Now test with a value that meets criteria
- r = self.ToolRegistry(embedder=self.Embedder)
+ r = self.ToolRegistry(embedder=self.Embedder, default_tools=False)
r.register_tool(self.CriteriaTool(return_value=15))
criteria_exec = self.PlanExecutor(r)
@@ -593,7 +636,7 @@ def test_plan_node_with_criteria(self):
],
)
- r = self.ToolRegistry(embedder=self.Embedder)
+ r = self.ToolRegistry(embedder=self.Embedder, default_tools=False)
# First test with a value that does not meet criteria
r.register_tool(self.CriteriaTool(return_value=5))
criteria_exec = self.PlanExecutor(r)
@@ -605,7 +648,7 @@ def test_plan_node_with_criteria(self):
self.assertEqual(bb["criteria_tool"]["value"], 5)
# Now test with a value that meets criteria
- r = self.ToolRegistry(embedder=self.Embedder)
+ r = self.ToolRegistry(embedder=self.Embedder, default_tools=False)
r.register_tool(self.CriteriaTool(return_value=15))
criteria_exec = self.PlanExecutor(r)
@@ -629,7 +672,7 @@ def test_plan_node_with_retries(self):
],
)
- r = self.ToolRegistry(embedder=self.Embedder)
+ r = self.ToolRegistry(embedder=self.Embedder, default_tools=False)
flaky_tool = self.FlakyTool()
r.register_tool(flaky_tool)
flaky_exec = self.PlanExecutor(r)
diff --git a/tests/unittest/test_tool_registry.py b/tests/unittest/test_tool_registry.py
index 9dc09ca..648ca59 100644
--- a/tests/unittest/test_tool_registry.py
+++ b/tests/unittest/test_tool_registry.py
@@ -108,6 +108,30 @@ class SpeakTool(self.AtomicTool):
def run(self, **kwargs):
return {"spoken": True}
+ class DemoGroupListTool(self.AtomicTool):
+ name = "demo_group_list"
+ group_name = "demo_group"
+ description = "List entries from a grouped tool family"
+ tags = ["demo", "grouped"]
+ input_schema = []
+ output_schema = {"ok": "bool"}
+ version = "0.1"
+
+ def run(self, **kwargs):
+ return {"ok": True}
+
+ class DemoGroupInfoTool(self.AtomicTool):
+ name = "demo_group_info"
+ group_name = "demo_group"
+ description = "Show info from a grouped tool family"
+ tags = ["demo", "grouped"]
+ input_schema = []
+ output_schema = {"ok": "bool"}
+ version = "0.1"
+
+ def run(self, **kwargs):
+ return {"ok": True}
+
class ComplexTool(self.CompositeTool):
name = "complex_action"
description = "A complex action using multiple tools"
@@ -135,11 +159,13 @@ def run(self, **kwargs):
self.NavTool = NavTool
self.DetectTool = DetectTool
self.SpeakTool = SpeakTool
+ self.DemoGroupListTool = DemoGroupListTool
+ self.DemoGroupInfoTool = DemoGroupInfoTool
self.ComplexTool = ComplexTool
self.TestValidationTool = TestValidationTool
# Build registry with dummy embedder and register tools
- self.registry = self.ToolRegistry(embedder=self.Embedder())
+ self.registry = self.ToolRegistry(embedder=self.Embedder(), default_tools=False)
self.registry.register_tool(self.NavTool())
self.registry.register_tool(self.DetectTool())
self.registry.register_tool(self.SpeakTool())
@@ -171,7 +197,7 @@ def test_top_k_returns_registered_instances_validation(self):
def test_empty_registry(self):
"""Test that top_k on empty registry returns empty list."""
- r = self.ToolRegistry(embedder=self.Embedder())
+ r = self.ToolRegistry(embedder=self.Embedder(), default_tools=False)
res = r.top_k("anything", k=3)
# When no tools are registered, only the help tool is present.
# However, top_k will return an empty list because a tool registry with only the help tool
@@ -192,7 +218,7 @@ def embed(self, text: str) -> np.ndarray:
self.embed_call_count += 1
return super().embed(text)
- r = self.ToolRegistry(embedder=MockEmbedder())
+ r = self.ToolRegistry(embedder=MockEmbedder(), default_tools=False)
r.register_tool(self.NavTool())
r.register_tool(self.DetectTool())
r.register_tool(self.SpeakTool())
@@ -208,7 +234,7 @@ def embed(self, text: str) -> np.ndarray:
# Test registers
def test_register_tool(self):
"""Test that tools can be registered and are present in the registry."""
- r = self.ToolRegistry(embedder=self.Embedder())
+ r = self.ToolRegistry(embedder=self.Embedder(), default_tools=False)
self.assertEqual(len(r.tools), 0 + 1) # +1 for help tool
r.register_tool(self.NavTool())
self.assertEqual(len(r.tools), 1 + 1) # +1 for help tool
@@ -231,7 +257,7 @@ def test_register_tool_from_file(self):
self.assertEqual(len(self.registry._index), 7) # (3 existing + 4 new)
def test_register_tool_from_nonexistent_file(self):
- r = self.ToolRegistry(embedder=self.Embedder())
+ r = self.ToolRegistry(embedder=self.Embedder(), default_tools=False)
r.discover_tools_from_file("/path/does/not/exist.py")
self.assertEqual(len(r.tools), 0 + 1) # +1 for help tool
@@ -252,7 +278,7 @@ def test_register_composite_tool_fails_reports_error(self):
buf = io.StringIO()
sys.stdout = buf
# Check that the composite tool has no resolved deps before registration
- r = self.ToolRegistry(embedder=self.Embedder())
+ r = self.ToolRegistry(embedder=self.Embedder(), default_tools=False)
self.assertEqual(len(r.tools), 0 + 1) # +1 for help tool
self.assertEqual(len(self.ComplexTool().dependencies), 3)
self.assertEqual(len(self.ComplexTool().resolved_deps), 0)
@@ -271,7 +297,7 @@ def test_register_composite_tool_fails_reports_error(self):
def test_register_composite_tool_solves_deps_from_file(self):
"""Test that registering a composite tool from file correctly resolves its dependencies."""
- r = self.ToolRegistry(embedder=self.Embedder())
+ r = self.ToolRegistry(embedder=self.Embedder(), default_tools=False)
buf = io.StringIO()
sys.stdout = buf
r.discover_tools_from_file(os.path.join(RESOURCES_DIR, "test_tools.py"))
@@ -289,7 +315,7 @@ def test_register_composite_tool_fails_from_file(self):
buf = io.StringIO()
sys.stdout = buf
# Register tool from test_composite_tool.py and expect errors for missing deps
- r = self.ToolRegistry(embedder=self.Embedder())
+ r = self.ToolRegistry(embedder=self.Embedder(), default_tools=False)
r.discover_tools_from_file(os.path.join(RESOURCES_DIR, "test_composite_tool.py"))
sys.stdout = sys.__stdout__ # Reset stdout
output = buf.getvalue()
@@ -301,7 +327,7 @@ def test_register_composite_tool_fails_from_file(self):
def test_register_validation_tool(self):
"""Test that validation tools can be registered and are present in the registry."""
- r = self.ToolRegistry(embedder=self.Embedder())
+ r = self.ToolRegistry(embedder=self.Embedder(), default_tools=False)
self.assertEqual(len(r.tools), 0 + 1) # +1 for help tool
self.assertEqual(len(r._index), 0)
r.register_tool(self.TestValidationTool())
@@ -309,6 +335,34 @@ def test_register_validation_tool(self):
self.assertEqual(len(r._index), 1)
self.assertIn("test_validation", r.validation_tools)
+ def test_group_tool_names_keeps_deactivated_grouped_tools_together(self):
+ """Test grouped tools still render as a group after all of them are deactivated."""
+ r = self.ToolRegistry(embedder=self.Embedder(), default_tools=False)
+ r.register_tool(self.DemoGroupListTool())
+ r.register_tool(self.DemoGroupInfoTool())
+
+ self.assertTrue(r.deactivate_tool("demo_group_list"))
+ self.assertTrue(r.deactivate_tool("demo_group_info"))
+
+ grouped = r.group_tool_names(["demo_group_info", "demo_group_list"])
+
+ self.assertEqual(grouped, [("demo_group", ["info", "list"])])
+
+ def test_group_tool_names_keeps_deactivated_groups_when_built_from_registry_state(self):
+ """Test grouped tools remain grouped when names come from active + deactivated registries."""
+ r = self.ToolRegistry(embedder=self.Embedder(), default_tools=False)
+ r.register_tool(self.DemoGroupListTool())
+ r.register_tool(self.DemoGroupInfoTool())
+
+ self.assertTrue(r.deactivate_tool("demo_group_list"))
+ self.assertTrue(r.deactivate_tool("demo_group_info"))
+
+ all_names = sorted(n for n in list(r.tools.keys()) + list(r.deactivated_tools.keys()) if n != "help")
+
+ grouped = r.group_tool_names(all_names)
+
+ self.assertEqual(grouped, [("demo_group", ["info", "list"])])
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/unittest/test_validator.py b/tests/unittest/test_validator.py
index 611ac4f..a08687b 100644
--- a/tests/unittest/test_validator.py
+++ b/tests/unittest/test_validator.py
@@ -20,6 +20,7 @@
import unittest
import numpy as np
+from pydantic import ValidationError
# Stub sentence_transformers to avoid heavy dependency during tests
@@ -78,7 +79,7 @@ def similarity(self, a: np.ndarray, b: np.ndarray) -> float:
self.Embedder = LocalDummyEmbedder()
# Build registry and executor
- self.registry = self.ToolRegistry(embedder=self.Embedder)
+ self.registry = self.ToolRegistry(embedder=self.Embedder, default_tools=False)
# Define and register tools
class EmptyTool(self.AtomicTool):
@@ -141,11 +142,21 @@ class TypesTool(self.AtomicTool):
def run(self, **kwargs):
return {"types": True}
+ class OptionalTool(self.AtomicTool):
+ name = "optional"
+ description = "Tool with one required argument and one optional argument"
+ input_schema = [("required", "string"), ("optional_value", "string?")]
+ output_schema = {"ok": "bool"}
+
+ def run(self, **kwargs):
+ return {"ok": True}
+
self.registry.register_tool(EmptyTool())
self.registry.register_tool(DetectTool())
self.registry.register_tool(NavTool())
self.registry.register_tool(SpeakTool())
self.registry.register_tool(TypesTool())
+ self.registry.register_tool(OptionalTool())
self.validator = self.Validator(self.registry)
@@ -195,19 +206,69 @@ def test_validator_non_existing_tool(self):
self.assertIn("not found in registry", str(e))
self.assertTrue(fail, "Validator did not catch non-existing key error")
+ def test_global_plan_requires_at_least_one_plan_node(self):
+ with self.assertRaises(ValidationError) as ctx:
+ self.GlobalPlan(summary="Empty plan", plan=[])
+
+ self.assertIn("plan", str(ctx.exception))
+ self.assertIn("at least 1 item", str(ctx.exception))
+
+ def test_plan_node_requires_at_least_one_step(self):
+ with self.assertRaises(ValidationError) as ctx:
+ self.PlanNode(kind="SEQUENCE", steps=[])
+
+ self.assertIn("steps", str(ctx.exception))
+ self.assertIn("at least 1 item", str(ctx.exception))
+
+ def test_step_requires_tool_name(self):
+ with self.assertRaises(ValidationError) as ctx:
+ self.Step(args=[])
+
+ self.assertIn("tool", str(ctx.exception))
+ self.assertIn("Field required", str(ctx.exception))
+
+ def test_validator_rejects_non_global_plan(self):
+ with self.assertRaises(ValueError) as ctx:
+ self.validator.validate(object())
+
+ self.assertIn("not a GlobalPlan instance", str(ctx.exception))
+
+ def test_validate_step_rejects_non_step(self):
+ with self.assertRaises(ValueError) as ctx:
+ self.validator._validate_step(object())
+
+ self.assertIn("not a Step instance", str(ctx.exception))
+
+ def test_validator_accepts_required_arg_with_optional_arg_omitted(self):
+ plan = self.GlobalPlan(
+ summary="Use tool with omitted optional arg",
+ plan=[
+ self.PlanNode(
+ kind="SEQUENCE",
+ steps=[
+ self.Step(tool="optional", args=[self.Arg(key="required", val="hello")]),
+ ],
+ )
+ ],
+ )
+
+ try:
+ self.validator.validate(plan)
+ except Exception as e:
+ self.fail(f"Validation failed with omitted optional arg: {e}")
+
def test_validator_non_existing_key(self):
plan = self.GlobalPlan(
- summary="Navigate to a location",
+ summary="Optional tool with an unknown key",
plan=[
self.PlanNode(
kind="SEQUENCE",
steps=[
self.Step(
- tool="go_to_pose",
+ tool="optional",
args=[
- self.Arg(key="non_existing_key", val=1.0),
- self.Arg(key="y", val=2.0),
- self.Arg(key="z", val=0.0),
+ self.Arg(key="required", val="hello"),
+ self.Arg(key="non_existing_key", val="unexpected"),
],
),
],
@@ -362,6 +423,32 @@ def test_validator_wrong_all_brackets_missing(self):
self.assertIn("Blackboard reference in argument", str(e))
self.assertTrue(fail, "Validator did not catch non-existing key error")
+ def test_validator_accepts_multiple_well_formed_bb_refs_in_one_string(self):
+ plan = self.GlobalPlan(
+ summary="Speak using multiple blackboard references",
+ plan=[
+ self.PlanNode(
+ kind="SEQUENCE",
+ steps=[
+ self.Step(
+ tool="speak",
+ args=[
+ self.Arg(
+ key="text",
+ val="Detected at {{bb.detect_object.pose.x}}, {{bb.detect_object.pose.y}}",
+ )
+ ],
+ ),
+ ],
+ )
+ ],
+ )
+
+ try:
+ self.validator.validate(plan)
+ except Exception as e:
+ self.fail(f"Validation failed for well-formed multiple bb refs: {e}")
+
def test_validator_correct_types(self):
plan = self.GlobalPlan(
summary="Check types of arguments",