From de2d4a7c12fe40af5a986e4d8cb8d2c6168f20b3 Mon Sep 17 00:00:00 2001 From: danipiza Date: Wed, 3 Dec 2025 16:24:32 +0100 Subject: [PATCH 01/24] [#23896] Initial commit - ros2 cli commands Signed-off-by: danipiza --- src/vulcanai/console/utils.py | 10 +- src/vulcanai/tools/default_tools.py | 726 ++++++++++++++++++++++++++++ 2 files changed, 733 insertions(+), 3 deletions(-) create mode 100644 src/vulcanai/tools/default_tools.py diff --git a/src/vulcanai/console/utils.py b/src/vulcanai/console/utils.py index a6e72b7..ddf47c5 100644 --- a/src/vulcanai/console/utils.py +++ b/src/vulcanai/console/utils.py @@ -22,7 +22,11 @@ import time from textual.markup import escape # To remove potential errors in textual terminal - +# sipnner +from textual.timer import Timer +import rclpy +import os +from typing import List, Optional class StreamToTextual: """ @@ -62,7 +66,6 @@ def on_request_start(self, text="Querying LLM..."): def on_request_end(self): self.spinner_status.stop() - def attach_ros_logger_to_console(console): """ Redirect ALL rclpy RcutilsLogger output (nodes + executor + rclpy internals) @@ -149,10 +152,10 @@ def common_prefix(strings: str) -> str: 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 @@ -222,6 +225,7 @@ 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() diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py new file mode 100644 index 0000000..cd66bd5 --- /dev/null +++ b/src/vulcanai/tools/default_tools.py @@ -0,0 +1,726 @@ +# Copyright 2025 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 time + +import rclpy + +from vulcanai import AtomicTool, CompositeTool, vulcanai_tool + +import subprocess + +import asyncio +import os +from typing import List, Optional + +import threading + +from vulcanai.console.utils import execute_subprocess +from vulcanai.console.utils import run_oneshot_cmd + +""" + +- 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 + + +??? + | + V + +Commands: + + ros2 bag Various rosbag related sub-commands + ros2 component Various component related sub-commands + ros2 daemon Various daemon related sub-commands + ros2 doctor Check ROS setup and other potential issues + ros2 interface Show information about ROS interfaces + ros2 launch Run a launch file + ros2 lifecycle Various lifecycle related sub-commands + ros2 multicast Various multicast related sub-commands + ros2 pkg Various package related sub-commands + ros2 run Run a package specific executable + ros2 security Various security related sub-commands + ros2 wtf Use `wtf` as alias to `doctor` +""" + +import os +import time +import subprocess +from typing import List, Optional + + +@vulcanai_tool +class Ros2NodeTool(AtomicTool): + name = "ros2_node" + description = "List ROS2 nodes and optionally get detailed info for a specific node." + tags = ["ros2", "nodes", "cli", "info", "diagnostics"] + + input_schema = [ + ("node_name", "string?") # (optional) Name of the ros2 node. + # if the node is not provided the command is `ros2 node list`. + # otherwise `ros2 node info ` + ] + + output_schema = { + "ros2": "bool", # ros2 flag for pretty printing. + "output": "string", # list of ros2 nodes or info of a node. + } + + def run(self, **kwargs): + # Get the shared ROS2 node from the blackboard + node = self.bb.get("main_node", None) + if node is None: + raise Exception("Could not find shared node, aborting...") + + # Get the node name if provided by the query + node_name = kwargs.get("node_name", None) + + result = { + "ros2": True, + "output": None + } + + # -- Run `ros2 node list` --------------------------------------------- + if node_name == None: + try: + + # is one-shot, the of the subprocess is automatic + # (when it prints the nodes). + list_output = subprocess.check_output( + ["ros2", "node", "list"], + stderr=subprocess.STDOUT, + text=True + ) + + # add the output of the command to the dictionary + result["output"] = [line.strip() for line in list_output.splitlines()] + except subprocess.CalledProcessError as e: + raise Exception(f"Failed to run 'ros2 node list': {e.output}") + + # -- Run `ros2 node info ` -------------------------------------- + else: + + try: + # COMMAND: ros2 node info + # is one-shot, the of the subprocess is automatic + # (when it prints the infomation of the node). + info_output = subprocess.check_output( + ["ros2", "node", "info", node_name], + stderr=subprocess.STDOUT, + text=True + ) + + # add the ouptut of the comand to the dictionary + result["output"] = info_output + except subprocess.CalledProcessError as e: + raise Exception(f"Failed to get info for node '{node_name}': {e.output}") + + return result +@vulcanai_tool +class Ros2TopicTool(AtomicTool): + name = "ros2_topic" + description = ( + "Wrapper for `ros2 topic` CLI. Always returns `ros2 topic list` " + "and can optionally run any subcommand: bw, delay, echo, find, " + "hz, info, list, pub, type" + ) + tags = ["ros2", "topics", "cli", "info"] + + # - `command` lets you pick a single subcommand (echo/bw/hz/delay/find/pub/type). + input_schema = [ + ("command", "string"), # Command: "list", "info", "type", "find", + # "pub", "hz", "echo", "bw", "delay" + ("topic_name", "string?"), # (optional) Topic name. (info/echo/bw/delay/hz/type/pub) + ("msg_type", "string?"), # (optional) Message type (`find` , `pub` ) + # + ("max_duration", "number?"), # (optional) Seconds for streaming commands (echo/bw/hz/delay) + ("max_lines", "int?"), # (optional) Cap number of lines for streaming commands + ] + + output_schema = { + "ros2": "bool", # ros2 flag for pretty printing + "output": "string", # output + } + + + def run(self, **kwargs): + # Get the shared ROS2 node from the blackboard + node = self.bb.get("main_node", None) + if node is None: + raise Exception("Could not find shared node, aborting...") + + console = self.bb.get("console", None) + if console is None: + raise Exception("Could not find console, aborting...") + + command = kwargs.get("command", None) # optional explicit subcommand + topic_name = kwargs.get("topic_name", None) + msg_type = kwargs.get("msg_type", None) + # streaming commands variables + max_duration = kwargs.get("max_duration", 60.0) + max_lines = kwargs.get("max_lines", 1000) + + result = { + "ros2": True, + "output": None, + } + + command = command.lower() + + # -- ros2 topic list -------------------------------------------------- + if command == "list": + list_output = run_oneshot_cmd(["ros2", "topic", "list"]) + result["output"] = [line.strip() for line in list_output.splitlines() \ + if line.strip()] + + # -- ros2 topic info ------------------------------------- + elif command == "info": + if not topic_name: + raise ValueError("`command='info'` requires `topic_name`.") + + info_output = run_oneshot_cmd( + ["ros2", "topic", "info", topic_name] + ) + result["output"] = info_output + + # -- ros2 topic find ------------------------------------------- + elif command == "find": + # `` + if not msg_type: + raise ValueError("`command='find'` requires `msg_type` (ROS type).") + 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"] = find_topics + + # -- ros2 topic type ------------------------------------- + elif command == "type": + if not topic_name: + raise ValueError("`command='type'` requires `topic_name`.") + + type_output = run_oneshot_cmd( + ["ros2", "topic", "type", topic_name] + ) + result["output"] = type_output + + # -- ros2 topic echo ------------------------------------- + elif command == "echo": + if not topic_name: + raise ValueError("`command='echo'` requires `topic_name`.") + + base_args = ["ros2", "topic", "echo", topic_name] + execute_subprocess(console, base_args, max_duration, max_lines) + + result["output"] = True + + # -- ros2 topic bw --------------------------------------- + elif command == "bw": + + base_args = ["ros2", "topic", "bw", topic_name] + execute_subprocess(console, base_args, max_duration, max_lines) + + result["output"] = True + + # delay -------------------------------------------------------------- + elif command == "delay": + if not topic_name: + raise ValueError("`command='delay'` requires `topic_name`.") + + + base_args = ["ros2", "topic", "delay", topic_name] + execute_subprocess(console, base_args, max_duration, max_lines) + + result["output"] = True + + # -- ros2 topic hz --------------------------------------- + elif command == "hz": + if not topic_name: + raise ValueError("`command='hz'` requires `topic_name`.") + + base_args = ["ros2", "topic", "hz", topic_name] + execute_subprocess(console, base_args, max_duration, max_lines) + + result["output"] = True + + # -- publisher -------------------------------------------------------- + elif command == "pub": + # One-shot publish using `-1` + # ros2 topic pub -1 "" + # ros2 topic pub -1 /rosout2 std_msgs/msg/String "{data: 'Hello'}" + if not topic_name: + raise ValueError("`command='pub'` requires `topic_name`.") + if not msg_type: + raise ValueError("`command='pub'` requires `msg_type`.") + + """# only send 1 + pub_output = run_oneshot_cmd( + ["ros2", "topic", "pub", "-1", topic_name, msg_type] + ) + result["output"] = pub_output""" + + # TODO. expand publisher options? + + base_args = ["ros2", "topic", "pub", topic_name, msg_type] + execute_subprocess(console, base_args, max_duration, max_lines) + + result["output"] = True + + else: + raise ValueError( + f"Unknown command '{command}'. " + "Expected one of: list, info, echo, bw, delay, hz, find, pub, type." + ) + + return result + +@vulcanai_tool +class Ros2ServiceTool(AtomicTool): + name = "ros2_service" + description = ( + "Wrapper for `ros2 service` CLI. Always returns `ros2 service list`, " + "and can optionally run any subcommand: list, info, type, call, echo, find." + ) + tags = ["ros2", "services", "cli", "info", "call"] + + # - `command` = "list", "info", "type", "call", "echo", "find" + input_schema = [ + ("command", "string"), # Command: "list", "info", "type", "find" + # "call", "echo" + ("service_name", "string?"), # (optional) Service name. "info", "type", "call", "echo" + ("service_type", "string?"), # (optional) Service type. "find", "call" + ("call", "bool?"), # (optional) backwards-compatible call flag + ("args", "string?"), # (optional) YAML/JSON-like request data for `call` + ("max_duration", "number?"), # (optional) Maximum duration + ("max_lines", "int?"), # (optional) Maximum lines + ] + + output_schema = { + "ros2": "bool", # ros2 flag for pretty printing + "output": "string", # `ros2 service list` + } + + + def run(self, **kwargs): + # Get the shared ROS2 node from the blackboard + node = self.bb.get("main_node", None) + if node is None: + raise Exception("Could not find shared node, aborting...") + + console = self.bb.get("console", None) + if console is None: + raise Exception("Could not find console, aborting...") + + command = kwargs.get("command", None) + service_name = kwargs.get("service_name", None) + service_type = kwargs.get("service_type", None) + do_call = kwargs.get("call", False) # legacy flag + call_args = kwargs.get("args", None) + # streaming commands variables + max_duration = kwargs.get("max_duration", 2.0) # default for echo + max_lines = kwargs.get("max_lines", 50) + + result = { + "ros2": True, + "output": None, + } + + command = command.lower() + + # -- ros2 service list ------------------------------------------------ + if command == "list": + list_output = run_oneshot_cmd(["ros2", "service", "list"]) + result["output"] = list_output + + # -- ros2 service info --------------------------------- + elif command == "info": + if not service_name: + raise ValueError("`command='info'` requires `service_name`.") + + info_output = run_oneshot_cmd( + ["ros2", "service", "info", service_name] + ) + + result["output"] = info_output + + # -- ros2 service type --------------------------------- + elif command == "type": + if not service_name: + raise ValueError("`command='type'` requires `service_name`.") + + type_output = run_oneshot_cmd( + ["ros2", "service", "type", service_name] + ) + + result["output"] = type_output.strip() + + # -- ros2 service find ----------------------------------------- + elif command == "find": + if not service_type: + raise ValueError("`command='find'` requires `service_type`.") + + find_output = run_oneshot_cmd( + ["ros2", "service", "find", service_type] + ) + + result["output"] = find_output + + # -- ros2 service call service_name service_type ---------------------- + elif command == "call": + if not service_name: + raise ValueError("`command='call'` requires `service_name`.") + if call_args is None: + raise ValueError("`command='call'` requires `args`.") + + # If service_type not given, detect it + if not service_type: + type_output = run_oneshot_cmd( + ["ros2", "service", "type", service_name] + ) + service_type = type_output.strip() + + call_output = run_oneshot_cmd( + ["ros2", "service", "call", service_name, service_type, call_args] + ) + + result["output"] = call_output + + # -- ros2 service echo service_name ----------------------------------- + elif command == "echo": + if not service_name: + raise ValueError("`command='echo'` requires `service_name`.") + + base_args = ["ros2", "service", "echo", service_name] + execute_subprocess(console, base_args, max_duration, max_lines) + + result["output"] = echo_output + + # -- unknown ------------------------------------------------------------ + else: + + raise ValueError( + f"Unknown command '{command}'. " + "Expected one of: list, info, type, call, echo, find." + ) + + return result + +@vulcanai_tool +class Ros2ActionTool(AtomicTool): + name = "ros2_action" + description = ( + "Wrapper for `ros2 action` CLI. Always returns `ros2 action list`, " + "and can optionally run: list, info, type, send_goal." + ) + tags = ["ros2", "actions", "cli", "info", "goal"] + + # - `command`: "list", "info", "type", "send_goal" + input_schema = [ + ("command", "string"), # Command: "list" "info" "type" "send_goal" + ("action_name", "string?"), # (optional) Action name + ("action_type", "string?"), # (optional) Action type. "find" + ("send_goal", "bool?"), # (optional) legacy flag (backwards compatible) + ("args", "string?"), # (optional) goal YAML, e.g. '{order: 5}' + ("wait_for_result", "bool?"), # (optional) if true => add `--result` + ] + + output_schema = { + "ros2": "bool", # ros2 flag for pretty printing + "output": "string", # `ros2 action list` + } + + + + def run(self, **kwargs): + # Get the shared ROS2 node from the blackboard + node = self.bb.get("main_node", None) + if node is None: + raise Exception("Could not find shared node, aborting...") + + command = kwargs.get("command", None) + action_name = kwargs.get("action_name", None) + do_send_goal = kwargs.get("send_goal", False) # legacy flag + goal_args = kwargs.get("args", None) + wait_for_result = kwargs.get("wait_for_result", True) + action_type = kwargs.get("action_type", None) + + result = { + "ros2": True, + "output": None, + } + + command = command.lower() + + # -- ros2 action list ------------------------------------------------- + if command == "list": + list_output = run_oneshot_cmd(["ros2", "action", "list"]) + result["output"] = list_output + + # -- ros2 action info ----------------------------------- + elif command == "info": + if not action_name: + raise ValueError("`command='info'` requires `action_name`.") + + info_output = run_oneshot_cmd( + ["ros2", "action", "info", action_name] + ) + result["output"] = info_output + + # -- ros2 action type ------------------------------------ + elif command == "get": + if not node or not param: + raise ValueError("`command='get'` requires `node_name` and `param_name`.") + + get_output = run_oneshot_cmd( + ["ros2", "param", "get", node, param] + ) + + result["output"] = get_output + + # -- ros2 param describe ------------------------------- + elif command == "describe": + if not node or not param: + raise ValueError("`command='describe'` requires `node_name` and `param_name`.") + + describe_output = run_oneshot_cmd( + ["ros2", "param", "describe", node, param] + ) + + result["output"] = describe_output + + # -- ros2 param set ------------------------ + elif command == "set": + if not node or not param: + raise ValueError("`command='set'` requires `node_name` and `param_name`.") + if set_value is None: + raise ValueError("`command='set'` requires `set_value`.") + + set_output = run_oneshot_cmd( + ["ros2", "param", "set", node, param, set_value] + ) + + result["output"] = set_output + + # -- ros2 param delete ---------------------------------- + elif command == "delete": + if not node or not param: + raise ValueError("`command='delete'` requires `node_name` and `param_name`.") + + delete_output = run_oneshot_cmd( + ["ros2", "param", "delete", node, param] + ) + + result["output"] = delete_output + + # -- ros2 param dump [file_path] ------------------------------- + elif command == "dump": + if not node: + raise ValueError("`command='dump'` requires `node_name`.") + + # Two modes: + # - If file_path given, write to file with --output-file + # - Otherwise, capture YAML from stdout + if file_path: + dump_output = run_oneshot_cmd( + ["ros2", "param", "dump", node, "--output-file", file_path] + ) + + # CLI usually prints a line like "Saved parameters to file..." + # so we just expose that. + result["output"] = dump_output or f"Dumped parameters to {file_path}" + else: + dump_output = run_oneshot_cmd( + ["ros2", "param", "dump", node] + ) + + result["output"] = dump_output + + # -- ros2 param load ------------------------------- + elif command == "load": + if not node or not file_path: + raise ValueError("`command='load'` requires `node_name` and `file_path`.") + + load_output = run_oneshot_cmd( + ["ros2", "param", "load", node, file_path] + ) + + result["output"] = load_output + + # -- unknown ---------------------------------------------------------- + else: + raise ValueError( + f"Unknown command '{command}'. " + "Expected one of: list, get, describe, set, delete, dump, load." + ) + + return result + + + From c8f214798fb59c3603092823775b113b1b03bb61 Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 4 Dec 2025 09:07:30 +0100 Subject: [PATCH 02/24] [#23896] Updated code to use rich terminal with default tools Signed-off-by: danipiza --- src/vulcanai/console/console.py | 32 ++++++++++++++++++++++++++++++-- src/vulcanai/console/utils.py | 6 +----- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index b333a5a..d7f8a86 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -34,6 +34,10 @@ from vulcanai.console.widget_custom_log_text_area import CustomLogTextArea from vulcanai.console.widget_spinner import SpinnerStatus +from vulcanai.console.logger import VulcanAILogger + +import asyncio + class TextualLogSink: """A default console that prints to standard output.""" @@ -160,6 +164,27 @@ def __init__( self.tab_matches = [] # Current index in the tab matches self.tab_index = 0 + current_path = os.path.dirname(os.path.abspath(__file__)) + self.manager.register_tools_from_file(f"{current_path}/../tools/default_tools.py") + + self.manager.bb["console"] = self + self.logger = VulcanAILogger.log_manager + self.stream_task = None + + #self.loop = asyncio.get_running_loop() + + 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 + task by using the signal "Ctrl + C" + """ + + self.stream_task = input_stream + + def run(self): + self.print("VulcanAI Interactive Console") + self.print("Type 'exit' to quit.\n") # Terminal qol self.history = [] @@ -345,8 +370,11 @@ def worker(user_input: str = "") -> None: self.logger.log_console(f"Output of plan: {bb_ret}") except KeyboardInterrupt: - self.logger.log_msg("Exiting...") - return + if self.stream_task == 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 diff --git a/src/vulcanai/console/utils.py b/src/vulcanai/console/utils.py index ddf47c5..54f1e02 100644 --- a/src/vulcanai/console/utils.py +++ b/src/vulcanai/console/utils.py @@ -181,8 +181,7 @@ async def run_streaming_cmd_async( if echo: line_processed = escape(line) console.add_line(line_processed) - - # Count the line + # 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) @@ -208,8 +207,6 @@ async def run_streaming_cmd_async( 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) @@ -225,7 +222,6 @@ 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() From 50668bc65cf47a5df01b723dd40286ba3c48b7ce Mon Sep 17 00:00:00 2001 From: danipiza Date: Fri, 5 Dec 2025 13:44:29 +0100 Subject: [PATCH 03/24] [#23896] Applied revision Signed-off-by: danipiza --- src/vulcanai/console/console.py | 10 +- src/vulcanai/console/utils.py | 3 +- src/vulcanai/tools/default_tools.py | 277 ++++++++++++++++++++-------- 3 files changed, 211 insertions(+), 79 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index d7f8a86..93592fb 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -34,9 +34,14 @@ from vulcanai.console.widget_custom_log_text_area import CustomLogTextArea from vulcanai.console.widget_spinner import SpinnerStatus -from vulcanai.console.logger import VulcanAILogger -import asyncio +from prompt_toolkit import PromptSession +from rich.progress import Progress, SpinnerColumn, TextColumn +from vulcanai.models.model import IModelHooks +from vulcanai.console.logger import console, VulcanAILogger + + + class TextualLogSink: @@ -171,7 +176,6 @@ def __init__( self.logger = VulcanAILogger.log_manager self.stream_task = None - #self.loop = asyncio.get_running_loop() def set_stream_task(self, input_stream): """ diff --git a/src/vulcanai/console/utils.py b/src/vulcanai/console/utils.py index 54f1e02..15d6701 100644 --- a/src/vulcanai/console/utils.py +++ b/src/vulcanai/console/utils.py @@ -27,6 +27,7 @@ import rclpy import os from typing import List, Optional +import threading class StreamToTextual: """ @@ -125,7 +126,6 @@ def patched_log(self, msg, level, *args, **kwargs): RcutilsLogger.log = patched_log RcutilsLogger._textual_patched = True - def common_prefix(strings: str) -> str: if not strings: return "" @@ -155,7 +155,6 @@ def common_prefix(strings: str) -> str: 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 diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index cd66bd5..7aaa46f 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -18,25 +18,11 @@ It contains atomic tools used to call ROS2 CLI. """ -import time - -import rclpy - -from vulcanai import AtomicTool, CompositeTool, vulcanai_tool - import subprocess - -import asyncio -import os -from typing import List, Optional - -import threading - -from vulcanai.console.utils import execute_subprocess -from vulcanai.console.utils import run_oneshot_cmd +from vulcanai import AtomicTool, CompositeTool, vulcanai_tool +from vulcanai.console.utils import execute_subprocess, run_oneshot_cmd """ - - ros2 node Commands: info Output information about a node @@ -104,11 +90,6 @@ ros2 wtf Use `wtf` as alias to `doctor` """ -import os -import time -import subprocess -from typing import List, Optional - @vulcanai_tool class Ros2NodeTool(AtomicTool): @@ -143,40 +124,23 @@ def run(self, **kwargs): # -- Run `ros2 node list` --------------------------------------------- if node_name == None: - try: - - # is one-shot, the of the subprocess is automatic - # (when it prints the nodes). - list_output = subprocess.check_output( - ["ros2", "node", "list"], - stderr=subprocess.STDOUT, - text=True - ) - - # add the output of the command to the dictionary - result["output"] = [line.strip() for line in list_output.splitlines()] - except subprocess.CalledProcessError as e: - raise Exception(f"Failed to run 'ros2 node list': {e.output}") + node_name_list = run_oneshot_cmd(["ros2", "node", "list"]) + node_name_list = node_name_list.splitlines() + result["output"] = node_name_list # -- Run `ros2 node info ` -------------------------------------- else: + if not node_name: + raise ValueError("`command='info'` requires `node_name`.") - try: - # COMMAND: ros2 node info - # is one-shot, the of the subprocess is automatic - # (when it prints the infomation of the node). - info_output = subprocess.check_output( - ["ros2", "node", "info", node_name], - stderr=subprocess.STDOUT, - text=True - ) - - # add the ouptut of the comand to the dictionary - result["output"] = info_output - except subprocess.CalledProcessError as e: - raise Exception(f"Failed to get info for node '{node_name}': {e.output}") + info_output = run_oneshot_cmd( + ["ros2", "topic", "info", node_name] + ) + result["output"] = info_output return result + + @vulcanai_tool class Ros2TopicTool(AtomicTool): name = "ros2_topic" @@ -203,7 +167,6 @@ class Ros2TopicTool(AtomicTool): "output": "string", # output } - def run(self, **kwargs): # Get the shared ROS2 node from the blackboard node = self.bb.get("main_node", None) @@ -217,7 +180,7 @@ def run(self, **kwargs): command = kwargs.get("command", None) # optional explicit subcommand topic_name = kwargs.get("topic_name", None) msg_type = kwargs.get("msg_type", None) - # streaming commands variables + # Streaming commands variables max_duration = kwargs.get("max_duration", 60.0) max_lines = kwargs.get("max_lines", 1000) @@ -228,17 +191,28 @@ def run(self, **kwargs): command = command.lower() + topic_name_list = run_oneshot_cmd(["ros2", "topic", "list"]) + topic_name_list = topic_name_list.splitlines() + # -- ros2 topic list -------------------------------------------------- if command == "list": - list_output = run_oneshot_cmd(["ros2", "topic", "list"]) - result["output"] = [line.strip() for line in list_output.splitlines() \ - if line.strip()] + result["output"] = topic_name_list # -- ros2 topic info ------------------------------------- elif command == "info": if not topic_name: raise ValueError("`command='info'` requires `topic_name`.") + """topics = topic_name_list.splitlines() + + # TODO. Will be updated in the TUI Migration PR. + # The PR adds a modalscreen to select the most similar string), + # this applies to all ros cli commands. Though, not implemented + # in the rest commands from this PR + if topic_name not in topics: + topic_similar = search_similar(topics, topic_name) + topic_name = topic_similar""" + info_output = run_oneshot_cmd( ["ros2", "topic", "info", topic_name] ) @@ -246,7 +220,6 @@ def run(self, **kwargs): # -- ros2 topic find ------------------------------------------- elif command == "find": - # `` if not msg_type: raise ValueError("`command='find'` requires `msg_type` (ROS type).") find_output = run_oneshot_cmd( @@ -279,7 +252,6 @@ def run(self, **kwargs): # -- ros2 topic bw --------------------------------------- elif command == "bw": - base_args = ["ros2", "topic", "bw", topic_name] execute_subprocess(console, base_args, max_duration, max_lines) @@ -290,7 +262,6 @@ def run(self, **kwargs): if not topic_name: raise ValueError("`command='delay'` requires `topic_name`.") - base_args = ["ros2", "topic", "delay", topic_name] execute_subprocess(console, base_args, max_duration, max_lines) @@ -329,6 +300,7 @@ def run(self, **kwargs): result["output"] = True + # -- unknown ---------------------------------------------------------- else: raise ValueError( f"Unknown command '{command}'. " @@ -337,6 +309,7 @@ def run(self, **kwargs): return result + @vulcanai_tool class Ros2ServiceTool(AtomicTool): name = "ros2_service" @@ -363,7 +336,6 @@ class Ros2ServiceTool(AtomicTool): "output": "string", # `ros2 service list` } - def run(self, **kwargs): # Get the shared ROS2 node from the blackboard node = self.bb.get("main_node", None) @@ -377,9 +349,8 @@ def run(self, **kwargs): command = kwargs.get("command", None) service_name = kwargs.get("service_name", None) service_type = kwargs.get("service_type", None) - do_call = kwargs.get("call", False) # legacy flag call_args = kwargs.get("args", None) - # streaming commands variables + # Streaming commands variables max_duration = kwargs.get("max_duration", 2.0) # default for echo max_lines = kwargs.get("max_lines", 50) @@ -390,10 +361,12 @@ def run(self, **kwargs): command = command.lower() + service_name_list = run_oneshot_cmd(["ros2", "service", "list"]) + service_name_list = service_name_list.splitlines() + # -- ros2 service list ------------------------------------------------ if command == "list": - list_output = run_oneshot_cmd(["ros2", "service", "list"]) - result["output"] = list_output + result["output"] = service_name_list # -- ros2 service info --------------------------------- elif command == "info": @@ -456,11 +429,10 @@ def run(self, **kwargs): base_args = ["ros2", "service", "echo", service_name] execute_subprocess(console, base_args, max_duration, max_lines) - result["output"] = echo_output + result["output"] = True # -- unknown ------------------------------------------------------------ else: - raise ValueError( f"Unknown command '{command}'. " "Expected one of: list, info, type, call, echo, find." @@ -468,6 +440,7 @@ def run(self, **kwargs): return result + @vulcanai_tool class Ros2ActionTool(AtomicTool): name = "ros2_action" @@ -492,17 +465,18 @@ class Ros2ActionTool(AtomicTool): "output": "string", # `ros2 action list` } - - def run(self, **kwargs): # Get the shared ROS2 node from the blackboard node = self.bb.get("main_node", None) if node is None: raise Exception("Could not find shared node, aborting...") - + + console = self.bb.get("console", None) + if console is None: + raise Exception("Could not find console, aborting...") + command = kwargs.get("command", None) action_name = kwargs.get("action_name", None) - do_send_goal = kwargs.get("send_goal", False) # legacy flag goal_args = kwargs.get("args", None) wait_for_result = kwargs.get("wait_for_result", True) action_type = kwargs.get("action_type", None) @@ -514,10 +488,12 @@ def run(self, **kwargs): command = command.lower() + action_name_list = run_oneshot_cmd(["ros2", "action", "list"]) + action_name_list = action_name_list.splitlines() + # -- ros2 action list ------------------------------------------------- if command == "list": - list_output = run_oneshot_cmd(["ros2", "action", "list"]) - result["output"] = list_output + result["output"] = action_name_list # -- ros2 action info ----------------------------------- elif command == "info": @@ -559,8 +535,6 @@ def run(self, **kwargs): args_list.extend([action_name, action_type, goal_args]) goal_output = run_oneshot_cmd(args_list) - """result["goal_output"] = goal_output - result["type"] = action_type""" result["output"] = goal_output # -- unknown ------------------------------------------------------------ @@ -598,14 +572,16 @@ class Ros2ParamTool(AtomicTool): "output": "string", } - - def run(self, **kwargs): # Get the shared ROS2 node from the blackboard node = self.bb.get("main_node", None) if node is None: raise Exception("Could not find shared node, aborting...") + console = self.bb.get("console", None) + if console is None: + raise Exception("Could not find console, aborting...") + command = kwargs.get("command", None) node = kwargs.get("node_name", None) param = kwargs.get("param_name", None) @@ -723,4 +699,157 @@ def run(self, **kwargs): return result +@vulcanai_tool +class Ros2PkgTool(AtomicTool): + name = "ros2_pkg" + description = "List ROS2 packages and optionally get executables for a specific package." + tags = ["ros2", "pkg", "packages", "cli", "introspection"] + + # If package_name is not provided, runs: `ros2 pkg list` + # If provided, runs: `ros2 pkg executables ` + input_schema = [ + ("package_name", "string?") # (optional) Name of the package. + # If not provided, the command is `ros2 pkg list`. + # Otherwise `ros2 pkg executables `. + ] + + output_schema = { + "ros2": "bool", # ros2 flag for pretty printing. + "output": "string", # list of packages or list of executables for a package. + } + + def run(self, **kwargs): + # Get the shared ROS2 node from the blackboard + node = self.bb.get("main_node", None) + if node is None: + raise Exception("Could not find shared node, aborting...") + + console = self.bb.get("console", None) + if console is None: + raise Exception("Could not find console, aborting...") + + # Get the package name if provided by the query + package_name = kwargs.get("package_name", None) + + result = { + "ros2": True, + "output": None + } + + command = command.lower() + + # -- Run `ros2 pkg list` -------------------------------------------- + if command == "list": + pkg_name_list = run_oneshot_cmd(["ros2", "pkg", "list"]) + pkg_name_list = pkg_name_list.splitlines() + result["output"] = pkg_name_list + + # -- Run `ros2 pkg executables` -------------------------------------------- + elif command == "executables": + pkg_name_list = run_oneshot_cmd(["ros2", "pkg", "executables"]) + pkg_name_list = pkg_name_list.splitlines() + result["output"] = pkg_name_list + + # -- Run `ros2 pkg executables ` --------------------------- + elif command == "prefix": + if not package_name: + raise ValueError("`command='prefix'` requires `package_name`.") + + info_output = run_oneshot_cmd( + ["ros2", "topic", "prefix", package_name] + ) + result["output"] = info_output + + # -- Run `ros2 pkg executables ` --------------------------- + elif command == "xml": + if not package_name: + raise ValueError("`command='xml'` requires `package_name`.") + + info_output = run_oneshot_cmd( + ["ros2", "topic", "xml", package_name] + ) + result["output"] = info_output + + # -- unknown ---------------------------------------------------------- + else: + raise ValueError( + f"Unknown command '{command}'. " + "Expected one of: list, executables, prefix, xml" + ) + + return result + + +@vulcanai_tool +class Ros2InterfaceTool(AtomicTool): + name = "ros2_interface" + description = "List ROS2 interfaces and optionally show the definition of a specific interface." + tags = ["ros2", "interface", "msg", "srv", "action", "cli", "introspection"] + + # - `command` lets you pick a single subcommand (list/packages/package). + input_schema = [ + ("interface_name", "string?"), # (optional) Name of the interface, e.g. "std_msgs/msg/String". + # If not provided, the command is `ros2 interface list`. + # Otherwise `ros2 interface show `. + ] + + output_schema = { + "ros2": "bool", # ros2 flag for pretty printing. + "output": "string", # list of interfaces (as list of strings) or full interface definition. + } + + def run(self, **kwargs): + # Get the shared ROS2 node from the blackboard + node = self.bb.get("main_node", None) + if node is None: + raise Exception("Could not find shared node, aborting...") + + console = self.bb.get("console", None) + if console is None: + raise Exception("Could not find console, aborting...") + + # Get the interface name if provided by the query + interface_name = kwargs.get("interface_name", None) + + result = { + "ros2": True, + "output": None + } + + command = command.lower() + + pkg_name_list = run_oneshot_cmd(["ros2", "pkg", "list"]) + pkg_name_list = pkg_name_list.splitlines() + + # -- ros2 interface list ---------------------------------------------- + if interface_name is None: + interface_name_list = run_oneshot_cmd(["ros2", "interface", "list"]) + interface_name_list = interface_name_list.splitlines() + result["output"] = interface_name_list + + # -- ros2 interface packages ------------------------------------------ + elif command == "inpackagesfo": + interface_pkg_name_list = run_oneshot_cmd(["ros2", "interface", "packages"]) + interface_pkg_name_list = interface_pkg_name_list.splitlines() + result["output"] = interface_pkg_name_list + + # -- ros2 interface package -------------------------------- + elif command == "package": + if not interface_name: + raise ValueError("`command='package'` requires `interface_name`.") + + info_output = run_oneshot_cmd( + ["ros2", "topic", "package", interface_name] + ) + result["output"] = info_output + + # TODO. proto, show? + + # -- unknown ---------------------------------------------------------- + else: + raise ValueError( + f"Unknown command '{command}'. " + "Expected one of: list, info, echo, bw, delay, hz, find, pub, type." + ) + return result \ No newline at end of file From 17069b60d2e069d16658ea862d18157aa5e5598e Mon Sep 17 00:00:00 2001 From: danipiza Date: Fri, 19 Dec 2025 14:07:20 +0100 Subject: [PATCH 04/24] [#23896] Changed to load 'default_tools.py' as a submodule Signed-off-by: danipiza --- src/vulcanai/console/console.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index 93592fb..9cc8571 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -169,8 +169,8 @@ def __init__( self.tab_matches = [] # Current index in the tab matches self.tab_index = 0 - current_path = os.path.dirname(os.path.abspath(__file__)) - self.manager.register_tools_from_file(f"{current_path}/../tools/default_tools.py") + # current_path = os.path.dirname(os.path.abspath(__file__)) + # self.manager.register_tools_from_file(f"{current_path}/../tools/default_tools.py") self.manager.bb["console"] = self self.logger = VulcanAILogger.log_manager From fd1513c652407be0f360d38e0c307dc8a414922e Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 8 Jan 2026 10:27:57 +0100 Subject: [PATCH 05/24] [#23897] Added ros2 publisher/subcriber default tool Signed-off-by: danipiza --- src/vulcanai/tools/default_tools.py | 143 +++++++++++++++++++++++++++- 1 file changed, 142 insertions(+), 1 deletion(-) diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index 7aaa46f..e15c446 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -22,6 +22,10 @@ from vulcanai import AtomicTool, CompositeTool, vulcanai_tool from vulcanai.console.utils import execute_subprocess, run_oneshot_cmd +import time +import rclpy +from std_msgs.msg import String + """ - ros2 node Commands: @@ -852,4 +856,141 @@ def run(self, **kwargs): "Expected one of: list, info, echo, bw, delay, hz, find, pub, type." ) - return result \ No newline at end of file + return result + + +@vulcanai_tool +class Ros2PublishTool(AtomicTool): + name = "ros_publish" + description = "Publish one or more String messages to a given ROS 2 topic." + tags = ["ros2", "publish", "message", "std_msgs"] + + input_schema = [ + ("topic", "string"), # e.g. "chatter" + ("message", "string"), # payload + ("count", "int"), # number of messages to publish + ("period_sec", "float"), # delay between publishes (in seconds) + ("qos_depth", "int"), # publisher queue depth + ] + + output_schema = { + "published": "bool", + "count": "int", + "topic": "string" + } + + def run(self, **kwargs): + + node = self.bb.get("main_node", None) + if node is None: + raise Exception("Could not find shared node, aborting...") + + topic = kwargs.get("topic", None) + message = kwargs.get("message", "Hello from PublishTool!") + count = kwargs.get("count", 100) + period_sec = kwargs.get("period_sec", 0.1) + qos_depth = kwargs.get("qos_depth", 10.0) + + if not topic: + node.get_logger().error("No topic provided.") + return {"published": False, "count": 0, "topic": topic} + + if count <= 0: + node.get_logger().warn("Count <= 0, nothing to publish.") + return {"published": True, "count": 0, "topic": topic} + + publisher = node.create_publisher(String, topic, qos_depth) + + published_count = 0 + for _ in range(count): + msg = String() + msg.data = message + + with node.node_lock: + node.get_logger().info(f"Publishing: '{msg.data}'") + publisher.publish(msg) + + published_count += 1 + + rclpy.spin_once(node, timeout_sec=0.05) + + if period_sec and period_sec > 0.0: + time.sleep(period_sec) + + return {"published": True, "count": published_count, "topic": topic} + + +@vulcanai_tool +class Ros2SubscribeTool(AtomicTool): + name = "ros_subscribe" + description = "Subscribe to a topic and stop after receiving N messages or a timeout." + tags = ["ros2", "subscribe", "topic", "std_msgs"] + + input_schema = [ + ("topic", "string"), # topic name + ("max_messages", "int"), # stop after this number of messages + ("timeout_sec", "float"), # stop after this seconds + ("qos_depth", "int"), # subscription queue depth + ] + + output_schema = { + "success": "bool", + "received": "int", + "messages": "list", + "reason": "string", + "topic": "string", + } + + def run(self, **kwargs): + + node = self.bb.get("main_node", None) + if node is None: + raise Exception("Could not find shared node, aborting...") + + topic = kwargs.get("topic", None) + max_messages = kwargs.get("max_messages", 100) + timeout_sec = kwargs.get("timeout_sec", 60.0) + qos_depth = kwargs.get("qos_depth", 10.0) + + if not topic: + return {"success": False, "received": 0, "messages": [], "reason": "no_topic", "topic": topic} + + if max_messages <= 0: + return {"success": True, "received": 0, "messages": [], "reason": "max_messages<=0", "topic": topic} + + received_msgs = [] + + def callback(msg: String): + received_msgs.append(msg.data) + node.get_logger().info(f"I heard: [{msg.data}]") + + sub = node.create_subscription(String, topic, callback, qos_depth) + + start = time.monotonic() + reason = "timeout" + + try: + while rclpy.ok(): + # Stop conditions + if len(received_msgs) >= max_messages: + reason = "max_messages" + break + if (time.monotonic() - start) >= timeout_sec: + reason = "timeout" + break + + rclpy.spin_once(node, timeout_sec=0.1) + + finally: + try: + node.destroy_subscription(sub) + except Exception: + pass + + return { + "success": True, + "received": len(received_msgs), + "messages": received_msgs, + "reason": reason, + "topic": topic, + } \ No newline at end of file From b863d723973185b3ec8570256ca35e6994c8e5a2 Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 8 Jan 2026 14:21:38 +0100 Subject: [PATCH 06/24] [#23897] Added ros2 types for publisher/subscriber tool Signed-off-by: danipiza --- src/vulcanai/tools/default_tools.py | 152 ++++++++++++++++++++++------ 1 file changed, 121 insertions(+), 31 deletions(-) diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index e15c446..8f076c3 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -18,13 +18,16 @@ It contains atomic tools used to call ROS2 CLI. """ -import subprocess from vulcanai import AtomicTool, CompositeTool, vulcanai_tool from vulcanai.console.utils import execute_subprocess, run_oneshot_cmd -import time +import importlib +import json import rclpy +import subprocess from std_msgs.msg import String +import time + """ - ros2 node @@ -74,24 +77,19 @@ set Set parameter -??? - | - V - -Commands: - - ros2 bag Various rosbag related sub-commands - ros2 component Various component related sub-commands - ros2 daemon Various daemon related sub-commands - ros2 doctor Check ROS setup and other potential issues - ros2 interface Show information about ROS interfaces - ros2 launch Run a launch file - ros2 lifecycle Various lifecycle related sub-commands - ros2 multicast Various multicast related sub-commands - ros2 pkg Various package related sub-commands - ros2 run Run a package specific executable - ros2 security Various security related sub-commands - ros2 wtf Use `wtf` as alias to `doctor` +- 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 """ @@ -742,19 +740,19 @@ def run(self, **kwargs): command = command.lower() - # -- Run `ros2 pkg list` -------------------------------------------- + # -- Run `ros2 pkg list` ---------------------------------------------- if command == "list": pkg_name_list = run_oneshot_cmd(["ros2", "pkg", "list"]) pkg_name_list = pkg_name_list.splitlines() result["output"] = pkg_name_list - # -- Run `ros2 pkg executables` -------------------------------------------- + # -- Run `ros2 pkg executables` --------------------------------------- elif command == "executables": pkg_name_list = run_oneshot_cmd(["ros2", "pkg", "executables"]) pkg_name_list = pkg_name_list.splitlines() result["output"] = pkg_name_list - # -- Run `ros2 pkg executables ` --------------------------- + # -- Run `ros2 pkg prefix ` ----------------------------- elif command == "prefix": if not package_name: raise ValueError("`command='prefix'` requires `package_name`.") @@ -764,7 +762,7 @@ def run(self, **kwargs): ) result["output"] = info_output - # -- Run `ros2 pkg executables ` --------------------------- + # -- Run `ros2 pkg xml ` -------------------------------- elif command == "xml": if not package_name: raise ValueError("`command='xml'` requires `package_name`.") @@ -859,6 +857,29 @@ def run(self, **kwargs): return result +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" @@ -868,6 +889,7 @@ class Ros2PublishTool(AtomicTool): input_schema = [ ("topic", "string"), # e.g. "chatter" ("message", "string"), # payload + ("msg_type", "string"), # e.g. "std_msgs/msg/String" ("count", "int"), # number of messages to publish ("period_sec", "float"), # delay between publishes (in seconds) ("qos_depth", "int"), # publisher queue depth @@ -879,6 +901,25 @@ class Ros2PublishTool(AtomicTool): "topic": "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): node = self.bb.get("main_node", None) @@ -887,10 +928,12 @@ def run(self, **kwargs): topic = kwargs.get("topic", None) message = kwargs.get("message", "Hello from PublishTool!") + msg_type_str = kwargs.get("msg_type", "std_msgs/msg/String") count = kwargs.get("count", 100) period_sec = kwargs.get("period_sec", 0.1) qos_depth = kwargs.get("qos_depth", 10.0) + if not topic: node.get_logger().error("No topic provided.") return {"published": False, "count": 0, "topic": topic} @@ -899,12 +942,18 @@ def run(self, **kwargs): node.get_logger().warn("Count <= 0, nothing to publish.") return {"published": True, "count": 0, "topic": topic} - publisher = node.create_publisher(String, topic, qos_depth) + + MsgType = import_msg_type(msg_type_str, node) + publisher = node.create_publisher(MsgType, topic, qos_depth) published_count = 0 - for _ in range(count): - msg = String() - msg.data = message + for i in range(count): + msg = MsgType() + if hasattr(msg, "data"): + msg.data = message + else: + payload = json.loads(message) + self.msg_from_dict(msg, payload) with node.node_lock: node.get_logger().info(f"Publishing: '{msg.data}'") @@ -928,9 +977,11 @@ class Ros2SubscribeTool(AtomicTool): input_schema = [ ("topic", "string"), # topic name + ("msg_type", "string"), # e.g. "std_msgs/msg/String" ("max_messages", "int"), # stop after this number of messages ("timeout_sec", "float"), # stop after this seconds ("qos_depth", "int"), # subscription queue depth + ("output_format", "string"), # "data" | "dict" ] output_schema = { @@ -941,6 +992,31 @@ class Ros2SubscribeTool(AtomicTool): "topic": "string", } + + def msg_to_dict(self, msg): + """ + Convert a ROS 2 message instance into a Python dictionary. + + This function recursively converts a ROS 2 message into a dictionary + using ROS 2 Python introspection (`__slots__`). + + Supports: + - Primitive fields + - Nested ROS 2 messages + """ + out = {} + for field in getattr(msg, "__slots__", []): + key = field.lstrip("_") + val = getattr(msg, field) + if hasattr(val, "__slots__"): + out[key] = self.msg_to_dict(val) + elif isinstance(val, (list, tuple)): + out[key] = [self.msg_to_dict(v) if hasattr(v, "__slots__") else v for v in val] + else: + out[key] = val + return out + + def run(self, **kwargs): node = self.bb.get("main_node", None) @@ -948,9 +1024,12 @@ def run(self, **kwargs): raise Exception("Could not find shared node, aborting...") topic = kwargs.get("topic", None) + msg_type_str = kwargs.get("msg_type", "std_msgs/msg/String") max_messages = kwargs.get("max_messages", 100) timeout_sec = kwargs.get("timeout_sec", 60.0) qos_depth = kwargs.get("qos_depth", 10.0) + output_format = kwargs.get("output_format", "data") + if not topic: return {"success": False, "received": 0, "messages": [], "reason": "no_topic", "topic": topic} @@ -958,13 +1037,24 @@ def run(self, **kwargs): if max_messages <= 0: return {"success": True, "received": 0, "messages": [], "reason": "max_messages<=0", "topic": topic} + if not msg_type_str: + msg_type_str = "std_msgs/msg/String" + received_msgs = [] def callback(msg: String): - received_msgs.append(msg.data) - node.get_logger().info(f"I heard: [{msg.data}]") + # received_msgs.append(msg.data) + # node.get_logger().info(f"I heard: [{msg.data}]") + if output_format == "data" and hasattr(msg, "data"): + received_msgs.append(msg.data) + node.get_logger().info(f"I heard: [{msg.data}]") + else: + d = self.msg_to_dict(msg) + received_msgs.append(d["data"] if "data" in d else d) + node.get_logger().info(f"I heard: [{d["data"]}]") - sub = node.create_subscription(String, topic, callback, qos_depth) + MsgType = import_msg_type(msg_type_str, node) + sub = node.create_subscription(MsgType, topic, callback, qos_depth) start = time.monotonic() reason = "timeout" From 0d98ff232e70336508043f350c65736885c0569a Mon Sep 17 00:00:00 2001 From: danipiza Date: Tue, 27 Jan 2026 10:19:58 +0100 Subject: [PATCH 07/24] [#23897] Updated code with revision suggestions Signed-off-by: danipiza --- pyproject.toml | 3 + src/vulcanai/console/console.py | 4 +- src/vulcanai/tools/default_tools.py | 284 +++++++++++++++------------- 3 files changed, 154 insertions(+), 137 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a7ae25c..a17f309 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."vulcanai.tools.default_tools"] +default_tools = "vulcanai.tools.default_tools" diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index 9cc8571..1e6423e 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -169,8 +169,8 @@ def __init__( self.tab_matches = [] # Current index in the tab matches self.tab_index = 0 - # current_path = os.path.dirname(os.path.abspath(__file__)) - # self.manager.register_tools_from_file(f"{current_path}/../tools/default_tools.py") + + self.manager.register_tools_from_entry_points("vulcanai.tools.default_tools") self.manager.bb["console"] = self self.logger = VulcanAILogger.log_manager diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index 8f076c3..70c9827 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -29,6 +29,17 @@ import time +"""topics = topic_name_list.splitlines() + +# TODO. in all commands +# Will be updated in the TUI Migration PR. +# The PR adds a modalscreen to select the most similar string), +# this applies to all ros cli commands. Though, not implemented +# in the rest commands from this PR +if topic_name not in topics: + topic_similar = search_similar(topics, topic_name) + topic_name = topic_similar""" + """ - ros2 node Commands: @@ -96,6 +107,11 @@ @vulcanai_tool class Ros2NodeTool(AtomicTool): name = "ros2_node" + description = ( + "Wrapper for `ros2 node` CLI." + "Run any subcommand: 'list', 'info'" + "With an optional argument 'node_name' for 'info' subcommand." + ) description = "List ROS2 nodes and optionally get detailed info for a specific node." tags = ["ros2", "nodes", "cli", "info", "diagnostics"] @@ -121,13 +137,12 @@ def run(self, **kwargs): result = { "ros2": True, - "output": None + "output": "string" } # -- Run `ros2 node list` --------------------------------------------- if node_name == None: node_name_list = run_oneshot_cmd(["ros2", "node", "list"]) - node_name_list = node_name_list.splitlines() result["output"] = node_name_list # -- Run `ros2 node info ` -------------------------------------- @@ -136,7 +151,7 @@ def run(self, **kwargs): raise ValueError("`command='info'` requires `node_name`.") info_output = run_oneshot_cmd( - ["ros2", "topic", "info", node_name] + ["ros2", "node", "info", node_name] ) result["output"] = info_output @@ -147,19 +162,17 @@ def run(self, **kwargs): class Ros2TopicTool(AtomicTool): name = "ros2_topic" description = ( - "Wrapper for `ros2 topic` CLI. Always returns `ros2 topic list` " - "and can optionally run any subcommand: bw, delay, echo, find, " - "hz, info, list, pub, type" + "Wrapper for `ros2 topic` CLI." + "Run any subcommand: 'list', 'info', 'find', 'type', 'echo', 'bw', 'delay', 'hz', 'pub'." + "With optional arguments like 'topic_name', 'message_type', 'max_duration' or 'max_lines'" ) tags = ["ros2", "topics", "cli", "info"] # - `command` lets you pick a single subcommand (echo/bw/hz/delay/find/pub/type). input_schema = [ - ("command", "string"), # Command: "list", "info", "type", "find", - # "pub", "hz", "echo", "bw", "delay" + ("command", "string"), # Command ("topic_name", "string?"), # (optional) Topic name. (info/echo/bw/delay/hz/type/pub) ("msg_type", "string?"), # (optional) Message type (`find` , `pub` ) - # ("max_duration", "number?"), # (optional) Seconds for streaming commands (echo/bw/hz/delay) ("max_lines", "int?"), # (optional) Cap number of lines for streaming commands ] @@ -188,13 +201,12 @@ def run(self, **kwargs): result = { "ros2": True, - "output": None, + "output": "string", } command = command.lower() topic_name_list = run_oneshot_cmd(["ros2", "topic", "list"]) - topic_name_list = topic_name_list.splitlines() # -- ros2 topic list -------------------------------------------------- if command == "list": @@ -224,13 +236,14 @@ def run(self, **kwargs): elif command == "find": if not msg_type: raise ValueError("`command='find'` requires `msg_type` (ROS type).") + 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"] = find_topics + result["output"] = ', '.join(find_topics) # -- ros2 topic type ------------------------------------- elif command == "type": @@ -250,16 +263,18 @@ def run(self, **kwargs): base_args = ["ros2", "topic", "echo", topic_name] execute_subprocess(console, base_args, max_duration, max_lines) - result["output"] = True + result["output"] = "True" + result["ros2"] = True # -- ros2 topic bw --------------------------------------- elif command == "bw": base_args = ["ros2", "topic", "bw", topic_name] execute_subprocess(console, base_args, max_duration, max_lines) - result["output"] = True + result["output"] = "True" + result["ros2"] = True - # delay -------------------------------------------------------------- + # -- ros2 topic delay ------------------------------------ elif command == "delay": if not topic_name: raise ValueError("`command='delay'` requires `topic_name`.") @@ -267,7 +282,8 @@ def run(self, **kwargs): base_args = ["ros2", "topic", "delay", topic_name] execute_subprocess(console, base_args, max_duration, max_lines) - result["output"] = True + result["output"] = "True" + result["ros2"] = True # -- ros2 topic hz --------------------------------------- elif command == "hz": @@ -277,7 +293,8 @@ def run(self, **kwargs): base_args = ["ros2", "topic", "hz", topic_name] execute_subprocess(console, base_args, max_duration, max_lines) - result["output"] = True + result["output"] = "True" + result["ros2"] = True # -- publisher -------------------------------------------------------- elif command == "pub": @@ -300,7 +317,8 @@ def run(self, **kwargs): base_args = ["ros2", "topic", "pub", topic_name, msg_type] execute_subprocess(console, base_args, max_duration, max_lines) - result["output"] = True + result["output"] = "True" + result["ros2"] = True # -- unknown ---------------------------------------------------------- else: @@ -316,15 +334,16 @@ def run(self, **kwargs): class Ros2ServiceTool(AtomicTool): name = "ros2_service" description = ( - "Wrapper for `ros2 service` CLI. Always returns `ros2 service list`, " - "and can optionally run any subcommand: list, info, type, call, echo, find." + "Wrapper for `ros2 service` CLI." + "Run any subcommand: 'list', 'info', 'type', 'find', 'call', 'echo'." + "With optional arguments like 'service_name', 'service_type', " + "'call', 'args', 'max_duration' or 'max_lines'" ) tags = ["ros2", "services", "cli", "info", "call"] # - `command` = "list", "info", "type", "call", "echo", "find" input_schema = [ - ("command", "string"), # Command: "list", "info", "type", "find" - # "call", "echo" + ("command", "string"), # Command ("service_name", "string?"), # (optional) Service name. "info", "type", "call", "echo" ("service_type", "string?"), # (optional) Service type. "find", "call" ("call", "bool?"), # (optional) backwards-compatible call flag @@ -334,8 +353,8 @@ class Ros2ServiceTool(AtomicTool): ] output_schema = { - "ros2": "bool", # ros2 flag for pretty printing - "output": "string", # `ros2 service list` + "ros2": "bool", # ros2 flag for pretty printing + "output": "string", # `ros2 service list` } def run(self, **kwargs): @@ -358,13 +377,12 @@ def run(self, **kwargs): result = { "ros2": True, - "output": None, + "output": "string", } command = command.lower() service_name_list = run_oneshot_cmd(["ros2", "service", "list"]) - service_name_list = service_name_list.splitlines() # -- ros2 service list ------------------------------------------------ if command == "list": @@ -378,7 +396,6 @@ def run(self, **kwargs): info_output = run_oneshot_cmd( ["ros2", "service", "info", service_name] ) - result["output"] = info_output # -- ros2 service type --------------------------------- @@ -389,7 +406,6 @@ def run(self, **kwargs): type_output = run_oneshot_cmd( ["ros2", "service", "type", service_name] ) - result["output"] = type_output.strip() # -- ros2 service find ----------------------------------------- @@ -400,7 +416,6 @@ def run(self, **kwargs): find_output = run_oneshot_cmd( ["ros2", "service", "find", service_type] ) - result["output"] = find_output # -- ros2 service call service_name service_type ---------------------- @@ -420,7 +435,6 @@ def run(self, **kwargs): call_output = run_oneshot_cmd( ["ros2", "service", "call", service_name, service_type, call_args] ) - result["output"] = call_output # -- ros2 service echo service_name ----------------------------------- @@ -431,7 +445,8 @@ def run(self, **kwargs): base_args = ["ros2", "service", "echo", service_name] execute_subprocess(console, base_args, max_duration, max_lines) - result["output"] = True + result["output"] = "True" + result["ros2"] = True # -- unknown ------------------------------------------------------------ else: @@ -447,19 +462,20 @@ def run(self, **kwargs): class Ros2ActionTool(AtomicTool): name = "ros2_action" description = ( - "Wrapper for `ros2 action` CLI. Always returns `ros2 action list`, " - "and can optionally run: list, info, type, send_goal." + "Wrapper for `ros2 action` CLI." + "Run any subcommand: 'list', 'info', 'type', 'send_goal'." + "With optional arguments like 'action_name', 'action_type', " + "'goal_args', 'args'" ) tags = ["ros2", "actions", "cli", "info", "goal"] # - `command`: "list", "info", "type", "send_goal" input_schema = [ - ("command", "string"), # Command: "list" "info" "type" "send_goal" + ("command", "string"), # Command ("action_name", "string?"), # (optional) Action name ("action_type", "string?"), # (optional) Action type. "find" ("send_goal", "bool?"), # (optional) legacy flag (backwards compatible) ("args", "string?"), # (optional) goal YAML, e.g. '{order: 5}' - ("wait_for_result", "bool?"), # (optional) if true => add `--result` ] output_schema = { @@ -473,25 +489,19 @@ def run(self, **kwargs): if node is None: raise Exception("Could not find shared node, aborting...") - console = self.bb.get("console", None) - if console is None: - raise Exception("Could not find console, aborting...") - command = kwargs.get("command", None) action_name = kwargs.get("action_name", None) goal_args = kwargs.get("args", None) - wait_for_result = kwargs.get("wait_for_result", True) action_type = kwargs.get("action_type", None) result = { "ros2": True, - "output": None, + "output": "string", } command = command.lower() action_name_list = run_oneshot_cmd(["ros2", "action", "list"]) - action_name_list = action_name_list.splitlines() # -- ros2 action list ------------------------------------------------- if command == "list": @@ -511,18 +521,16 @@ def run(self, **kwargs): elif command == "type": if not action_name: raise ValueError("`command='type'` requires `action_name`.") + type_output = run_oneshot_cmd( ["ros2", "action", "type", action_name] ) - result["output"] = type_output # send_goal ----------------------------------------------------------- elif command == "send_goal": if not action_name: raise ValueError("`command='send_goal'` requires `action_name`.") - if goal_args is None: - raise ValueError("`command='send_goal'` requires `args`.") # Use explicit type if provided, otherwise detect it if not action_type: @@ -531,10 +539,9 @@ def run(self, **kwargs): ) action_type = type_output.strip() - args_list = ["ros2", "action", "send_goal"] - if wait_for_result: - args_list.append("--result") - args_list.extend([action_name, action_type, goal_args]) + args_list = ["ros2", "action", "send_goal", action_name, action_type] + if goal_args is not None: + args_list.extend(goal_args) goal_output = run_oneshot_cmd(args_list) result["output"] = goal_output @@ -553,16 +560,16 @@ def run(self, **kwargs): class Ros2ParamTool(AtomicTool): name = "ros2_param" description = ( - "Wrapper for `ros2 param` CLI. Always returns `ros2 param list` " - "(optionally filtered by node), and can run: list, get, describe, " - "set, delete, dump, load." + "Wrapper for `ros2 param` CLI." + "Run any subcommand: 'list', 'get', 'describe', 'set', 'delete', 'dump', 'load'." + "With optional arguments like 'param_name', 'node_name', " + "'set_value', 'file_path'" ) tags = ["ros2", "param", "parameters", "cli"] # - `command`: "list", "get", "describe", "set", "delete", "dump", "load" input_schema = [ - ("command", "string"), # Command: "list" "get" "describe" - # "set" "delete" "dump" "load" + ("command", "string"), # Command ("param_name", "string?"), # (optional) Parameter name ("node_name", "string?"), # (optional) Target node ("set_value", "string?"), # (optional) value for set @@ -580,10 +587,6 @@ def run(self, **kwargs): if node is None: raise Exception("Could not find shared node, aborting...") - console = self.bb.get("console", None) - if console is None: - raise Exception("Could not find console, aborting...") - command = kwargs.get("command", None) node = kwargs.get("node_name", None) param = kwargs.get("param_name", None) @@ -592,7 +595,7 @@ def run(self, **kwargs): result = { "ros2": True, - "output": None, + "output": "string", } command = command.lower() @@ -619,7 +622,6 @@ def run(self, **kwargs): get_output = run_oneshot_cmd( ["ros2", "param", "get", node, param] ) - result["output"] = get_output # -- ros2 param describe ------------------------------- @@ -630,7 +632,6 @@ def run(self, **kwargs): describe_output = run_oneshot_cmd( ["ros2", "param", "describe", node, param] ) - result["output"] = describe_output # -- ros2 param set ------------------------ @@ -643,7 +644,6 @@ def run(self, **kwargs): set_output = run_oneshot_cmd( ["ros2", "param", "set", node, param, set_value] ) - result["output"] = set_output # -- ros2 param delete ---------------------------------- @@ -654,7 +654,6 @@ def run(self, **kwargs): delete_output = run_oneshot_cmd( ["ros2", "param", "delete", node, param] ) - result["output"] = delete_output # -- ros2 param dump [file_path] ------------------------------- @@ -669,7 +668,6 @@ def run(self, **kwargs): dump_output = run_oneshot_cmd( ["ros2", "param", "dump", node, "--output-file", file_path] ) - # CLI usually prints a line like "Saved parameters to file..." # so we just expose that. result["output"] = dump_output or f"Dumped parameters to {file_path}" @@ -677,7 +675,6 @@ def run(self, **kwargs): dump_output = run_oneshot_cmd( ["ros2", "param", "dump", node] ) - result["output"] = dump_output # -- ros2 param load ------------------------------- @@ -688,7 +685,6 @@ def run(self, **kwargs): load_output = run_oneshot_cmd( ["ros2", "param", "load", node, file_path] ) - result["output"] = load_output # -- unknown ---------------------------------------------------------- @@ -704,15 +700,16 @@ def run(self, **kwargs): @vulcanai_tool class Ros2PkgTool(AtomicTool): name = "ros2_pkg" - description = "List ROS2 packages and optionally get executables for a specific package." + description = ( + "Wrapper for `ros2 pkg` CLI." + "Run any subcommand: 'list', 'executables'." + ) tags = ["ros2", "pkg", "packages", "cli", "introspection"] # If package_name is not provided, runs: `ros2 pkg list` # If provided, runs: `ros2 pkg executables ` input_schema = [ - ("package_name", "string?") # (optional) Name of the package. - # If not provided, the command is `ros2 pkg list`. - # Otherwise `ros2 pkg executables `. + ("command", "string"), # Command ] output_schema = { @@ -726,16 +723,11 @@ def run(self, **kwargs): if node is None: raise Exception("Could not find shared node, aborting...") - console = self.bb.get("console", None) - if console is None: - raise Exception("Could not find console, aborting...") - # Get the package name if provided by the query - package_name = kwargs.get("package_name", None) - + command = kwargs.get("command", None) result = { "ros2": True, - "output": None + "output": "string" } command = command.lower() @@ -743,35 +735,13 @@ def run(self, **kwargs): # -- Run `ros2 pkg list` ---------------------------------------------- if command == "list": pkg_name_list = run_oneshot_cmd(["ros2", "pkg", "list"]) - pkg_name_list = pkg_name_list.splitlines() result["output"] = pkg_name_list # -- Run `ros2 pkg executables` --------------------------------------- elif command == "executables": pkg_name_list = run_oneshot_cmd(["ros2", "pkg", "executables"]) - pkg_name_list = pkg_name_list.splitlines() result["output"] = pkg_name_list - # -- Run `ros2 pkg prefix ` ----------------------------- - elif command == "prefix": - if not package_name: - raise ValueError("`command='prefix'` requires `package_name`.") - - info_output = run_oneshot_cmd( - ["ros2", "topic", "prefix", package_name] - ) - result["output"] = info_output - - # -- Run `ros2 pkg xml ` -------------------------------- - elif command == "xml": - if not package_name: - raise ValueError("`command='xml'` requires `package_name`.") - - info_output = run_oneshot_cmd( - ["ros2", "topic", "xml", package_name] - ) - result["output"] = info_output - # -- unknown ---------------------------------------------------------- else: raise ValueError( @@ -785,11 +755,16 @@ def run(self, **kwargs): @vulcanai_tool class Ros2InterfaceTool(AtomicTool): name = "ros2_interface" - description = "List ROS2 interfaces and optionally show the definition of a specific interface." - tags = ["ros2", "interface", "msg", "srv", "action", "cli", "introspection"] + description = ( + "Wrapper for `ros2 interface` CLI." + "Run any subcommand: 'list', 'packages', 'package', 'show'." + "With optional arguments like 'interface_name'." + ) + tags = ["ros2", "interface", "msg", "srv", "cli", "introspection"] # - `command` lets you pick a single subcommand (list/packages/package). input_schema = [ + ("command", "string"), # Command ("interface_name", "string?"), # (optional) Name of the interface, e.g. "std_msgs/msg/String". # If not provided, the command is `ros2 interface list`. # Otherwise `ros2 interface show `. @@ -806,36 +781,28 @@ def run(self, **kwargs): if node is None: raise Exception("Could not find shared node, aborting...") - console = self.bb.get("console", None) - if console is None: - raise Exception("Could not find console, aborting...") - # Get the interface name if provided by the query + command = kwargs.get("command", None) interface_name = kwargs.get("interface_name", None) result = { "ros2": True, - "output": None + "output": "string" } command = command.lower() - pkg_name_list = run_oneshot_cmd(["ros2", "pkg", "list"]) - pkg_name_list = pkg_name_list.splitlines() - # -- ros2 interface list ---------------------------------------------- if interface_name is None: interface_name_list = run_oneshot_cmd(["ros2", "interface", "list"]) - interface_name_list = interface_name_list.splitlines() result["output"] = interface_name_list # -- ros2 interface packages ------------------------------------------ - elif command == "inpackagesfo": + elif command == "packages": interface_pkg_name_list = run_oneshot_cmd(["ros2", "interface", "packages"]) - interface_pkg_name_list = interface_pkg_name_list.splitlines() result["output"] = interface_pkg_name_list - # -- ros2 interface package -------------------------------- + # -- ros2 interface package -------------------------------- elif command == "package": if not interface_name: raise ValueError("`command='package'` requires `interface_name`.") @@ -845,7 +812,15 @@ def run(self, **kwargs): ) result["output"] = info_output - # TODO. proto, show? + # -- ros2 interface show -------------------------------- + elif command == "show": + if not interface_name: + raise ValueError("`command='package'` requires `interface_name`.") + + info_output = run_oneshot_cmd( + ["ros2", "topic", "show", interface_name] + ) + result["output"] = info_output # -- unknown ---------------------------------------------------------- else: @@ -883,16 +858,25 @@ def import_msg_type(type_str: str, node): @vulcanai_tool class Ros2PublishTool(AtomicTool): name = "ros_publish" - description = "Publish one or more String messages to a given ROS 2 topic." + description = ( + "Publish one or more messages to a given ROS 2 topic. " + "Supports both simple string messages (for std_msgs/msg/String) and custom message types. " + "For custom types, pass message_data as a JSON object with field names and values. " + "By default 10 messages 'Hello from VulcanAI PublishTool!' " + "with type 'std_msgs/msg/String' in topic '/chatter' " + "with 0.1 seconds of delay between messages to publish with a qos_depth of 10. " + "Example for custom type: msg_type='my_pkg/msg/MyMessage', message_data='{\"index\": 1, \"message\": \"Hello\"}'" + ) tags = ["ros2", "publish", "message", "std_msgs"] input_schema = [ - ("topic", "string"), # e.g. "chatter" - ("message", "string"), # payload - ("msg_type", "string"), # e.g. "std_msgs/msg/String" - ("count", "int"), # number of messages to publish - ("period_sec", "float"), # delay between publishes (in seconds) - ("qos_depth", "int"), # publisher queue depth + ("topic", "string"), # e.g. "/chatter" + ("message_data", "string?"), # (optional) payload - string for std_msgs/String or JSON for custom types + ("msg_type", "string?"), # (optional) e.g. "std_msgs/msg/String" or "my_pkg/msg/CustomMsg" + ("count", "int?"), # (optional) number of messages to publish + ("period_sec", "float?"), # (optional) delay between publishes (in seconds) + ("qos_depth", "int?"), # (optional) publisher queue depth + ("message", "string?"), # (deprecated) use message_data instead ] output_schema = { @@ -926,10 +910,11 @@ def run(self, **kwargs): if node is None: raise Exception("Could not find shared node, aborting...") - topic = kwargs.get("topic", None) - message = kwargs.get("message", "Hello from PublishTool!") + topic = 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", "std_msgs/msg/String") - count = kwargs.get("count", 100) + count = kwargs.get("count", 10) period_sec = kwargs.get("period_sec", 0.1) qos_depth = kwargs.get("qos_depth", 10.0) @@ -949,14 +934,39 @@ def run(self, **kwargs): published_count = 0 for i in range(count): msg = MsgType() + + # TODO. danip Check custom messages implementation + """ + E.G.: + PlanNode 1: kind=SEQUENCE + Step 1: ros_publish(topic=/custom_topic, msg_type=my_pkg/msg/CustomMsg, message_data={"id":1,"text":"Custom message 1"}, count=1, period_sec=0.1, qos_depth=10) + + [EXECUTOR] Invoking 'ros_publish' with args:'{'topic': '/custom_topic', 'msg_type': 'my_pkg/msg/CustomMsg', 'message_data': '{"id":1,"text":"Custom message 1"}', 'count': '1', 'period_sec': '0.1', 'qos_depth': '10'}' + [EXECUTOR] Execution failed for 'ros_publish': No module named 'my_pkg' + [EXECUTOR] Step 'ros_publish' attempt 1/1 failed + [EXECUTOR] [ERROR] PlanNode SEQUENCE failed on attempt 1/1 + """ + + # Try to populate message based on message type if hasattr(msg, "data"): - msg.data = message + # Standard message type with a 'data' field (e.g., std_msgs/msg/String) + msg.data = message_data else: - payload = json.loads(message) - self.msg_from_dict(msg, payload) + # 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: + node.get_logger().error( + f"Failed to parse message_data as JSON for custom type '{msg_type_str}': {e}" + ) + return {"published": False, "count": 0, "topic": topic} with node.node_lock: - node.get_logger().info(f"Publishing: '{msg.data}'") + if hasattr(msg, "data"): + node.get_logger().info(f"Publishing: '{msg.data}'") + else: + node.get_logger().info(f"Publishing custom message to '{topic}'") publisher.publish(msg) published_count += 1 @@ -973,15 +983,19 @@ def run(self, **kwargs): class Ros2SubscribeTool(AtomicTool): name = "ros_subscribe" description = "Subscribe to a topic and stop after receiving N messages or a timeout." + description = ( + "Subscribe to a given ROS 2 topic and stop after receiven N messages or a timeout." + "By default 100 messages and 300 seconds duration and a qos_depth of 10" + ) tags = ["ros2", "subscribe", "topic", "std_msgs"] input_schema = [ - ("topic", "string"), # topic name - ("msg_type", "string"), # e.g. "std_msgs/msg/String" - ("max_messages", "int"), # stop after this number of messages - ("timeout_sec", "float"), # stop after this seconds - ("qos_depth", "int"), # subscription queue depth - ("output_format", "string"), # "data" | "dict" + ("topic", "string"), # topic name + ("msg_type", "string"), # e.g. "std_msgs/msg/String" + ("output_format", "string"), # "data" | "dict" + ("max_messages", "int?"), # (optional) stop after this number of messages + ("timeout_sec", "float?"), # (optional) stop after this seconds + ("qos_depth", "int?"), # (optional) subscription queue depth ] output_schema = { @@ -1026,7 +1040,7 @@ def run(self, **kwargs): topic = kwargs.get("topic", None) msg_type_str = kwargs.get("msg_type", "std_msgs/msg/String") max_messages = kwargs.get("max_messages", 100) - timeout_sec = kwargs.get("timeout_sec", 60.0) + timeout_sec = kwargs.get("timeout_sec", 300.0) qos_depth = kwargs.get("qos_depth", 10.0) output_format = kwargs.get("output_format", "data") From 8caf4cd15388b37db6891765266eb180c0164ebe Mon Sep 17 00:00:00 2001 From: danipiza Date: Wed, 28 Jan 2026 12:31:38 +0100 Subject: [PATCH 08/24] [#23897] Fixed rebase changes Signed-off-by: danipiza --- src/vulcanai/console/console.py | 32 ++++++-------------------------- src/vulcanai/console/utils.py | 15 ++++++++------- 2 files changed, 14 insertions(+), 33 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index 1e6423e..0d8bc74 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -35,15 +35,6 @@ from vulcanai.console.widget_spinner import SpinnerStatus -from prompt_toolkit import PromptSession -from rich.progress import Progress, SpinnerColumn, TextColumn -from vulcanai.models.model import IModelHooks -from vulcanai.console.logger import console, VulcanAILogger - - - - - class TextualLogSink: """A default console that prints to standard output.""" @@ -170,11 +161,14 @@ def __init__( # Current index in the tab matches self.tab_index = 0 - self.manager.register_tools_from_entry_points("vulcanai.tools.default_tools") + # Terminal qol + self.history = [] - self.manager.bb["console"] = self - self.logger = VulcanAILogger.log_manager + # Streaming task control self.stream_task = None + # Suggestion index for RadioListModal + self.suggestion_index = -1 + self.suggestion_index_changed = threading.Event() def set_stream_task(self, input_stream): @@ -186,19 +180,6 @@ def set_stream_task(self, input_stream): self.stream_task = input_stream - def run(self): - self.print("VulcanAI Interactive Console") - self.print("Type 'exit' to quit.\n") - - # Terminal qol - self.history = [] - - # Streaming task control - self.stream_task = None - # Suggestion index for RadioListModal - self.suggestion_index = -1 - self.suggestion_index_changed = threading.Event() - async def on_mouse_down(self, event: MouseEvent) -> None: """ Function used to paste the string for the user clipboard @@ -277,7 +258,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) diff --git a/src/vulcanai/console/utils.py b/src/vulcanai/console/utils.py index 15d6701..6514f62 100644 --- a/src/vulcanai/console/utils.py +++ b/src/vulcanai/console/utils.py @@ -22,12 +22,6 @@ import time from textual.markup import escape # To remove potential errors in textual terminal -# sipnner -from textual.timer import Timer -import rclpy -import os -from typing import List, Optional -import threading class StreamToTextual: """ @@ -67,7 +61,12 @@ def on_request_start(self, text="Querying LLM..."): def on_request_end(self): self.spinner_status.stop() +<<<<<<< HEAD def attach_ros_logger_to_console(console): +======= + +def attach_ros_logger_to_console(console, node): +>>>>>>> dc8f6d1 ([#23897] Fixed rebase changes) """ Redirect ALL rclpy RcutilsLogger output (nodes + executor + rclpy internals) to a Textual console. @@ -126,6 +125,7 @@ def patched_log(self, msg, level, *args, **kwargs): RcutilsLogger.log = patched_log RcutilsLogger._textual_patched = True + def common_prefix(strings: str) -> str: if not strings: return "" @@ -152,6 +152,7 @@ def common_prefix(strings: str) -> str: 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: @@ -180,7 +181,7 @@ async def run_streaming_cmd_async( if echo: line_processed = escape(line) console.add_line(line_processed) - # Count the line + # 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) From b31284a912fc801e4b31c25ccbe95bf22955f43e Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 29 Jan 2026 16:45:13 +0100 Subject: [PATCH 09/24] [#23897] Applied revision Signed-off-by: danipiza --- pyproject.toml | 2 +- src/vulcanai/console/console.py | 7 +- src/vulcanai/console/utils.py | 9 +- src/vulcanai/core/manager.py | 3 +- src/vulcanai/core/manager_iterator.py | 3 +- src/vulcanai/core/manager_plan.py | 3 +- src/vulcanai/tools/default_tools.py | 33 ++-- src/vulcanai/tools/tool_registry.py | 6 +- src/vulcanai/tools/utils.py | 225 ++++++++++++++++++++++++++ 9 files changed, 266 insertions(+), 25 deletions(-) create mode 100644 src/vulcanai/tools/utils.py diff --git a/pyproject.toml b/pyproject.toml index a17f309..ee51a38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,5 +57,5 @@ select = ["E", "F", "I"] [tool.ruff.lint.isort] known-first-party = ["vulcanai"] -[project.entry-points."vulcanai.tools.default_tools"] +[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 0d8bc74..0bfde6c 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -120,6 +120,7 @@ def __init__( tools_from_entrypoints: str = "", user_context: str = "", main_node=None, + default_tools: bool = True, ): super().__init__() # Textual lib @@ -137,6 +138,8 @@ def __init__( self.model = model # 'k' value for top_k tools selection self.k = k + # + self.default_tools = default_tools # Iterative mode self.iterative = iterative # CustomLogTextArea instance @@ -307,7 +310,7 @@ 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("Use '/exit' or press 'Ctrl+Q' to quit.") # Activate the terminal input self.set_input_enabled(True) @@ -1100,7 +1103,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 diff --git a/src/vulcanai/console/utils.py b/src/vulcanai/console/utils.py index 6514f62..faa4d80 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. @@ -23,6 +23,7 @@ from textual.markup import escape # To remove potential errors in textual terminal + class StreamToTextual: """ Class used to redirect the stdout/stderr streams in the textual terminal @@ -61,10 +62,13 @@ def on_request_start(self, text="Querying LLM..."): def on_request_end(self): self.spinner_status.stop() +<<<<<<< HEAD <<<<<<< HEAD def attach_ros_logger_to_console(console): ======= +======= +>>>>>>> c235ed6 ([#23897] Applied revision) def attach_ros_logger_to_console(console, node): >>>>>>> dc8f6d1 ([#23897] Fixed rebase changes) """ @@ -125,7 +129,6 @@ def patched_log(self, msg, level, *args, **kwargs): RcutilsLogger.log = patched_log RcutilsLogger._textual_patched = True - def common_prefix(strings: str) -> str: if not strings: return "" @@ -152,7 +155,6 @@ def common_prefix(strings: str) -> str: 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: @@ -349,3 +351,4 @@ def _get_suggestions(real_string_list_comp: list[str], string_comp: str) -> tupl console.suggestion_index_changed.clear() return ret + diff --git a/src/vulcanai/core/manager.py b/src/vulcanai/core/manager.py index 66936d3..c15cbe7 100644 --- a/src/vulcanai/core/manager.py +++ b/src/vulcanai/core/manager.py @@ -33,12 +33,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() diff --git a/src/vulcanai/core/manager_iterator.py b/src/vulcanai/core/manager_iterator.py index 8414792..868ba97 100644 --- a/src/vulcanai/core/manager_iterator.py +++ b/src/vulcanai/core/manager_iterator.py @@ -43,8 +43,9 @@ 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, registry, validator, k, max(3, hist_depth), logger, _default_tools) self.iter: int = 0 self.max_iters: int = int(max_iters) diff --git a/src/vulcanai/core/manager_plan.py b/src/vulcanai/core/manager_plan.py index 78b3cbb..9b56bdd 100644 --- a/src/vulcanai/core/manager_plan.py +++ b/src/vulcanai/core/manager_plan.py @@ -30,8 +30,9 @@ 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: """ diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index 70c9827..e789718 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.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. @@ -18,13 +18,13 @@ It contains atomic tools used to call ROS2 CLI. """ -from vulcanai import AtomicTool, CompositeTool, vulcanai_tool -from vulcanai.console.utils import execute_subprocess, run_oneshot_cmd +from vulcanai import AtomicTool, vulcanai_tool +from vulcanai.tools.utils import execute_subprocess, run_oneshot_cmd import importlib import json import rclpy -import subprocess +import subprocess # TODO. danip from std_msgs.msg import String import time @@ -137,7 +137,7 @@ def run(self, **kwargs): result = { "ros2": True, - "output": "string" + "output": "", } # -- Run `ros2 node list` --------------------------------------------- @@ -201,7 +201,7 @@ def run(self, **kwargs): result = { "ros2": True, - "output": "string", + "output": "", } command = command.lower() @@ -377,7 +377,7 @@ def run(self, **kwargs): result = { "ros2": True, - "output": "string", + "output": "", } command = command.lower() @@ -465,7 +465,7 @@ class Ros2ActionTool(AtomicTool): "Wrapper for `ros2 action` CLI." "Run any subcommand: 'list', 'info', 'type', 'send_goal'." "With optional arguments like 'action_name', 'action_type', " - "'goal_args', 'args'" + "'goal_args'" ) tags = ["ros2", "actions", "cli", "info", "goal"] @@ -475,7 +475,7 @@ class Ros2ActionTool(AtomicTool): ("action_name", "string?"), # (optional) Action name ("action_type", "string?"), # (optional) Action type. "find" ("send_goal", "bool?"), # (optional) legacy flag (backwards compatible) - ("args", "string?"), # (optional) goal YAML, e.g. '{order: 5}' + ("goal_args", "string?"), # (optional) goal YAML, e.g. '{order: 5}' ] output_schema = { @@ -491,12 +491,12 @@ def run(self, **kwargs): command = kwargs.get("command", None) action_name = kwargs.get("action_name", None) - goal_args = kwargs.get("args", None) action_type = kwargs.get("action_type", None) + goal_args = kwargs.get("goal_args", None) result = { "ros2": True, - "output": "string", + "output": "", } command = command.lower() @@ -595,7 +595,7 @@ def run(self, **kwargs): result = { "ros2": True, - "output": "string", + "output": "", } command = command.lower() @@ -727,7 +727,7 @@ def run(self, **kwargs): command = kwargs.get("command", None) result = { "ros2": True, - "output": "string" + "output": "", } command = command.lower() @@ -787,7 +787,7 @@ def run(self, **kwargs): result = { "ros2": True, - "output": "string" + "output": "", } command = command.lower() @@ -1097,4 +1097,7 @@ def callback(msg: String): "messages": received_msgs, "reason": reason, "topic": topic, - } \ No newline at end of file + } + + + diff --git a/src/vulcanai/tools/tool_registry.py b/src/vulcanai/tools/tool_registry.py index 686c8af..25cc245 100644 --- a/src/vulcanai/tools/tool_registry.py +++ b/src/vulcanai/tools/tool_registry.py @@ -78,7 +78,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,6 +97,10 @@ def __init__(self, embedder=None, logger=None): # Validation tools list to retrieve validation tools separately self.validation_tools: List[str] = [] + # Default tools + if default_tools: + self.discover_tools_from_entry_points("ros2_default_tools") + def register_tool(self, tool: ITool, solve_deps: bool = True): """Register a single tool instance.""" # Avoid duplicates diff --git a/src/vulcanai/tools/utils.py b/src/vulcanai/tools/utils.py new file mode 100644 index 0000000..d998ad7 --- /dev/null +++ b/src/vulcanai/tools/utils.py @@ -0,0 +1,225 @@ +# 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 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 = 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` is your Textual App instance. + 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.add_line(f"{tool_header_str} {string_name}: \"{input_string}\" does not exists") + 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 From ed2f76f1d8159179102d27e07dc4e554310f736b Mon Sep 17 00:00:00 2001 From: danipiza Date: Mon, 2 Feb 2026 08:19:30 +0100 Subject: [PATCH 10/24] [#23897] Applied revision Signed-off-by: danipiza --- src/vulcanai/tools/default_tools.py | 313 +++++++++++++++------------- 1 file changed, 166 insertions(+), 147 deletions(-) diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index e789718..5fbc8e7 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -19,17 +19,23 @@ """ from vulcanai import AtomicTool, vulcanai_tool -from vulcanai.tools.utils import execute_subprocess, run_oneshot_cmd +from vulcanai.tools.utils import execute_subprocess, run_oneshot_cmd, suggest_string import importlib import json -import rclpy -import subprocess # TODO. danip -from std_msgs.msg import String import time +# ROS2 imports +try: + import rclpy + from std_msgs.msg import String + ROS2_AVAILABLE = True +except ImportError: + ROS2_AVAILABLE = False + rclpy = None + String = None -"""topics = topic_name_list.splitlines() +"""topics = topic_name_list_str.splitlines() # TODO. in all commands # Will be updated in the TUI Migration PR. @@ -127,10 +133,10 @@ class Ros2NodeTool(AtomicTool): } def run(self, **kwargs): - # Get the shared ROS2 node from the blackboard - node = self.bb.get("main_node", None) - if node is None: - raise Exception("Could not find shared node, aborting...") + # Used in the suggestion string + console = self.bb.get("console", None) + if console is None: + raise Exception("Could not find console, aborting...") # Get the node name if provided by the query node_name = kwargs.get("node_name", None) @@ -140,15 +146,24 @@ def run(self, **kwargs): "output": "", } + # -- Node name suggestions -- + node_name_list_str = run_oneshot_cmd(["ros2", "node", "list"]) + node_name_list = node_name_list_str.splitlines() + # -- Run `ros2 node list` --------------------------------------------- if node_name == None: - node_name_list = run_oneshot_cmd(["ros2", "node", "list"]) - result["output"] = node_name_list + result["output"] = node_name_list_str # -- Run `ros2 node info ` -------------------------------------- else: - if not node_name: - raise ValueError("`command='info'` requires `node_name`.") + # Check if the topic is not available ros2 topic list + # if it is not create a window for the user to choose a correct topic name + suggested_topic = suggest_string(console, self.name, "Topic", topic_name, node_name_list) + if suggested_topic != None: + topic_name = suggested_topic + + if not topic_name: + raise ValueError("`command='{}'` requires `node_name`.".format("info")) info_output = run_oneshot_cmd( ["ros2", "node", "info", node_name] @@ -183,11 +198,7 @@ class Ros2TopicTool(AtomicTool): } def run(self, **kwargs): - # Get the shared ROS2 node from the blackboard - node = self.bb.get("main_node", None) - if node is None: - raise Exception("Could not find shared node, aborting...") - + # Used in the suggestion string console = self.bb.get("console", None) if console is None: raise Exception("Could not find console, aborting...") @@ -206,27 +217,33 @@ def run(self, **kwargs): command = command.lower() - topic_name_list = run_oneshot_cmd(["ros2", "topic", "list"]) + topic_name_list_str = run_oneshot_cmd(["ros2", "topic", "list"]) + topic_name_list = topic_name_list_str.splitlines() + + # -- Topic name suggestions -- + if command == "find": + # TODO? + """suggested_type = suggest_string(console, self.name, "Topic", msg_type, topic_name_list) + if suggested_type != None: + msg_type = suggested_type""" + elif command != "list": + # Check if the topic is not available ros2 topic list + # if it is not create a window for the user to choose a correct topic name + suggested_topic_name = suggest_string(console, self.name, "Topic", topic_name, topic_name_list) + if suggested_topic_name != None: + topic_name = suggested_topic_name + + # Check if the topic_name is null (suggest_string() failed) + if not topic_name: + raise ValueError("`command='{}'` requires `topic_name`.".format(command)) + # -- Commands -- # -- ros2 topic list -------------------------------------------------- if command == "list": - result["output"] = topic_name_list + result["output"] = topic_name_list_str # -- ros2 topic info ------------------------------------- - elif command == "info": - if not topic_name: - raise ValueError("`command='info'` requires `topic_name`.") - - """topics = topic_name_list.splitlines() - - # TODO. Will be updated in the TUI Migration PR. - # The PR adds a modalscreen to select the most similar string), - # this applies to all ros cli commands. Though, not implemented - # in the rest commands from this PR - if topic_name not in topics: - topic_similar = search_similar(topics, topic_name) - topic_name = topic_similar""" - + if command == "info": info_output = run_oneshot_cmd( ["ros2", "topic", "info", topic_name] ) @@ -234,9 +251,6 @@ def run(self, **kwargs): # -- ros2 topic find ------------------------------------------- elif command == "find": - if not msg_type: - raise ValueError("`command='find'` requires `msg_type` (ROS type).") - find_output = run_oneshot_cmd( ["ros2", "topic", "find", msg_type] ) @@ -247,9 +261,6 @@ def run(self, **kwargs): # -- ros2 topic type ------------------------------------- elif command == "type": - if not topic_name: - raise ValueError("`command='type'` requires `topic_name`.") - type_output = run_oneshot_cmd( ["ros2", "topic", "type", topic_name] ) @@ -257,9 +268,6 @@ def run(self, **kwargs): # -- ros2 topic echo ------------------------------------- elif command == "echo": - if not topic_name: - raise ValueError("`command='echo'` requires `topic_name`.") - base_args = ["ros2", "topic", "echo", topic_name] execute_subprocess(console, base_args, max_duration, max_lines) @@ -276,9 +284,6 @@ def run(self, **kwargs): # -- ros2 topic delay ------------------------------------ elif command == "delay": - if not topic_name: - raise ValueError("`command='delay'` requires `topic_name`.") - base_args = ["ros2", "topic", "delay", topic_name] execute_subprocess(console, base_args, max_duration, max_lines) @@ -287,9 +292,6 @@ def run(self, **kwargs): # -- ros2 topic hz --------------------------------------- elif command == "hz": - if not topic_name: - raise ValueError("`command='hz'` requires `topic_name`.") - base_args = ["ros2", "topic", "hz", topic_name] execute_subprocess(console, base_args, max_duration, max_lines) @@ -301,19 +303,10 @@ def run(self, **kwargs): # One-shot publish using `-1` # ros2 topic pub -1 "" # ros2 topic pub -1 /rosout2 std_msgs/msg/String "{data: 'Hello'}" - if not topic_name: - raise ValueError("`command='pub'` requires `topic_name`.") + if not msg_type: raise ValueError("`command='pub'` requires `msg_type`.") - """# only send 1 - pub_output = run_oneshot_cmd( - ["ros2", "topic", "pub", "-1", topic_name, msg_type] - ) - result["output"] = pub_output""" - - # TODO. expand publisher options? - base_args = ["ros2", "topic", "pub", topic_name, msg_type] execute_subprocess(console, base_args, max_duration, max_lines) @@ -358,11 +351,7 @@ class Ros2ServiceTool(AtomicTool): } def run(self, **kwargs): - # Get the shared ROS2 node from the blackboard - node = self.bb.get("main_node", None) - if node is None: - raise Exception("Could not find shared node, aborting...") - + # Used in the suggestion string console = self.bb.get("console", None) if console is None: raise Exception("Could not find console, aborting...") @@ -382,17 +371,33 @@ def run(self, **kwargs): command = command.lower() - service_name_list = run_oneshot_cmd(["ros2", "service", "list"]) + service_name_list_str = run_oneshot_cmd(["ros2", "service", "list"]) + service_name_list = service_name_list_str.splitlines() + + # -- Service name suggestions -- + if command == "find": + # TODO? + """suggested_type = suggest_string(console, self.name, "Service_Type", service_type, service_name_list) + if suggested_type != None: + service_type = suggested_type""" + + elif command != "list": + # Check if the topic is not available ros2 topic list + # if it is not create a window for the user to choose a correct topic name + suggested_service_name = suggest_string(console, self.name, "Service", service_name, service_name_list) + if suggested_service_name != None: + service_name = suggested_service_name + + # Check if the service_name is null (suggest_string() failed) + if not service_name: + raise ValueError("`command='{}'` requires `service_name`.".format(command)) # -- ros2 service list ------------------------------------------------ if command == "list": - result["output"] = service_name_list + result["output"] = service_name_list_str # -- ros2 service info --------------------------------- elif command == "info": - if not service_name: - raise ValueError("`command='info'` requires `service_name`.") - info_output = run_oneshot_cmd( ["ros2", "service", "info", service_name] ) @@ -400,9 +405,6 @@ def run(self, **kwargs): # -- ros2 service type --------------------------------- elif command == "type": - if not service_name: - raise ValueError("`command='type'` requires `service_name`.") - type_output = run_oneshot_cmd( ["ros2", "service", "type", service_name] ) @@ -410,9 +412,6 @@ def run(self, **kwargs): # -- ros2 service find ----------------------------------------- elif command == "find": - if not service_type: - raise ValueError("`command='find'` requires `service_type`.") - find_output = run_oneshot_cmd( ["ros2", "service", "find", service_type] ) @@ -420,8 +419,6 @@ def run(self, **kwargs): # -- ros2 service call service_name service_type ---------------------- elif command == "call": - if not service_name: - raise ValueError("`command='call'` requires `service_name`.") if call_args is None: raise ValueError("`command='call'` requires `args`.") @@ -439,9 +436,6 @@ def run(self, **kwargs): # -- ros2 service echo service_name ----------------------------------- elif command == "echo": - if not service_name: - raise ValueError("`command='echo'` requires `service_name`.") - base_args = ["ros2", "service", "echo", service_name] execute_subprocess(console, base_args, max_duration, max_lines) @@ -484,10 +478,10 @@ class Ros2ActionTool(AtomicTool): } def run(self, **kwargs): - # Get the shared ROS2 node from the blackboard - node = self.bb.get("main_node", None) - if node is None: - raise Exception("Could not find shared node, aborting...") + # Used in the suggestion string + console = self.bb.get("console", None) + if console is None: + raise Exception("Could not find console, aborting...") command = kwargs.get("command", None) action_name = kwargs.get("action_name", None) @@ -501,17 +495,26 @@ def run(self, **kwargs): command = command.lower() - action_name_list = run_oneshot_cmd(["ros2", "action", "list"]) + action_name_list_str = run_oneshot_cmd(["ros2", "action", "list"]) + action_name_list = action_name_list_str.splitlines() + + # -- Action name suggestions -- + if command != "list": + # Check if the topic is not available ros2 topic list + suggested_action_name = suggest_string(console, self.name, "Action", action_name, action_name_list) + if suggested_action_name != None: + action_name = suggested_action_name + + # Check if the action_name is null (suggest_string() failed) + if not action_name: + raise ValueError("`command='{}'` requires `action_name`.".format(command)) # -- ros2 action list ------------------------------------------------- if command == "list": - result["output"] = action_name_list + result["output"] = action_name_list_str # -- ros2 action info ----------------------------------- - elif command == "info": - if not action_name: - raise ValueError("`command='info'` requires `action_name`.") - + if command == "info": info_output = run_oneshot_cmd( ["ros2", "action", "info", action_name] ) @@ -519,9 +522,6 @@ def run(self, **kwargs): # -- ros2 action type ------------------------------------ elif command == "get": - if not node or not param: - raise ValueError("`command='get'` requires `node_name` and `param_name`.") - get_output = run_oneshot_cmd( - ["ros2", "param", "get", node, param] + ["ros2", "param", "get", node_name, param_name] ) result["output"] = get_output # -- ros2 param describe ------------------------------- elif command == "describe": - if not node or not param: - raise ValueError("`command='describe'` requires `node_name` and `param_name`.") - describe_output = run_oneshot_cmd( - ["ros2", "param", "describe", node, param] + ["ros2", "param", "describe", node_name, param_name] ) result["output"] = describe_output # -- ros2 param set ------------------------ elif command == "set": - if not node or not param: - raise ValueError("`command='set'` requires `node_name` and `param_name`.") if set_value is None: raise ValueError("`command='set'` requires `set_value`.") set_output = run_oneshot_cmd( - ["ros2", "param", "set", node, param, set_value] + ["ros2", "param", "set", node_name, param_name, set_value] ) result["output"] = set_output # -- ros2 param delete ---------------------------------- elif command == "delete": - if not node or not param: - raise ValueError("`command='delete'` requires `node_name` and `param_name`.") - delete_output = run_oneshot_cmd( - ["ros2", "param", "delete", node, param] + ["ros2", "param", "delete", node_name, param_name] ) result["output"] = delete_output # -- ros2 param dump [file_path] ------------------------------- elif command == "dump": - if not node: - raise ValueError("`command='dump'` requires `node_name`.") - # Two modes: # - If file_path given, write to file with --output-file # - Otherwise, capture YAML from stdout if file_path: dump_output = run_oneshot_cmd( - ["ros2", "param", "dump", node, "--output-file", file_path] + ["ros2", "param", "dump", node_name, "--output-file", file_path] ) # CLI usually prints a line like "Saved parameters to file..." # so we just expose that. result["output"] = dump_output or f"Dumped parameters to {file_path}" else: dump_output = run_oneshot_cmd( - ["ros2", "param", "dump", node] + ["ros2", "param", "dump", node_name] ) result["output"] = dump_output # -- ros2 param load ------------------------------- elif command == "load": - if not node or not file_path: - raise ValueError("`command='load'` requires `node_name` and `file_path`.") + if not file_path: + raise ValueError("`command='load'` `file_path`.") load_output = run_oneshot_cmd( - ["ros2", "param", "load", node, file_path] + ["ros2", "param", "load", node_name, file_path] ) result["output"] = load_output @@ -718,11 +722,6 @@ class Ros2PkgTool(AtomicTool): } def run(self, **kwargs): - # Get the shared ROS2 node from the blackboard - node = self.bb.get("main_node", None) - if node is None: - raise Exception("Could not find shared node, aborting...") - # Get the package name if provided by the query command = kwargs.get("command", None) result = { @@ -776,10 +775,10 @@ class Ros2InterfaceTool(AtomicTool): } def run(self, **kwargs): - # Get the shared ROS2 node from the blackboard - node = self.bb.get("main_node", None) - if node is None: - raise Exception("Could not find shared node, aborting...") + # Used in the suggestion string + console = self.bb.get("console", None) + if console is None: + raise Exception("Could not find console, aborting...") # Get the interface name if provided by the query command = kwargs.get("command", None) @@ -792,10 +791,24 @@ def run(self, **kwargs): command = command.lower() + interface_name_list_str = run_oneshot_cmd(["ros2", "interface", "list"]) + interface_name_list = interface_name_list_str.splitlines() + + # -- Interface name suggestions -- + if command in ["package", "show"]: + # Check if the topic is not available ros2 topic list + # if it is not create a window for the user to choose a correct topic name + suggested_interface_name = suggest_string(console, self.name, "Interface", interface_name, interface_name_list) + if suggested_interface_name != None: + interface_name = suggested_interface_name + + # Check if the interface_name is null (suggest_string() failed) + if not interface_name: + raise ValueError("`command='{}'` requires `interface_name`.".format(command)) + # -- ros2 interface list ---------------------------------------------- if interface_name is None: - interface_name_list = run_oneshot_cmd(["ros2", "interface", "list"]) - result["output"] = interface_name_list + result["output"] = interface_name_list_str # -- ros2 interface packages ------------------------------------------ elif command == "packages": @@ -905,7 +918,10 @@ def msg_from_dict(self, msg, values: dict): setattr(msg, field, value) def run(self, **kwargs): + if not ROS2_AVAILABLE: + return + # 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...") @@ -1032,7 +1048,10 @@ def msg_to_dict(self, msg): def run(self, **kwargs): + if not ROS2_AVAILABLE: + return + # 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...") From 429fbd701ff3a065ee22243d07a67621425a70d3 Mon Sep 17 00:00:00 2001 From: danipiza Date: Mon, 2 Feb 2026 07:55:34 +0100 Subject: [PATCH 11/24] [#23897] Added missing previous commit changes Signed-off-by: danipiza --- src/vulcanai/tools/default_tools.py | 16 ++++++++-------- src/vulcanai/tools/tool_registry.py | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index 5fbc8e7..d222591 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -243,7 +243,7 @@ def run(self, **kwargs): result["output"] = topic_name_list_str # -- ros2 topic info ------------------------------------- - if command == "info": + elif command == "info": info_output = run_oneshot_cmd( ["ros2", "topic", "info", topic_name] ) @@ -269,7 +269,7 @@ def run(self, **kwargs): # -- ros2 topic echo ------------------------------------- elif command == "echo": base_args = ["ros2", "topic", "echo", topic_name] - execute_subprocess(console, base_args, max_duration, max_lines) + execute_subprocess(console, self.name, base_args, max_duration, max_lines) result["output"] = "True" result["ros2"] = True @@ -277,7 +277,7 @@ def run(self, **kwargs): # -- ros2 topic bw --------------------------------------- elif command == "bw": base_args = ["ros2", "topic", "bw", topic_name] - execute_subprocess(console, base_args, max_duration, max_lines) + execute_subprocess(console, self.name, base_args, max_duration, max_lines) result["output"] = "True" result["ros2"] = True @@ -285,7 +285,7 @@ def run(self, **kwargs): # -- ros2 topic delay ------------------------------------ elif command == "delay": base_args = ["ros2", "topic", "delay", topic_name] - execute_subprocess(console, base_args, max_duration, max_lines) + execute_subprocess(console, self.name, base_args, max_duration, max_lines) result["output"] = "True" result["ros2"] = True @@ -293,7 +293,7 @@ def run(self, **kwargs): # -- ros2 topic hz --------------------------------------- elif command == "hz": base_args = ["ros2", "topic", "hz", topic_name] - execute_subprocess(console, base_args, max_duration, max_lines) + execute_subprocess(console, self.name, base_args, max_duration, max_lines) result["output"] = "True" result["ros2"] = True @@ -308,7 +308,7 @@ def run(self, **kwargs): raise ValueError("`command='pub'` requires `msg_type`.") base_args = ["ros2", "topic", "pub", topic_name, msg_type] - execute_subprocess(console, base_args, max_duration, max_lines) + execute_subprocess(console, self.name, base_args, max_duration, max_lines) result["output"] = "True" result["ros2"] = True @@ -437,7 +437,7 @@ def run(self, **kwargs): # -- ros2 service echo service_name ----------------------------------- elif command == "echo": base_args = ["ros2", "service", "echo", service_name] - execute_subprocess(console, base_args, max_duration, max_lines) + execute_subprocess(console, self.name, base_args, max_duration, max_lines) result["output"] = "True" result["ros2"] = True @@ -514,7 +514,7 @@ def run(self, **kwargs): result["output"] = action_name_list_str # -- ros2 action info ----------------------------------- - if command == "info": + elif command == "info": info_output = run_oneshot_cmd( ["ros2", "action", "info", action_name] ) diff --git a/src/vulcanai/tools/tool_registry.py b/src/vulcanai/tools/tool_registry.py index 25cc245..98aae59 100644 --- a/src/vulcanai/tools/tool_registry.py +++ b/src/vulcanai/tools/tool_registry.py @@ -99,7 +99,7 @@ def __init__(self, embedder=None, logger=None, default_tools=True): # Default tools if default_tools: - self.discover_tools_from_entry_points("ros2_default_tools") + self.discover_tools_from_entry_points("vulcanai.tools.default_tools") def register_tool(self, tool: ITool, solve_deps: bool = True): """Register a single tool instance.""" From 508c41db69f507a4cb5c3befeb4941f086b66d2c Mon Sep 17 00:00:00 2001 From: danipiza Date: Wed, 4 Feb 2026 11:30:57 +0100 Subject: [PATCH 12/24] [#23897] Applied revision Signed-off-by: danipiza --- src/vulcanai/console/console.py | 4 +-- .../console/widget_custom_log_text_area.py | 14 ++++++-- src/vulcanai/core/manager_iterator.py | 4 +-- src/vulcanai/core/manager_plan.py | 4 +-- src/vulcanai/tools/default_tools.py | 35 +++---------------- src/vulcanai/tools/tool_registry.py | 6 +++- 6 files changed, 26 insertions(+), 41 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index 0bfde6c..208791b 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -138,7 +138,7 @@ 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 @@ -1103,7 +1103,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, _default_tools=self.default_tools) + 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 diff --git a/src/vulcanai/console/widget_custom_log_text_area.py b/src/vulcanai/console/widget_custom_log_text_area.py index dd60f4c..79bf21d 100644 --- a/src/vulcanai/console/widget_custom_log_text_area.py +++ b/src/vulcanai/console/widget_custom_log_text_area.py @@ -21,6 +21,8 @@ from rich.style import Style from textual.widgets import TextArea +from vulcanai.console.logger import VulcanAILogger + class CustomLogTextArea(TextArea): """ @@ -374,6 +376,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}") + return + diff --git a/src/vulcanai/core/manager_iterator.py b/src/vulcanai/core/manager_iterator.py index 868ba97..7f94a01 100644 --- a/src/vulcanai/core/manager_iterator.py +++ b/src/vulcanai/core/manager_iterator.py @@ -43,9 +43,9 @@ def __init__( logger=None, max_iters: int = 5, step_timeout_ms: Optional[int] = None, - _default_tools: bool = True + default_tools: bool = True ): - super().__init__(model, registry, validator, k, max(3, hist_depth), logger, _default_tools) + super().__init__(model, registry, validator, k, max(3, hist_depth), logger, default_tools) self.iter: int = 0 self.max_iters: int = int(max_iters) diff --git a/src/vulcanai/core/manager_plan.py b/src/vulcanai/core/manager_plan.py index 9b56bdd..637d2fd 100644 --- a/src/vulcanai/core/manager_plan.py +++ b/src/vulcanai/core/manager_plan.py @@ -30,9 +30,9 @@ def __init__( k: int = 5, hist_depth: int = 3, logger=None, - _default_tools=True + default_tools=True ): - super().__init__(model, registry=registry, validator=validator, k=k, hist_depth=hist_depth, logger=logger, default_tools=_default_tools) + 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: """ diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index d222591..a1864ae 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -29,11 +29,10 @@ try: import rclpy from std_msgs.msg import String - ROS2_AVAILABLE = True except ImportError: - ROS2_AVAILABLE = False - rclpy = None - String = None + raise ImportError( + "Unable to load default tools because no ROS 2 installation was found." + ) """topics = topic_name_list_str.splitlines() @@ -128,7 +127,6 @@ class Ros2NodeTool(AtomicTool): ] output_schema = { - "ros2": "bool", # ros2 flag for pretty printing. "output": "string", # list of ros2 nodes or info of a node. } @@ -142,7 +140,6 @@ def run(self, **kwargs): node_name = kwargs.get("node_name", None) result = { - "ros2": True, "output": "", } @@ -193,7 +190,6 @@ class Ros2TopicTool(AtomicTool): ] output_schema = { - "ros2": "bool", # ros2 flag for pretty printing "output": "string", # output } @@ -211,7 +207,6 @@ def run(self, **kwargs): max_lines = kwargs.get("max_lines", 1000) result = { - "ros2": True, "output": "", } @@ -272,7 +267,6 @@ def run(self, **kwargs): execute_subprocess(console, self.name, base_args, max_duration, max_lines) result["output"] = "True" - result["ros2"] = True # -- ros2 topic bw --------------------------------------- elif command == "bw": @@ -280,7 +274,6 @@ def run(self, **kwargs): execute_subprocess(console, self.name, base_args, max_duration, max_lines) result["output"] = "True" - result["ros2"] = True # -- ros2 topic delay ------------------------------------ elif command == "delay": @@ -288,7 +281,6 @@ def run(self, **kwargs): execute_subprocess(console, self.name, base_args, max_duration, max_lines) result["output"] = "True" - result["ros2"] = True # -- ros2 topic hz --------------------------------------- elif command == "hz": @@ -296,7 +288,6 @@ def run(self, **kwargs): execute_subprocess(console, self.name, base_args, max_duration, max_lines) result["output"] = "True" - result["ros2"] = True # -- publisher -------------------------------------------------------- elif command == "pub": @@ -311,7 +302,6 @@ def run(self, **kwargs): execute_subprocess(console, self.name, base_args, max_duration, max_lines) result["output"] = "True" - result["ros2"] = True # -- unknown ---------------------------------------------------------- else: @@ -346,7 +336,6 @@ class Ros2ServiceTool(AtomicTool): ] output_schema = { - "ros2": "bool", # ros2 flag for pretty printing "output": "string", # `ros2 service list` } @@ -365,7 +354,6 @@ def run(self, **kwargs): max_lines = kwargs.get("max_lines", 50) result = { - "ros2": True, "output": "", } @@ -440,7 +428,6 @@ def run(self, **kwargs): execute_subprocess(console, self.name, base_args, max_duration, max_lines) result["output"] = "True" - result["ros2"] = True # -- unknown ------------------------------------------------------------ else: @@ -473,7 +460,6 @@ class Ros2ActionTool(AtomicTool): ] output_schema = { - "ros2": "bool", # ros2 flag for pretty printing "output": "string", # `ros2 action list` } @@ -489,7 +475,6 @@ def run(self, **kwargs): goal_args = kwargs.get("goal_args", None) result = { - "ros2": True, "output": "", } @@ -574,7 +559,6 @@ class Ros2ParamTool(AtomicTool): ] output_schema = { - "ros2": "bool", # ros2 flag for pretty printing "output": "string", } @@ -591,7 +575,6 @@ def run(self, **kwargs): file_path = kwargs.get("file_path", None) result = { - "ros2": True, "output": "", } @@ -717,7 +700,6 @@ class Ros2PkgTool(AtomicTool): ] output_schema = { - "ros2": "bool", # ros2 flag for pretty printing. "output": "string", # list of packages or list of executables for a package. } @@ -725,7 +707,6 @@ def run(self, **kwargs): # Get the package name if provided by the query command = kwargs.get("command", None) result = { - "ros2": True, "output": "", } @@ -770,7 +751,6 @@ class Ros2InterfaceTool(AtomicTool): ] output_schema = { - "ros2": "bool", # ros2 flag for pretty printing. "output": "string", # list of interfaces (as list of strings) or full interface definition. } @@ -785,7 +765,6 @@ def run(self, **kwargs): interface_name = kwargs.get("interface_name", None) result = { - "ros2": True, "output": "", } @@ -918,9 +897,6 @@ def msg_from_dict(self, msg, values: dict): setattr(msg, field, value) def run(self, **kwargs): - if not ROS2_AVAILABLE: - return - # Ros2 node to create the Publisher and print the log information node = self.bb.get("main_node", None) if node is None: @@ -1048,9 +1024,6 @@ def msg_to_dict(self, msg): def run(self, **kwargs): - if not ROS2_AVAILABLE: - return - # Ros2 node to create the Publisher and print the log information node = self.bb.get("main_node", None) if node is None: @@ -1084,7 +1057,7 @@ def callback(msg: String): else: d = self.msg_to_dict(msg) received_msgs.append(d["data"] if "data" in d else d) - node.get_logger().info(f"I heard: [{d["data"]}]") + node.get_logger().info(f"I heard: [{d['data']}]") MsgType = import_msg_type(msg_type_str, node) sub = node.create_subscription(MsgType, topic, callback, qos_depth) diff --git a/src/vulcanai/tools/tool_registry.py b/src/vulcanai/tools/tool_registry.py index 98aae59..2ea5743 100644 --- a/src/vulcanai/tools/tool_registry.py +++ b/src/vulcanai/tools/tool_registry.py @@ -99,7 +99,11 @@ def __init__(self, embedder=None, logger=None, default_tools=True): # Default tools if default_tools: - self.discover_tools_from_entry_points("vulcanai.tools.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): """Register a single tool instance.""" From 0139c3344ebb4c3c43b1132c23c4243a7038111d Mon Sep 17 00:00:00 2001 From: danipiza Date: Tue, 10 Feb 2026 16:11:38 +0100 Subject: [PATCH 13/24] [#23897] Applied Ruff + notify error in textual pop-up Signed-off-by: danipiza --- src/vulcanai/console/console.py | 14 +- src/vulcanai/console/utils.py | 9 +- .../console/widget_custom_log_text_area.py | 2 +- src/vulcanai/core/manager.py | 2 +- src/vulcanai/core/manager_iterator.py | 2 +- src/vulcanai/core/manager_plan.py | 12 +- src/vulcanai/tools/default_tools.py | 278 +++++++----------- 7 files changed, 121 insertions(+), 198 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index 208791b..bbfa76b 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -173,16 +173,6 @@ def __init__( self.suggestion_index = -1 self.suggestion_index_changed = threading.Event() - - 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 - task by using the signal "Ctrl + C" - """ - - self.stream_task = input_stream - async def on_mouse_down(self, event: MouseEvent) -> None: """ Function used to paste the string for the user clipboard @@ -357,10 +347,10 @@ def worker(user_input: str = "") -> None: self.logger.log_console(f"Output of plan: {bb_ret}") except KeyboardInterrupt: - if self.stream_task == None: + if self.stream_task is None: self.logger.log_msg("Exiting...") else: - self.stream_task.cancel() # triggers CancelledError in the task + self.stream_task.cancel() # triggers CancelledError in the task self.stream_task = None except EOFError: self.logger.log_msg("Exiting...") diff --git a/src/vulcanai/console/utils.py b/src/vulcanai/console/utils.py index faa4d80..cc74544 100644 --- a/src/vulcanai/console/utils.py +++ b/src/vulcanai/console/utils.py @@ -62,15 +62,7 @@ def on_request_start(self, text="Querying LLM..."): def on_request_end(self): self.spinner_status.stop() -<<<<<<< HEAD -<<<<<<< HEAD def attach_ros_logger_to_console(console): -======= - -======= ->>>>>>> c235ed6 ([#23897] Applied revision) -def attach_ros_logger_to_console(console, node): ->>>>>>> dc8f6d1 ([#23897] Fixed rebase changes) """ Redirect ALL rclpy RcutilsLogger output (nodes + executor + rclpy internals) to a Textual console. @@ -129,6 +121,7 @@ def patched_log(self, msg, level, *args, **kwargs): RcutilsLogger.log = patched_log RcutilsLogger._textual_patched = True + def common_prefix(strings: str) -> str: if not strings: return "" diff --git a/src/vulcanai/console/widget_custom_log_text_area.py b/src/vulcanai/console/widget_custom_log_text_area.py index 79bf21d..f8fdb53 100644 --- a/src/vulcanai/console/widget_custom_log_text_area.py +++ b/src/vulcanai/console/widget_custom_log_text_area.py @@ -383,5 +383,5 @@ def action_copy_selection(self) -> None: except Exception as e: error_color = VulcanAILogger.vulcanai_theme["error"] self.append_line(f"<{error_color}>Clipboard error: {e}") + self.notify(f"Clipboard error: {e}") return - diff --git a/src/vulcanai/core/manager.py b/src/vulcanai/core/manager.py index c15cbe7..43988e3 100644 --- a/src/vulcanai/core/manager.py +++ b/src/vulcanai/core/manager.py @@ -33,7 +33,7 @@ def __init__( k: int = 10, hist_depth: int = 3, logger: Optional[VulcanAILogger] = None, - default_tools: bool = True + default_tools: bool = True, ): # Logger default to a stdout logger if none is provided (StdoutLogSink) self.logger = logger or VulcanAILogger.default() diff --git a/src/vulcanai/core/manager_iterator.py b/src/vulcanai/core/manager_iterator.py index 7f94a01..e940b6d 100644 --- a/src/vulcanai/core/manager_iterator.py +++ b/src/vulcanai/core/manager_iterator.py @@ -43,7 +43,7 @@ def __init__( logger=None, max_iters: int = 5, step_timeout_ms: Optional[int] = None, - default_tools: bool = True + default_tools: bool = True, ): super().__init__(model, registry, validator, k, max(3, hist_depth), logger, default_tools) diff --git a/src/vulcanai/core/manager_plan.py b/src/vulcanai/core/manager_plan.py index 637d2fd..32bfbdc 100644 --- a/src/vulcanai/core/manager_plan.py +++ b/src/vulcanai/core/manager_plan.py @@ -30,9 +30,17 @@ def __init__( k: int = 5, hist_depth: int = 3, logger=None, - default_tools=True + default_tools=True, ): - super().__init__(model, registry=registry, validator=validator, k=k, hist_depth=hist_depth, logger=logger, default_tools=default_tools) + 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: """ diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index a1864ae..a2c7646 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -18,21 +18,19 @@ It contains atomic tools used to call ROS2 CLI. """ -from vulcanai import AtomicTool, vulcanai_tool -from vulcanai.tools.utils import execute_subprocess, run_oneshot_cmd, suggest_string - import importlib import json import time +from vulcanai import AtomicTool, vulcanai_tool +from vulcanai.tools.utils import execute_subprocess, run_oneshot_cmd, suggest_string + # ROS2 imports try: import rclpy from std_msgs.msg import String except ImportError: - raise ImportError( - "Unable to load default tools because no ROS 2 installation was found." - ) + raise ImportError("Unable to load default tools because no ROS 2 installation was found.") """topics = topic_name_list_str.splitlines() @@ -121,13 +119,13 @@ class Ros2NodeTool(AtomicTool): tags = ["ros2", "nodes", "cli", "info", "diagnostics"] input_schema = [ - ("node_name", "string?") # (optional) Name of the ros2 node. - # if the node is not provided the command is `ros2 node list`. - # otherwise `ros2 node info ` + ("node_name", "string?") # (optional) Name of the ros2 node. + # if the node is not provided the command is `ros2 node list`. + # otherwise `ros2 node info ` ] output_schema = { - "output": "string", # list of ros2 nodes or info of a node. + "output": "string", # list of ros2 nodes or info of a node. } def run(self, **kwargs): @@ -148,23 +146,21 @@ def run(self, **kwargs): node_name_list = node_name_list_str.splitlines() # -- Run `ros2 node list` --------------------------------------------- - if node_name == None: + if node_name is None: result["output"] = node_name_list_str # -- Run `ros2 node info ` -------------------------------------- else: # Check if the topic is not available ros2 topic list # if it is not create a window for the user to choose a correct topic name - suggested_topic = suggest_string(console, self.name, "Topic", topic_name, node_name_list) - if suggested_topic != None: - topic_name = suggested_topic + suggested_topic = suggest_string(console, self.name, "Topic", node_name, node_name_list) + if suggested_topic is not None: + node_name = suggested_topic - if not topic_name: + if not node_name: raise ValueError("`command='{}'` requires `node_name`.".format("info")) - info_output = run_oneshot_cmd( - ["ros2", "node", "info", node_name] - ) + info_output = run_oneshot_cmd(["ros2", "node", "info", node_name]) result["output"] = info_output return result @@ -182,15 +178,15 @@ class Ros2TopicTool(AtomicTool): # - `command` lets you pick a single subcommand (echo/bw/hz/delay/find/pub/type). input_schema = [ - ("command", "string"), # Command - ("topic_name", "string?"), # (optional) Topic name. (info/echo/bw/delay/hz/type/pub) - ("msg_type", "string?"), # (optional) Message type (`find` , `pub` ) - ("max_duration", "number?"), # (optional) Seconds for streaming commands (echo/bw/hz/delay) - ("max_lines", "int?"), # (optional) Cap number of lines for streaming commands + ("command", "string"), # Command + ("topic_name", "string?"), # (optional) Topic name. (info/echo/bw/delay/hz/type/pub) + ("msg_type", "string?"), # (optional) Message type (`find` , `pub` ) + ("max_duration", "number?"), # (optional) Seconds for streaming commands (echo/bw/hz/delay) + ("max_lines", "int?"), # (optional) Cap number of lines for streaming commands ] output_schema = { - "output": "string", # output + "output": "string", # output } def run(self, **kwargs): @@ -219,13 +215,13 @@ def run(self, **kwargs): if command == "find": # TODO? """suggested_type = suggest_string(console, self.name, "Topic", msg_type, topic_name_list) - if suggested_type != None: + if suggested_type is not None: msg_type = suggested_type""" elif command != "list": # Check if the topic is not available ros2 topic list # if it is not create a window for the user to choose a correct topic name suggested_topic_name = suggest_string(console, self.name, "Topic", topic_name, topic_name_list) - if suggested_topic_name != None: + if suggested_topic_name is not None: topic_name = suggested_topic_name # Check if the topic_name is null (suggest_string() failed) @@ -239,26 +235,18 @@ def run(self, **kwargs): # -- ros2 topic info ------------------------------------- elif command == "info": - info_output = run_oneshot_cmd( - ["ros2", "topic", "info", topic_name] - ) + info_output = run_oneshot_cmd(["ros2", "topic", "info", topic_name]) result["output"] = info_output # -- ros2 topic find ------------------------------------------- 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) + 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) # -- ros2 topic type ------------------------------------- elif command == "type": - type_output = run_oneshot_cmd( - ["ros2", "topic", "type", topic_name] - ) + type_output = run_oneshot_cmd(["ros2", "topic", "type", topic_name]) result["output"] = type_output # -- ros2 topic echo ------------------------------------- @@ -306,8 +294,7 @@ def run(self, **kwargs): # -- unknown ---------------------------------------------------------- else: raise ValueError( - f"Unknown command '{command}'. " - "Expected one of: list, info, echo, bw, delay, hz, find, pub, type." + f"Unknown command '{command}'. Expected one of: list, info, echo, bw, delay, hz, find, pub, type." ) return result @@ -326,17 +313,17 @@ class Ros2ServiceTool(AtomicTool): # - `command` = "list", "info", "type", "call", "echo", "find" input_schema = [ - ("command", "string"), # Command - ("service_name", "string?"), # (optional) Service name. "info", "type", "call", "echo" - ("service_type", "string?"), # (optional) Service type. "find", "call" - ("call", "bool?"), # (optional) backwards-compatible call flag - ("args", "string?"), # (optional) YAML/JSON-like request data for `call` - ("max_duration", "number?"), # (optional) Maximum duration - ("max_lines", "int?"), # (optional) Maximum lines + ("command", "string"), # Command + ("service_name", "string?"), # (optional) Service name. "info", "type", "call", "echo" + ("service_type", "string?"), # (optional) Service type. "find", "call" + ("call", "bool?"), # (optional) backwards-compatible call flag + ("args", "string?"), # (optional) YAML/JSON-like request data for `call` + ("max_duration", "number?"), # (optional) Maximum duration + ("max_lines", "int?"), # (optional) Maximum lines ] output_schema = { - "output": "string", # `ros2 service list` + "output": "string", # `ros2 service list` } def run(self, **kwargs): @@ -350,7 +337,7 @@ def run(self, **kwargs): service_type = kwargs.get("service_type", None) call_args = kwargs.get("args", None) # Streaming commands variables - max_duration = kwargs.get("max_duration", 2.0) # default for echo + max_duration = kwargs.get("max_duration", 2.0) # default for echo max_lines = kwargs.get("max_lines", 50) result = { @@ -366,14 +353,14 @@ def run(self, **kwargs): if command == "find": # TODO? """suggested_type = suggest_string(console, self.name, "Service_Type", service_type, service_name_list) - if suggested_type != None: + if suggested_type is not None: service_type = suggested_type""" elif command != "list": # Check if the topic is not available ros2 topic list # if it is not create a window for the user to choose a correct topic name suggested_service_name = suggest_string(console, self.name, "Service", service_name, service_name_list) - if suggested_service_name != None: + if suggested_service_name is not None: service_name = suggested_service_name # Check if the service_name is null (suggest_string() failed) @@ -386,23 +373,17 @@ def run(self, **kwargs): # -- ros2 service info --------------------------------- elif command == "info": - info_output = run_oneshot_cmd( - ["ros2", "service", "info", service_name] - ) + info_output = run_oneshot_cmd(["ros2", "service", "info", service_name]) result["output"] = info_output # -- ros2 service type --------------------------------- elif command == "type": - type_output = run_oneshot_cmd( - ["ros2", "service", "type", service_name] - ) + type_output = run_oneshot_cmd(["ros2", "service", "type", service_name]) result["output"] = type_output.strip() # -- ros2 service find ----------------------------------------- elif command == "find": - find_output = run_oneshot_cmd( - ["ros2", "service", "find", service_type] - ) + find_output = run_oneshot_cmd(["ros2", "service", "find", service_type]) result["output"] = find_output # -- ros2 service call service_name service_type ---------------------- @@ -412,14 +393,10 @@ def run(self, **kwargs): # If service_type not given, detect it if not service_type: - type_output = run_oneshot_cmd( - ["ros2", "service", "type", service_name] - ) + type_output = run_oneshot_cmd(["ros2", "service", "type", service_name]) service_type = type_output.strip() - call_output = run_oneshot_cmd( - ["ros2", "service", "call", service_name, service_type, call_args] - ) + call_output = run_oneshot_cmd(["ros2", "service", "call", service_name, service_type, call_args]) result["output"] = call_output # -- ros2 service echo service_name ----------------------------------- @@ -431,10 +408,7 @@ def run(self, **kwargs): # -- unknown ------------------------------------------------------------ else: - raise ValueError( - f"Unknown command '{command}'. " - "Expected one of: list, info, type, call, echo, find." - ) + raise ValueError(f"Unknown command '{command}'. Expected one of: list, info, type, call, echo, find.") return result @@ -452,11 +426,11 @@ class Ros2ActionTool(AtomicTool): # - `command`: "list", "info", "type", "send_goal" input_schema = [ - ("command", "string"), # Command - ("action_name", "string?"), # (optional) Action name - ("action_type", "string?"), # (optional) Action type. "find" - ("send_goal", "bool?"), # (optional) legacy flag (backwards compatible) - ("goal_args", "string?"), # (optional) goal YAML, e.g. '{order: 5}' + ("command", "string"), # Command + ("action_name", "string?"), # (optional) Action name + ("action_type", "string?"), # (optional) Action type. "find" + ("send_goal", "bool?"), # (optional) legacy flag (backwards compatible) + ("goal_args", "string?"), # (optional) goal YAML, e.g. '{order: 5}' ] output_schema = { @@ -487,7 +461,7 @@ def run(self, **kwargs): if command != "list": # Check if the topic is not available ros2 topic list suggested_action_name = suggest_string(console, self.name, "Action", action_name, action_name_list) - if suggested_action_name != None: + if suggested_action_name is not None: action_name = suggested_action_name # Check if the action_name is null (suggest_string() failed) @@ -500,25 +474,19 @@ def run(self, **kwargs): # -- ros2 action info ----------------------------------- elif command == "info": - info_output = run_oneshot_cmd( - ["ros2", "action", "info", action_name] - ) + info_output = run_oneshot_cmd(["ros2", "action", "info", action_name]) result["output"] = info_output # -- ros2 action type ------------------------------------ elif command == "get": - get_output = run_oneshot_cmd( - ["ros2", "param", "get", node_name, param_name] - ) + get_output = run_oneshot_cmd(["ros2", "param", "get", node_name, param_name]) result["output"] = get_output # -- ros2 param describe ------------------------------- elif command == "describe": - describe_output = run_oneshot_cmd( - ["ros2", "param", "describe", node_name, param_name] - ) + describe_output = run_oneshot_cmd(["ros2", "param", "describe", node_name, param_name]) result["output"] = describe_output # -- ros2 param set ------------------------ @@ -634,16 +594,12 @@ def run(self, **kwargs): if set_value is None: raise ValueError("`command='set'` requires `set_value`.") - set_output = run_oneshot_cmd( - ["ros2", "param", "set", node_name, param_name, set_value] - ) + set_output = run_oneshot_cmd(["ros2", "param", "set", node_name, param_name, set_value]) result["output"] = set_output # -- ros2 param delete ---------------------------------- elif command == "delete": - delete_output = run_oneshot_cmd( - ["ros2", "param", "delete", node_name, param_name] - ) + delete_output = run_oneshot_cmd(["ros2", "param", "delete", node_name, param_name]) result["output"] = delete_output # -- ros2 param dump [file_path] ------------------------------- @@ -652,16 +608,12 @@ def run(self, **kwargs): # - If file_path given, write to file with --output-file # - Otherwise, capture YAML from stdout if file_path: - dump_output = run_oneshot_cmd( - ["ros2", "param", "dump", node_name, "--output-file", file_path] - ) + dump_output = run_oneshot_cmd(["ros2", "param", "dump", node_name, "--output-file", file_path]) # CLI usually prints a line like "Saved parameters to file..." # so we just expose that. result["output"] = dump_output or f"Dumped parameters to {file_path}" else: - dump_output = run_oneshot_cmd( - ["ros2", "param", "dump", node_name] - ) + dump_output = run_oneshot_cmd(["ros2", "param", "dump", node_name]) result["output"] = dump_output # -- ros2 param load ------------------------------- @@ -669,16 +621,13 @@ def run(self, **kwargs): if not file_path: raise ValueError("`command='load'` `file_path`.") - load_output = run_oneshot_cmd( - ["ros2", "param", "load", node_name, file_path] - ) + load_output = run_oneshot_cmd(["ros2", "param", "load", node_name, file_path]) result["output"] = load_output # -- unknown ---------------------------------------------------------- else: raise ValueError( - f"Unknown command '{command}'. " - "Expected one of: list, get, describe, set, delete, dump, load." + f"Unknown command '{command}'. Expected one of: list, get, describe, set, delete, dump, load." ) return result @@ -687,10 +636,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2PkgTool(AtomicTool): name = "ros2_pkg" - description = ( - "Wrapper for `ros2 pkg` CLI." - "Run any subcommand: 'list', 'executables'." - ) + description = "Wrapper for `ros2 pkg` CLI.Run any subcommand: 'list', 'executables'." tags = ["ros2", "pkg", "packages", "cli", "introspection"] # If package_name is not provided, runs: `ros2 pkg list` @@ -700,7 +646,7 @@ class Ros2PkgTool(AtomicTool): ] output_schema = { - "output": "string", # list of packages or list of executables for a package. + "output": "string", # list of packages or list of executables for a package. } def run(self, **kwargs): @@ -724,10 +670,7 @@ def run(self, **kwargs): # -- unknown ---------------------------------------------------------- else: - raise ValueError( - f"Unknown command '{command}'. " - "Expected one of: list, executables, prefix, xml" - ) + raise ValueError(f"Unknown command '{command}'. Expected one of: list, executables, prefix, xml") return result @@ -744,14 +687,14 @@ class Ros2InterfaceTool(AtomicTool): # - `command` lets you pick a single subcommand (list/packages/package). input_schema = [ - ("command", "string"), # Command - ("interface_name", "string?"), # (optional) Name of the interface, e.g. "std_msgs/msg/String". - # If not provided, the command is `ros2 interface list`. - # Otherwise `ros2 interface show `. + ("command", "string"), # Command + ("interface_name", "string?"), # (optional) Name of the interface, e.g. "std_msgs/msg/String". + # If not provided, the command is `ros2 interface list`. + # Otherwise `ros2 interface show `. ] output_schema = { - "output": "string", # list of interfaces (as list of strings) or full interface definition. + "output": "string", # list of interfaces (as list of strings) or full interface definition. } def run(self, **kwargs): @@ -777,8 +720,10 @@ def run(self, **kwargs): if command in ["package", "show"]: # Check if the topic is not available ros2 topic list # if it is not create a window for the user to choose a correct topic name - suggested_interface_name = suggest_string(console, self.name, "Interface", interface_name, interface_name_list) - if suggested_interface_name != None: + suggested_interface_name = suggest_string( + console, self.name, "Interface", interface_name, interface_name_list + ) + if suggested_interface_name is not None: interface_name = suggested_interface_name # Check if the interface_name is null (suggest_string() failed) @@ -799,9 +744,7 @@ def run(self, **kwargs): if not interface_name: raise ValueError("`command='package'` requires `interface_name`.") - info_output = run_oneshot_cmd( - ["ros2", "topic", "package", interface_name] - ) + info_output = run_oneshot_cmd(["ros2", "topic", "package", interface_name]) result["output"] = info_output # -- ros2 interface show -------------------------------- @@ -809,16 +752,13 @@ def run(self, **kwargs): if not interface_name: raise ValueError("`command='package'` requires `interface_name`.") - info_output = run_oneshot_cmd( - ["ros2", "topic", "show", interface_name] - ) + info_output = run_oneshot_cmd(["ros2", "topic", "show", interface_name]) result["output"] = info_output # -- unknown ---------------------------------------------------------- else: raise ValueError( - f"Unknown command '{command}'. " - "Expected one of: list, info, echo, bw, delay, hz, find, pub, type." + f"Unknown command '{command}'. Expected one of: list, info, echo, bw, delay, hz, find, pub, type." ) return result @@ -837,8 +777,9 @@ def import_msg_type(type_str: str, node): 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.") + node.get_logger().warn( + f"Cannot import ROS message type '{type_str}'. " + "Adding default pkg 'std_msgs' instead." + ) else: pkg, _, msg_name = info_list @@ -857,25 +798,21 @@ class Ros2PublishTool(AtomicTool): "By default 10 messages 'Hello from VulcanAI PublishTool!' " "with type 'std_msgs/msg/String' in topic '/chatter' " "with 0.1 seconds of delay between messages to publish with a qos_depth of 10. " - "Example for custom type: msg_type='my_pkg/msg/MyMessage', message_data='{\"index\": 1, \"message\": \"Hello\"}'" + 'Example for custom type: msg_type=\'my_pkg/msg/MyMessage\', message_data=\'{"index": 1, "message": "Hello"}\'' ) tags = ["ros2", "publish", "message", "std_msgs"] input_schema = [ - ("topic", "string"), # e.g. "/chatter" - ("message_data", "string?"), # (optional) payload - string for std_msgs/String or JSON for custom types - ("msg_type", "string?"), # (optional) e.g. "std_msgs/msg/String" or "my_pkg/msg/CustomMsg" - ("count", "int?"), # (optional) number of messages to publish - ("period_sec", "float?"), # (optional) delay between publishes (in seconds) - ("qos_depth", "int?"), # (optional) publisher queue depth - ("message", "string?"), # (deprecated) use message_data instead + ("topic", "string"), # e.g. "/chatter" + ("message_data", "string?"), # (optional) payload - string for std_msgs/String or JSON for custom types + ("msg_type", "string?"), # (optional) e.g. "std_msgs/msg/String" or "my_pkg/msg/CustomMsg" + ("count", "int?"), # (optional) number of messages to publish + ("period_sec", "float?"), # (optional) delay between publishes (in seconds) + ("qos_depth", "int?"), # (optional) publisher queue depth + ("message", "string?"), # (deprecated) use message_data instead ] - output_schema = { - "published": "bool", - "count": "int", - "topic": "string" - } + output_schema = {"published": "bool", "count": "int", "topic": "string"} def msg_from_dict(self, msg, values: dict): """ @@ -910,7 +847,6 @@ def run(self, **kwargs): period_sec = kwargs.get("period_sec", 0.1) qos_depth = kwargs.get("qos_depth", 10.0) - if not topic: node.get_logger().error("No topic provided.") return {"published": False, "count": 0, "topic": topic} @@ -919,7 +855,6 @@ def run(self, **kwargs): node.get_logger().warn("Count <= 0, nothing to publish.") return {"published": True, "count": 0, "topic": topic} - MsgType = import_msg_type(msg_type_str, node) publisher = node.create_publisher(MsgType, topic, qos_depth) @@ -931,9 +866,12 @@ def run(self, **kwargs): """ E.G.: PlanNode 1: kind=SEQUENCE - Step 1: ros_publish(topic=/custom_topic, msg_type=my_pkg/msg/CustomMsg, message_data={"id":1,"text":"Custom message 1"}, count=1, period_sec=0.1, qos_depth=10) + Step 1: ros_publish(topic=/custom_topic, msg_type=my_pkg/msg/CustomMsg, + message_data={"id":1,"text":"Custom message 1"}, count=1, period_sec=0.1, qos_depth=10) - [EXECUTOR] Invoking 'ros_publish' with args:'{'topic': '/custom_topic', 'msg_type': 'my_pkg/msg/CustomMsg', 'message_data': '{"id":1,"text":"Custom message 1"}', 'count': '1', 'period_sec': '0.1', 'qos_depth': '10'}' + [EXECUTOR] Invoking 'ros_publish' with args:'{'topic': '/custom_topic', + 'msg_type': 'my_pkg/msg/CustomMsg', 'message_data': '{"id":1,"text":"Custom message 1"}', + 'count': '1', 'period_sec': '0.1', 'qos_depth': '10'}' [EXECUTOR] Execution failed for 'ros_publish': No module named 'my_pkg' [EXECUTOR] Step 'ros_publish' attempt 1/1 failed [EXECUTOR] [ERROR] PlanNode SEQUENCE failed on attempt 1/1 @@ -982,12 +920,12 @@ class Ros2SubscribeTool(AtomicTool): tags = ["ros2", "subscribe", "topic", "std_msgs"] input_schema = [ - ("topic", "string"), # topic name - ("msg_type", "string"), # e.g. "std_msgs/msg/String" - ("output_format", "string"), # "data" | "dict" - ("max_messages", "int?"), # (optional) stop after this number of messages - ("timeout_sec", "float?"), # (optional) stop after this seconds - ("qos_depth", "int?"), # (optional) subscription queue depth + ("topic", "string"), # topic name + ("msg_type", "string"), # e.g. "std_msgs/msg/String" + ("output_format", "string"), # "data" | "dict" + ("max_messages", "int?"), # (optional) stop after this number of messages + ("timeout_sec", "float?"), # (optional) stop after this seconds + ("qos_depth", "int?"), # (optional) subscription queue depth ] output_schema = { @@ -998,7 +936,6 @@ class Ros2SubscribeTool(AtomicTool): "topic": "string", } - def msg_to_dict(self, msg): """ Convert a ROS 2 message instance into a Python dictionary. @@ -1022,7 +959,6 @@ def msg_to_dict(self, msg): out[key] = val return out - def run(self, **kwargs): # Ros2 node to create the Publisher and print the log information node = self.bb.get("main_node", None) @@ -1036,7 +972,6 @@ def run(self, **kwargs): qos_depth = kwargs.get("qos_depth", 10.0) output_format = kwargs.get("output_format", "data") - if not topic: return {"success": False, "received": 0, "messages": [], "reason": "no_topic", "topic": topic} @@ -1090,6 +1025,3 @@ def callback(msg: String): "reason": reason, "topic": topic, } - - - From 02894893fca16a38b7876940aa38b54d6a689b88 Mon Sep 17 00:00:00 2001 From: danipiza Date: Tue, 10 Feb 2026 16:18:14 +0100 Subject: [PATCH 14/24] [#23897] Applied Ruff after rebase Signed-off-by: danipiza --- src/vulcanai/console/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vulcanai/console/utils.py b/src/vulcanai/console/utils.py index cc74544..f476f5b 100644 --- a/src/vulcanai/console/utils.py +++ b/src/vulcanai/console/utils.py @@ -148,6 +148,7 @@ def common_prefix(strings: str) -> str: 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: @@ -344,4 +345,3 @@ def _get_suggestions(real_string_list_comp: list[str], string_comp: str) -> tupl console.suggestion_index_changed.clear() return ret - From e9b65ddf3c0ce0cfa6cdffe334b4259425a96915 Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 19 Feb 2026 11:09:05 +0100 Subject: [PATCH 15/24] [#23897] Applied revision Signed-off-by: danipiza --- src/vulcanai/console/console.py | 86 +++++++++++++++++-- src/vulcanai/console/modal_screens.py | 7 +- src/vulcanai/console/utils.py | 77 ----------------- .../console/widget_custom_log_text_area.py | 14 ++- src/vulcanai/core/executor.py | 1 + src/vulcanai/tools/utils.py | 57 ++++++++---- 6 files changed, 134 insertions(+), 108 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index bbfa76b..fb462ba 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -74,6 +74,13 @@ class VulcanConsole(App): border: tall #333333; } + #streamcontent { + height: 0; + min-height: 0; + border: tall #56AA08; + display: none; + } + #llm_spinner { height: 0; display: none; @@ -143,7 +150,9 @@ def __init__( # Iterative mode self.iterative = iterative # CustomLogTextArea instance - self.left_pannel = None + self.main_pannel = None + # Subprocess output panel + self.stream_pannel = None # Logger instance self.logger = VulcanAILogger.default() self.logger.set_textualizer_console(TextualLogSink(self)) @@ -169,6 +178,8 @@ def __init__( # Streaming task control self.stream_task = None + # Route logger output to subprocess panel when needed. + self._route_logs_to_stream_panel = False # Suggestion index for RadioListModal self.suggestion_index = -1 self.suggestion_index_changed = threading.Event() @@ -191,7 +202,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) @@ -226,6 +238,9 @@ def compose(self) -> ComposeResult: with Horizontal(): # Left with Vertical(id="left"): + # Subprocess stream area (hidden by default, shown on-demand) + streamcontent = CustomLogTextArea(id="streamcontent") + yield streamcontent # Log Area logcontent = CustomLogTextArea(id="logcontent") yield logcontent @@ -452,17 +467,18 @@ async def open_checklist(self, tools_list: list[str], active_tools_num: int) -> self.logger.log_console(f"Deactivated tool '{tool}'") @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) @@ -662,7 +678,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() @@ -671,6 +689,56 @@ def cmd_quit(self, _) -> None: # region Logging + def show_subprocess_panel(self) -> None: + """ + Show the dedicated subprocess output panel at the top of the main panel. + """ + if self.stream_pannel is None: + return + + self.stream_pannel.clear_console() + self.stream_pannel.display = True + self.stream_pannel.styles.height = 12 + self.stream_pannel.refresh(layout=True) + self.refresh(layout=True) + + def enable_subprocess_log_routing(self) -> None: + """ + Route logger sink output to subprocess panel. + """ + self._route_logs_to_stream_panel = True + + def disable_subprocess_log_routing(self) -> None: + """ + Route logger sink output to main panel. + """ + self._route_logs_to_stream_panel = False + + def hide_subprocess_panel(self) -> None: + """ + Hide the subprocess output panel and return space to the main log panel. + """ + if self.stream_pannel is None: + return + + self.stream_pannel.display = False + self.stream_pannel.styles.height = 0 + self.stream_pannel.refresh(layout=True) + self.refresh(layout=True) + + def add_subprocess_line(self, input: str) -> None: + """ + Write output into the dedicated subprocess panel. + """ + if self.stream_pannel is None: + self.add_line(input) + return + + lines = input.splitlines() + for line in lines: + if not self.stream_pannel.append_line(line): + self.logger.log_console("Warning: Trying to add an empty subprocess line.") + def add_line(self, input: str, color: str = "", subprocess_flag: bool = False) -> None: """ Function used to write an input in the VulcanAI terminal. @@ -684,20 +752,24 @@ def add_line(self, input: str, color: str = "", subprocess_flag: bool = False) - color_begin = f"<{color}>" color_end = f"" + target_panel = self.main_pannel + if self._route_logs_to_stream_panel and self.stream_pannel is not None and self.stream_pannel.display: + target_panel = self.stream_pannel + # Append each line; deque automatically truncates old ones for line in lines: line_processed = line if subprocess_flag: line_processed = escape(line) text = f"{color_begin}{line_processed}{color_end}" - if not self.left_pannel.append_line(text): + if not target_panel.append_line(text): self.logger.log_console("Warning: Trying to add an empty line.") def delete_last_line(self): """ Function used to remove the last line in the VulcanAI terminal. """ - self.left_pannel.delete_last_row() + self.main_pannel.delete_last_row() # endregion diff --git a/src/vulcanai/console/modal_screens.py b/src/vulcanai/console/modal_screens.py index 829ad13..a69f70d 100644 --- a/src/vulcanai/console/modal_screens.py +++ b/src/vulcanai/console/modal_screens.py @@ -239,14 +239,17 @@ class RadioListModal(ModalScreen[str | None]): } """ - 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"): diff --git a/src/vulcanai/console/utils.py b/src/vulcanai/console/utils.py index f476f5b..b7d4990 100644 --- a/src/vulcanai/console/utils.py +++ b/src/vulcanai/console/utils.py @@ -268,80 +268,3 @@ def run_oneshot_cmd(args: list[str]) -> str: 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 diff --git a/src/vulcanai/console/widget_custom_log_text_area.py b/src/vulcanai/console/widget_custom_log_text_area.py index f8fdb53..37838d3 100644 --- a/src/vulcanai/console/widget_custom_log_text_area.py +++ b/src/vulcanai/console/widget_custom_log_text_area.py @@ -47,7 +47,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 @@ -281,6 +284,10 @@ def append_line(self, text: str) -> bool: # [EXECUTOR] Invoking 'move_turtle' with args: ... # [ROS] [INFO] Publishing message 1 to ... with self._lock: + # Terminal-like behavior: + # keep following output only if the user was already at the bottom. + should_follow_output = self.is_vertical_scroll_end + # 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 @@ -303,8 +310,9 @@ def append_line(self, text: str) -> bool: # Trim now self._trim_highlights() - # Scroll to end - self.scroll_end(animate=False) + # Scroll to end only when the user was already at the bottom. + if should_follow_output: + self.scroll_end(animate=False, immediate=False, x_axis=False) # Rebuild highlights and refresh self._rebuild_highlights() diff --git a/src/vulcanai/core/executor.py b/src/vulcanai/core/executor.py index 9b3010b..f690279 100644 --- a/src/vulcanai/core/executor.py +++ b/src/vulcanai/core/executor.py @@ -315,6 +315,7 @@ def _call_tool( + "with result:" ) + # TODO. danip Extra print of result if isinstance(result, dict): for key, value in result.items(): if key == "ros2": diff --git a/src/vulcanai/tools/utils.py b/src/vulcanai/tools/utils.py index d998ad7..5761904 100644 --- a/src/vulcanai/tools/utils.py +++ b/src/vulcanai/tools/utils.py @@ -28,20 +28,21 @@ async def run_streaming_cmd_async( # 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, - ) + 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 + assert process.stdout is not None - start_time = time.monotonic() - line_count = 0 + 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") @@ -49,7 +50,10 @@ async def run_streaming_cmd_async( # Print the line if echo: line_processed = escape(line) - console.add_line(line_processed) + if hasattr(console, "add_subprocess_line"): + console.add_subprocess_line(line_processed) + else: + console.add_line(line_processed) # Count the line line_count += 1 @@ -71,21 +75,28 @@ async def run_streaming_cmd_async( 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() + if process is not None: + 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() + if process is not None: + process.terminate() finally: try: - await asyncio.wait_for(process.wait(), timeout=3.0) + if process is not None: + 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() + if process is not None: + process.kill() + await process.wait() + finally: + if hasattr(console, "hide_subprocess_panel"): + console.hide_subprocess_panel() return "Process stopped due to Ctrl+C" @@ -95,6 +106,9 @@ def execute_subprocess(console, tool_name, base_args, max_duration, max_lines): def _launcher() -> None: nonlocal stream_task + if hasattr(console, "show_subprocess_panel"): + console.show_subprocess_panel() + # This always runs in the Textual event-loop thread loop = asyncio.get_running_loop() stream_task = loop.create_task( @@ -200,15 +214,20 @@ def _get_suggestions(real_string_list_comp: list[str], string_comp: str) -> tupl 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.add_line(f"{tool_header_str} {string_name}: \"{input_string}\" does not exists") 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}") + console.open_radiolist(topic_sim_list, tool_name, string_name, input_string) # Wait for the user to select and item in the # RadioList ModalScreen From 33b292a88abcafa8be2b03790c2158d46d2deb57 Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 19 Feb 2026 15:51:00 +0100 Subject: [PATCH 16/24] [#23897] Applied revision (Pub/Sub tools + UI updates) Signed-off-by: danipiza --- src/vulcanai/console/console.py | 38 ++- src/vulcanai/console/logger.py | 4 +- src/vulcanai/console/modal_screens.py | 44 +++- src/vulcanai/core/executor.py | 4 +- src/vulcanai/core/plan_types.py | 2 +- src/vulcanai/tools/default_tools.py | 331 ++++++++++++++++++-------- 6 files changed, 306 insertions(+), 117 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index fb462ba..6687ff4 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -50,6 +50,28 @@ class VulcanConsole(App): # Two panels: left (log + input) and right (history + variables) # Right panel: 48 characters length # Left panel: fills remaining space + + + # #right { + # width: 48; + # layout: vertical; + # border: tall #56AA08; + # padding: 0; + # } + + # #logcontent { + # height: auto; + # min-height: 1; + # max-height: 1fr; + # border: tall #333333; + # } + + # #streamcontent { + # height: 0; + # min-height: 0; + # border: tall #56AA08; + # display: none; + # } CSS = """ Screen { layout: horizontal; @@ -77,7 +99,7 @@ class VulcanConsole(App): #streamcontent { height: 0; min-height: 0; - border: tall #56AA08; + border: solid #56AA08; display: none; } @@ -702,17 +724,15 @@ def show_subprocess_panel(self) -> None: self.stream_pannel.refresh(layout=True) self.refresh(layout=True) - def enable_subprocess_log_routing(self) -> None: - """ - Route logger sink output to subprocess panel. + def change_route_logs(self, value: bool = False) -> None: """ - self._route_logs_to_stream_panel = True + Route logger sink output to stream panel. - def disable_subprocess_log_routing(self) -> None: + value = True -> Stream panel + value = False -> Main panel """ - Route logger sink output to main panel. - """ - self._route_logs_to_stream_panel = False + + self._route_logs_to_stream_panel = value def hide_subprocess_panel(self) -> None: """ diff --git a/src/vulcanai/console/logger.py b/src/vulcanai/console/logger.py index a6b6f88..9c781d5 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", diff --git a/src/vulcanai/console/modal_screens.py b/src/vulcanai/console/modal_screens.py index a69f70d..3f1aab8 100644 --- a/src/vulcanai/console/modal_screens.py +++ b/src/vulcanai/console/modal_screens.py @@ -14,6 +14,7 @@ from textual import events from textual.app import ComposeResult +from textual.content import Content from textual.containers import Container, Horizontal, Vertical, VerticalScroll from textual.screen import ModalScreen from textual.widgets import Button, Checkbox, Input, Label, RadioButton, RadioSet @@ -170,9 +171,16 @@ class CheckListModal(ModalScreen[list[str] | None]): } .btns { - height: 3; /* give buttons row a fixed height */ - 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 3; } """ @@ -209,6 +217,18 @@ def on_mount(self) -> None: 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), + ) + CSS = """ RadioListModal { align: center middle; @@ -218,7 +238,6 @@ class RadioListModal(ModalScreen[str | None]): width: 60%; max-width: 90%; height: 40%; - border: round $accent; padding: 1 2; background: $panel; } @@ -233,9 +252,16 @@ 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; } """ @@ -255,7 +281,7 @@ def compose(self) -> ComposeResult: with VerticalScroll(classes="radio-list"): with RadioSet(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"): @@ -263,7 +289,7 @@ def compose(self) -> ComposeResult: yield Button("Submit", variant="primary", id="submit") def on_mount(self) -> None: - first_rb = self.query_one(RadioButton) + first_rb = self.query_one(self.SquareRadioButton) self.set_focus(first_rb) def on_button_pressed(self, event: Button.Pressed) -> None: diff --git a/src/vulcanai/core/executor.py b/src/vulcanai/core/executor.py index f690279..9598f74 100644 --- a/src/vulcanai/core/executor.py +++ b/src/vulcanai/core/executor.py @@ -277,9 +277,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) diff --git a/src/vulcanai/core/plan_types.py b/src/vulcanai/core/plan_types.py index bdf915f..72d4fd7 100644 --- a/src/vulcanai/core/plan_types.py +++ b/src/vulcanai/core/plan_types.py @@ -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/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index a2c7646..70462b0 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -21,6 +21,7 @@ import importlib import json import time +from concurrent.futures import Future from vulcanai import AtomicTool, vulcanai_tool from vulcanai.tools.utils import execute_subprocess, run_oneshot_cmd, suggest_string @@ -28,6 +29,7 @@ # ROS2 imports try: import rclpy + from rclpy.qos import QoSProfile from std_msgs.msg import String except ImportError: raise ImportError("Unable to load default tools because no ROS 2 installation was found.") @@ -838,75 +840,139 @@ def run(self, **kwargs): 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) + + 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 = kwargs.get("topic", "/chatter") + 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", "std_msgs/msg/String") + + # Number of messages hte publisher is going to write count = kwargs.get("count", 10) + # Ensure "count" is not null or empty to avoid errors + if count is None or count == "": + count = 10 + period_sec = kwargs.get("period_sec", 0.1) + qos_depth = kwargs.get("qos_depth", 10.0) + # Ensure qos_depth is not null or empty to avoid errors + # `create_subscription` accepts QoSProfile or int depth. + # Most tool inputs come as scalars/strings, so coerce to int depth. + if not isinstance(qos_depth, int) and not isinstance(qos_depth, QoSProfile): + try: + qos_depth = int(qos_depth) + except (TypeError, ValueError): + console.call_from_thread( + console.logger.log_msg, + f"[ROS] [ERROR] Invalid 'qos_depth': {qos_depth!r}. Expected integer or QoSProfile.", + ) + return result - if not topic: - node.get_logger().error("No topic provided.") - return {"published": False, "count": 0, "topic": topic} - - if count <= 0: - node.get_logger().warn("Count <= 0, nothing to publish.") - return {"published": True, "count": 0, "topic": topic} - - MsgType = import_msg_type(msg_type_str, node) - publisher = node.create_publisher(MsgType, topic, qos_depth) - - published_count = 0 - for i in range(count): - msg = MsgType() - - # TODO. danip Check custom messages implementation - """ - E.G.: - PlanNode 1: kind=SEQUENCE - Step 1: ros_publish(topic=/custom_topic, msg_type=my_pkg/msg/CustomMsg, - message_data={"id":1,"text":"Custom message 1"}, count=1, period_sec=0.1, qos_depth=10) - - [EXECUTOR] Invoking 'ros_publish' with args:'{'topic': '/custom_topic', - 'msg_type': 'my_pkg/msg/CustomMsg', 'message_data': '{"id":1,"text":"Custom message 1"}', - 'count': '1', 'period_sec': '0.1', 'qos_depth': '10'}' - [EXECUTOR] Execution failed for 'ros_publish': No module named 'my_pkg' - [EXECUTOR] Step 'ros_publish' attempt 1/1 failed - [EXECUTOR] [ERROR] PlanNode SEQUENCE failed on attempt 1/1 - """ - - # 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: - node.get_logger().error( - f"Failed to parse message_data as JSON for custom type '{msg_type_str}': {e}" - ) - return {"published": False, "count": 0, "topic": topic} - - with node.node_lock: - if hasattr(msg, "data"): - node.get_logger().info(f"Publishing: '{msg.data}'") - else: - node.get_logger().info(f"Publishing custom message to '{topic}'") - publisher.publish(msg) + result = { + "published": "False", + "published_msgs": "", + "count": "0", + "topic": "", + } + + if console is None: + print("[ERROR] Console not is None") - published_count += 1 + return result - rclpy.spin_once(node, timeout_sec=0.05) + try: + if not topic_name: + # No topic + console.call_from_thread(console.logger.log_msg, "[ROS] [ERROR] No topic provided.") - if period_sec and period_sec > 0.0: - time.sleep(period_sec) + return result - return {"published": True, "count": published_count, "topic": topic} + result["topic"] = topic_name + + if count <= 0: + # No messages to publish + console.call_from_thread( + console.logger.log_msg, "[ROS] [WARN] Count <= 0, nothing to publish.") + return result + + topic_name_list_str = run_oneshot_cmd(["ros2", "topic", "list"]) + topic_name_list = topic_name_list_str.splitlines() + # Check if the topic is not available ros2 topic list + # if it is not create a window for the user to choose a correct topic name + suggested_topic_name = suggest_string(console, self.name, "Topic", topic_name, topic_name_list) + if suggested_topic_name is not None: + topic_name = suggested_topic_name + + published_msgs = [] + + MsgType = import_msg_type(msg_type_str, node) + publisher = node.create_publisher(MsgType, topic_name, qos_depth) + + for _ in range(count): + msg = MsgType() + + # TODO. danip Check custom messages implementation + """ + E.G.: + PlanNode 1: kind=SEQUENCE + Step 1: ros_publish(topic=/custom_topic, msg_type=my_pkg/msg/CustomMsg, + message_data={"id":1,"text":"Custom message 1"}, count=1, period_sec=0.1, qos_depth=10) + + [EXECUTOR] Invoking 'ros_publish' with args:'{'topic': '/custom_topic', + 'msg_type': 'my_pkg/msg/CustomMsg', 'message_data': '{"id":1,"text":"Custom message 1"}', + 'count': '1', 'period_sec': '0.1', 'qos_depth': '10'}' + [EXECUTOR] Execution failed for 'ros_publish': No module named 'my_pkg' + [EXECUTOR] Step 'ros_publish' attempt 1/1 failed + [EXECUTOR] [ERROR] PlanNode SEQUENCE failed on attempt 1/1 + """ + + # 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, + f"[ROS] [ERROR] Failed to parse message_data as JSON for custom type '{msg_type_str}': {e}") + return result + + with node.node_lock: + if hasattr(msg, "data"): + console.call_from_thread( + console.logger.log_msg, f"[ROS] [INFO] Publishing: '{msg.data}'") + else: + console.call_from_thread(console.logger.log_msg, + f"[ROS] [INFO] Publishing custom message to '{topic_name}'") + publisher.publish(msg) + published_msgs.appned(msg.data) + + + rclpy.spin_once(node, timeout_sec=0.05) + + if period_sec and period_sec > 0.0: + time.sleep(period_sec) + + finally: + if panel_enabled: + if hasattr(console, "change_route_logs"): + console.call_from_thread(console.change_route_logs, False) + console.call_from_thread(console.hide_subprocess_panel) + + result["published_msgs"] = published_msgs + result["count"] = len(published_msgs) + return result @vulcanai_tool @@ -914,7 +980,7 @@ class Ros2SubscribeTool(AtomicTool): name = "ros_subscribe" description = "Subscribe to a topic and stop after receiving N messages or a timeout." description = ( - "Subscribe to a given ROS 2 topic and stop after receiven N messages or a timeout." + "Subscribe to a given ROS 2 topic and stop after receiving N messages or a timeout." "By default 100 messages and 300 seconds duration and a qos_depth of 10" ) tags = ["ros2", "subscribe", "topic", "std_msgs"] @@ -923,17 +989,16 @@ class Ros2SubscribeTool(AtomicTool): ("topic", "string"), # topic name ("msg_type", "string"), # e.g. "std_msgs/msg/String" ("output_format", "string"), # "data" | "dict" - ("max_messages", "int?"), # (optional) stop after this number of messages + ("count", "int?"), # (optional) stop after this number of messages ("timeout_sec", "float?"), # (optional) stop after this seconds ("qos_depth", "int?"), # (optional) subscription queue depth ] output_schema = { - "success": "bool", - "received": "int", - "messages": "list", - "reason": "string", - "topic": "string", + "subscribed": "False", + "received_msgs": "", + "count": "0", + "topic": "", } def msg_to_dict(self, msg): @@ -964,64 +1029,142 @@ def run(self, **kwargs): 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) + + 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", None) - topic = kwargs.get("topic", None) msg_type_str = kwargs.get("msg_type", "std_msgs/msg/String") - max_messages = kwargs.get("max_messages", 100) - timeout_sec = kwargs.get("timeout_sec", 300.0) - qos_depth = kwargs.get("qos_depth", 10.0) - output_format = kwargs.get("output_format", "data") + # Ensure "msg_type_str" is not null or empty to avoid errors + if msg_type_str is None or msg_type_str == "": + msg_type_str = "std_msgs/msg/String" - if not topic: - return {"success": False, "received": 0, "messages": [], "reason": "no_topic", "topic": topic} + count = kwargs.get("count", 100) + # Ensure "count" is not null or empty to avoid errors + if count is None or count == "": + count = 10 - if max_messages <= 0: - return {"success": True, "received": 0, "messages": [], "reason": "max_messages<=0", "topic": topic} + timeout_sec = kwargs.get("timeout_sec", 60.0) + # Ensure "timeout_sec" is not null or empty to avoid errors + if timeout_sec is None or timeout_sec == "": + timeout_sec = 60.0 - if not msg_type_str: - msg_type_str = "std_msgs/msg/String" + qos_depth = kwargs.get("qos_depth", 10.0) + # Ensure qos_depth is not null or empty to avoid errors + # `create_subscription` accepts QoSProfile or int depth. + # Most tool inputs come as scalars/strings, so coerce to int depth. + if not isinstance(qos_depth, int) and not isinstance(qos_depth, QoSProfile): + try: + qos_depth = int(qos_depth) + except (TypeError, ValueError): + console.call_from_thread( + console.logger.log_msg, + f"[ROS] [ERROR] Invalid 'qos_depth': {qos_depth!r}. Expected integer or QoSProfile.", + ) + return result + + if qos_depth <= 0: + console.call_from_thread( + console.logger.log_msg, + f"[ROS] [ERROR] Invalid 'qos_depth': {qos_depth}. Must be > 0.", + ) + return result + + + output_format = kwargs.get("output_format", "data") - received_msgs = [] + result = { + "subscribed": "False", + "received_msgs": "", + "count": "0", + "topic": "", + } def callback(msg: String): # received_msgs.append(msg.data) - # node.get_logger().info(f"I heard: [{msg.data}]") if output_format == "data" and hasattr(msg, "data"): received_msgs.append(msg.data) - node.get_logger().info(f"I heard: [{msg.data}]") + console.call_from_thread(console.logger.log_msg, f"[ROS] [INFO] I heard: [{msg.data}]") else: d = self.msg_to_dict(msg) received_msgs.append(d["data"] if "data" in d else d) - node.get_logger().info(f"I heard: [{d['data']}]") + console.call_from_thread(console.logger.log_msg, f"[ROS] [INFO] I heard: [{d['data']}]") - MsgType = import_msg_type(msg_type_str, node) - sub = node.create_subscription(MsgType, topic, callback, qos_depth) - - start = time.monotonic() - reason = "timeout" + if console is None: + print("[ERROR] Console not is None") + return result try: + + if not topic_name: + # No topic + console.call_from_thread(console.logger.log_msg, "[ROS] [ERROR] No topic provided.") + return result + + result["topic"] = topic_name + + if count <= 0: + # No messages to publish + console.call_from_thread( + console.logger.log_msg, "[ROS] [WARN] Count <= 0, nothing to publish.") + return result + + topic_name_list_str = run_oneshot_cmd(["ros2", "topic", "list"]) + topic_name_list = topic_name_list_str.splitlines() + # Check if the topic is not available ros2 topic list + # if it is not create a window for the user to choose a correct topic name + suggested_topic_name = suggest_string(console, self.name, "Topic", topic_name, topic_name_list) + if suggested_topic_name is not None: + topic_name = suggested_topic_name + + received_msgs = [] + + MsgType = import_msg_type(msg_type_str, node) + sub = node.create_subscription(MsgType, topic_name, callback, qos_depth) + + start = time.monotonic() + cancel_token = None + + # Console Ctrl+C signal + cancel_token = Future() + console.set_stream_task(cancel_token) + console.logger.log_tool("[tool]Subscription created![tool]", tool_name=self.name) + + while rclpy.ok(): + if cancel_token is not None and cancel_token.cancelled(): + console.logger.log_tool("[tool]Ctrl+C received:[/tool] stopping subscription...", tool_name=self.name) + break + # Stop conditions - if len(received_msgs) >= max_messages: - reason = "max_messages" + if len(received_msgs) >= count: break if (time.monotonic() - start) >= timeout_sec: - reason = "timeout" break rclpy.spin_once(node, timeout_sec=0.1) + except KeyboardInterrupt: + console.logger.log_tool("[tool]Ctrl+C received:[/tool] stopping subscription...", tool_name=self.name) + finally: + console.set_stream_task(None) + if panel_enabled: + if hasattr(console, "change_route_logs"): + console.call_from_thread(console.change_route_logs, False) + console.call_from_thread(console.hide_subprocess_panel) try: node.destroy_subscription(sub) except Exception: pass - return { - "success": True, - "received": len(received_msgs), - "messages": received_msgs, - "reason": reason, - "topic": topic, - } + result["received_msgs"] = received_msgs + result["count"] = len(received_msgs) + + return result From f078003011042f08f105f33682d31f81cc599092 Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 19 Feb 2026 16:32:53 +0100 Subject: [PATCH 17/24] [#23897] Applied Ruff Signed-off-by: danipiza --- src/vulcanai/console/console.py | 5 ++- src/vulcanai/console/modal_screens.py | 2 +- src/vulcanai/console/utils.py | 3 +- src/vulcanai/tools/default_tools.py | 58 +++++++++++++++------------ src/vulcanai/tools/utils.py | 2 +- 5 files changed, 38 insertions(+), 32 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index 6687ff4..da60d29 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -51,7 +51,6 @@ class VulcanConsole(App): # Right panel: 48 characters length # Left panel: fills remaining space - # #right { # width: 48; # layout: vertical; @@ -489,7 +488,9 @@ async def open_checklist(self, tools_list: list[str], active_tools_num: int) -> self.logger.log_console(f"Deactivated tool '{tool}'") @work - async def open_radiolist(self, option_list: list[str], tool: str = "", category: str = "", input_string: 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. diff --git a/src/vulcanai/console/modal_screens.py b/src/vulcanai/console/modal_screens.py index 3f1aab8..81bcb3b 100644 --- a/src/vulcanai/console/modal_screens.py +++ b/src/vulcanai/console/modal_screens.py @@ -14,8 +14,8 @@ from textual import events from textual.app import ComposeResult -from textual.content import Content 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 diff --git a/src/vulcanai/console/utils.py b/src/vulcanai/console/utils.py index b7d4990..db61e5c 100644 --- a/src/vulcanai/console/utils.py +++ b/src/vulcanai/console/utils.py @@ -14,8 +14,6 @@ import asyncio -import difflib -import heapq import subprocess import sys import threading @@ -62,6 +60,7 @@ def on_request_start(self, text="Querying LLM..."): def on_request_end(self): self.spinner_status.stop() + def attach_ros_logger_to_console(console): """ Redirect ALL rclpy RcutilsLogger output (nodes + executor + rclpy internals) diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index 70462b0..8ca0396 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -843,6 +843,13 @@ def run(self, **kwargs): # Optional console handle to route logs to the subprocess panel. console = self.bb.get("console", None) + result = { + "published": "False", + "published_msgs": "", + "count": "0", + "topic": "", + } + panel_enabled = console is not None and hasattr(console, "show_subprocess_panel") if panel_enabled: console.call_from_thread(console.show_subprocess_panel) @@ -876,13 +883,6 @@ def run(self, **kwargs): ) return result - result = { - "published": "False", - "published_msgs": "", - "count": "0", - "topic": "", - } - if console is None: print("[ERROR] Console not is None") @@ -900,7 +900,8 @@ def run(self, **kwargs): if count <= 0: # No messages to publish console.call_from_thread( - console.logger.log_msg, "[ROS] [WARN] Count <= 0, nothing to publish.") + console.logger.log_msg, "[ROS] [WARN] Count <= 0, nothing to publish." + ) return result topic_name_list_str = run_oneshot_cmd(["ros2", "topic", "list"]) @@ -944,21 +945,26 @@ def run(self, **kwargs): payload = json.loads(message_data) self.msg_from_dict(msg, payload) except json.JSONDecodeError as e: - console.call_from_thread(console.logger.log_msg, - f"[ROS] [ERROR] Failed to parse message_data as JSON for custom type '{msg_type_str}': {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 with node.node_lock: if hasattr(msg, "data"): console.call_from_thread( - console.logger.log_msg, f"[ROS] [INFO] Publishing: '{msg.data}'") + console.logger.log_msg, f"[ROS] [INFO] Publishing: '{msg.data}'" + ) else: - console.call_from_thread(console.logger.log_msg, - f"[ROS] [INFO] Publishing custom message to '{topic_name}'") + console.call_from_thread( + console.logger.log_msg, + f"[ROS] [INFO] Publishing custom message to '{topic_name}'", + ) publisher.publish(msg) published_msgs.appned(msg.data) - rclpy.spin_once(node, timeout_sec=0.05) if period_sec and period_sec > 0.0: @@ -1032,6 +1038,13 @@ def run(self, **kwargs): # Optional console handle to support Ctrl+C cancellation. console = self.bb.get("console", None) + result = { + "published": "False", + "published_msgs": "", + "count": "0", + "topic": "", + } + panel_enabled = console is not None and hasattr(console, "show_subprocess_panel") if panel_enabled: console.call_from_thread(console.show_subprocess_panel) @@ -1076,16 +1089,8 @@ def run(self, **kwargs): ) return result - output_format = kwargs.get("output_format", "data") - result = { - "subscribed": "False", - "received_msgs": "", - "count": "0", - "topic": "", - } - def callback(msg: String): # received_msgs.append(msg.data) if output_format == "data" and hasattr(msg, "data"): @@ -1101,7 +1106,6 @@ def callback(msg: String): return result try: - if not topic_name: # No topic console.call_from_thread(console.logger.log_msg, "[ROS] [ERROR] No topic provided.") @@ -1112,7 +1116,8 @@ def callback(msg: String): if count <= 0: # No messages to publish console.call_from_thread( - console.logger.log_msg, "[ROS] [WARN] Count <= 0, nothing to publish.") + console.logger.log_msg, "[ROS] [WARN] Count <= 0, nothing to publish." + ) return result topic_name_list_str = run_oneshot_cmd(["ros2", "topic", "list"]) @@ -1136,10 +1141,11 @@ def callback(msg: String): console.set_stream_task(cancel_token) console.logger.log_tool("[tool]Subscription created![tool]", tool_name=self.name) - while rclpy.ok(): if cancel_token is not None and cancel_token.cancelled(): - console.logger.log_tool("[tool]Ctrl+C received:[/tool] stopping subscription...", tool_name=self.name) + console.logger.log_tool( + "[tool]Ctrl+C received:[/tool] stopping subscription...", tool_name=self.name + ) break # Stop conditions diff --git a/src/vulcanai/tools/utils.py b/src/vulcanai/tools/utils.py index 5761904..6a03eaa 100644 --- a/src/vulcanai/tools/utils.py +++ b/src/vulcanai/tools/utils.py @@ -216,7 +216,7 @@ def _get_suggestions(real_string_list_comp: list[str], string_comp: str) -> tupl # 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] != '/': + if string_name in ros_categories_list and len(input_string) > 0 and input_string[0] != "/": input_string = f"/{input_string}" ret = input_string From 3d25f8d77e30916511d79c572bf8642e4084ec8f Mon Sep 17 00:00:00 2001 From: danipiza Date: Tue, 24 Feb 2026 15:10:52 +0100 Subject: [PATCH 18/24] [#23897] Applied revision Signed-off-by: danipiza --- src/vulcanai/console/console.py | 20 ---- src/vulcanai/tools/__init__.pyi | 9 +- src/vulcanai/tools/custom_node.py | 64 +++++++++++ src/vulcanai/tools/default_tools.py | 160 ++++++++++++---------------- 4 files changed, 135 insertions(+), 118 deletions(-) create mode 100644 src/vulcanai/tools/custom_node.py diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index da60d29..c90b2f8 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -51,26 +51,6 @@ class VulcanConsole(App): # Right panel: 48 characters length # Left panel: fills remaining space - # #right { - # width: 48; - # layout: vertical; - # border: tall #56AA08; - # padding: 0; - # } - - # #logcontent { - # height: auto; - # min-height: 1; - # max-height: 1fr; - # border: tall #333333; - # } - - # #streamcontent { - # height: 0; - # min-height: 0; - # border: tall #56AA08; - # display: none; - # } CSS = """ Screen { layout: horizontal; diff --git a/src/vulcanai/tools/__init__.pyi b/src/vulcanai/tools/__init__.pyi index 55a6cdf..0ded42c 100644 --- a/src/vulcanai/tools/__init__.pyi +++ b/src/vulcanai/tools/__init__.pyi @@ -12,13 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from .custom_node import CustomNode from .tool_registry import ToolRegistry, vulcanai_tool from .tools import AtomicTool, CompositeTool, ValidationTool -__all__ = [ - "AtomicTool", - "CompositeTool", - "ToolRegistry", - "ValidationTool", - "vulcanai_tool", -] +__all__ = ["AtomicTool", "CompositeTool", "ToolRegistry", "ValidationTool", "vulcanai_tool", "CustomNode"] diff --git a/src/vulcanai/tools/custom_node.py b/src/vulcanai/tools/custom_node.py new file mode 100644 index 0000000..4f2edfe --- /dev/null +++ b/src/vulcanai/tools/custom_node.py @@ -0,0 +1,64 @@ +import threading + +import rclpy +from rclpy.node import Node +from rclpy.task import Future + + +class CustomNode(Node): + def __init__(self, name: str = "vulcanai_custom_node"): + super().__init__(name) + # 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() + + 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) + + rclpy.spin_until_future_complete(self, future, timeout_sec=timeout_sec) + self.destroy_subscription(sub) + + if future.done(): + return future.result() + return None diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index 8ca0396..be5cd3a 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -29,7 +29,6 @@ # ROS2 imports try: import rclpy - from rclpy.qos import QoSProfile from std_msgs.msg import String except ImportError: raise ImportError("Unable to load default tools because no ROS 2 installation was found.") @@ -799,7 +798,7 @@ class Ros2PublishTool(AtomicTool): "For custom types, pass message_data as a JSON object with field names and values. " "By default 10 messages 'Hello from VulcanAI PublishTool!' " "with type 'std_msgs/msg/String' in topic '/chatter' " - "with 0.1 seconds of delay between messages to publish with a qos_depth of 10. " + "with 0.1 seconds of delay between messages to publish" 'Example for custom type: msg_type=\'my_pkg/msg/MyMessage\', message_data=\'{"index": 1, "message": "Hello"}\'' ) tags = ["ros2", "publish", "message", "std_msgs"] @@ -810,7 +809,6 @@ class Ros2PublishTool(AtomicTool): ("msg_type", "string?"), # (optional) e.g. "std_msgs/msg/String" or "my_pkg/msg/CustomMsg" ("count", "int?"), # (optional) number of messages to publish ("period_sec", "float?"), # (optional) delay between publishes (in seconds) - ("qos_depth", "int?"), # (optional) publisher queue depth ("message", "string?"), # (deprecated) use message_data instead ] @@ -861,40 +859,38 @@ def run(self, **kwargs): message_data = kwargs.get("message_data", kwargs.get("message", "Hello from VulcanAI PublishTool!")) msg_type_str = kwargs.get("msg_type", "std_msgs/msg/String") - # Number of messages hte publisher is going to write + # Number of messages the publisher is going to write. count = kwargs.get("count", 10) - # Ensure "count" is not null or empty to avoid errors - if count is None or count == "": - count = 10 period_sec = kwargs.get("period_sec", 0.1) - qos_depth = kwargs.get("qos_depth", 10.0) - # Ensure qos_depth is not null or empty to avoid errors - # `create_subscription` accepts QoSProfile or int depth. - # Most tool inputs come as scalars/strings, so coerce to int depth. - if not isinstance(qos_depth, int) and not isinstance(qos_depth, QoSProfile): - try: - qos_depth = int(qos_depth) - except (TypeError, ValueError): - console.call_from_thread( - console.logger.log_msg, - f"[ROS] [ERROR] Invalid 'qos_depth': {qos_depth!r}. Expected integer or QoSProfile.", - ) - return result + qos_depth = 10 if console is None: print("[ERROR] Console not is None") return result + published_msgs = [] + publisher = None + cancel_token = None + try: if not topic_name: - # No topic console.call_from_thread(console.logger.log_msg, "[ROS] [ERROR] No topic provided.") - return result + if count is None or count == "": + count = 10 + else: + try: + count = int(count) + except (TypeError, ValueError): + console.call_from_thread( + console.logger.log_msg, "[ROS] [ERROR] 'count' must be an integer." + ) + return result + result["topic"] = topic_name if count <= 0: @@ -904,36 +900,18 @@ def run(self, **kwargs): ) return result - topic_name_list_str = run_oneshot_cmd(["ros2", "topic", "list"]) - topic_name_list = topic_name_list_str.splitlines() - # Check if the topic is not available ros2 topic list - # if it is not create a window for the user to choose a correct topic name - suggested_topic_name = suggest_string(console, self.name, "Topic", topic_name, topic_name_list) - if suggested_topic_name is not None: - topic_name = suggested_topic_name - - published_msgs = [] - 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) + console.logger.log_tool("[tool]Publisher created![tool]", tool_name=self.name) for _ in range(count): - msg = MsgType() + if cancel_token.cancelled(): + console.logger.log_tool("[tool]Ctrl+C received:[/tool] stopping publish...", tool_name=self.name) + break - # TODO. danip Check custom messages implementation - """ - E.G.: - PlanNode 1: kind=SEQUENCE - Step 1: ros_publish(topic=/custom_topic, msg_type=my_pkg/msg/CustomMsg, - message_data={"id":1,"text":"Custom message 1"}, count=1, period_sec=0.1, qos_depth=10) - - [EXECUTOR] Invoking 'ros_publish' with args:'{'topic': '/custom_topic', - 'msg_type': 'my_pkg/msg/CustomMsg', 'message_data': '{"id":1,"text":"Custom message 1"}', - 'count': '1', 'period_sec': '0.1', 'qos_depth': '10'}' - [EXECUTOR] Execution failed for 'ros_publish': No module named 'my_pkg' - [EXECUTOR] Step 'ros_publish' attempt 1/1 failed - [EXECUTOR] [ERROR] PlanNode SEQUENCE failed on attempt 1/1 - """ + msg = MsgType() # Try to populate message based on message type if hasattr(msg, "data"): @@ -952,18 +930,17 @@ def run(self, **kwargs): ) return result - with node.node_lock: - if hasattr(msg, "data"): - console.call_from_thread( - console.logger.log_msg, f"[ROS] [INFO] Publishing: '{msg.data}'" - ) - else: - console.call_from_thread( - console.logger.log_msg, - f"[ROS] [INFO] Publishing custom message to '{topic_name}'", - ) - publisher.publish(msg) - published_msgs.appned(msg.data) + if hasattr(msg, "data"): + console.call_from_thread( + console.logger.log_msg, f"[ROS] [INFO] Publishing: '{msg.data}'" + ) + else: + console.call_from_thread( + console.logger.log_msg, + f"[ROS] [INFO] Publishing custom message to '{topic_name}'", + ) + publisher.publish(msg) + published_msgs.append(msg.data if hasattr(msg, "data") else str(msg)) rclpy.spin_once(node, timeout_sec=0.05) @@ -971,10 +948,16 @@ def run(self, **kwargs): time.sleep(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) console.call_from_thread(console.hide_subprocess_panel) + if publisher is not None: + try: + node.destroy_publisher(publisher) + except Exception: + pass result["published_msgs"] = published_msgs result["count"] = len(published_msgs) @@ -987,7 +970,7 @@ class Ros2SubscribeTool(AtomicTool): description = "Subscribe to a topic and stop after receiving N messages or a timeout." description = ( "Subscribe to a given ROS 2 topic and stop after receiving N messages or a timeout." - "By default 100 messages and 300 seconds duration and a qos_depth of 10" + "By default 100 messages and 300 seconds duration" ) tags = ["ros2", "subscribe", "topic", "std_msgs"] @@ -996,8 +979,7 @@ class Ros2SubscribeTool(AtomicTool): ("msg_type", "string"), # e.g. "std_msgs/msg/String" ("output_format", "string"), # "data" | "dict" ("count", "int?"), # (optional) stop after this number of messages - ("timeout_sec", "float?"), # (optional) stop after this seconds - ("qos_depth", "int?"), # (optional) subscription queue depth + ("timeout_sec", "int?"), # (optional) stop after this seconds ] output_schema = { @@ -1045,6 +1027,7 @@ def run(self, **kwargs): "topic": "", } + # Check if the console have the function show_subprocess_panel() to open panel_enabled = console is not None and hasattr(console, "show_subprocess_panel") if panel_enabled: console.call_from_thread(console.show_subprocess_panel) @@ -1059,35 +1042,9 @@ def run(self, **kwargs): msg_type_str = "std_msgs/msg/String" count = kwargs.get("count", 100) - # Ensure "count" is not null or empty to avoid errors - if count is None or count == "": - count = 10 - - timeout_sec = kwargs.get("timeout_sec", 60.0) - # Ensure "timeout_sec" is not null or empty to avoid errors - if timeout_sec is None or timeout_sec == "": - timeout_sec = 60.0 - - qos_depth = kwargs.get("qos_depth", 10.0) - # Ensure qos_depth is not null or empty to avoid errors - # `create_subscription` accepts QoSProfile or int depth. - # Most tool inputs come as scalars/strings, so coerce to int depth. - if not isinstance(qos_depth, int) and not isinstance(qos_depth, QoSProfile): - try: - qos_depth = int(qos_depth) - except (TypeError, ValueError): - console.call_from_thread( - console.logger.log_msg, - f"[ROS] [ERROR] Invalid 'qos_depth': {qos_depth!r}. Expected integer or QoSProfile.", - ) - return result + timeout_sec = kwargs.get("timeout_sec", 60) - if qos_depth <= 0: - console.call_from_thread( - console.logger.log_msg, - f"[ROS] [ERROR] Invalid 'qos_depth': {qos_depth}. Must be > 0.", - ) - return result + qos_depth = 10 output_format = kwargs.get("output_format", "data") @@ -1107,10 +1064,31 @@ def callback(msg: String): try: if not topic_name: - # No topic console.call_from_thread(console.logger.log_msg, "[ROS] [ERROR] No topic provided.") return result + if count is None or count == "": + count = 10 + else: + try: + count = int(count) + except (TypeError, ValueError): + console.call_from_thread( + console.logger.log_msg, "[ROS] [ERROR] 'count' must be an integer." + ) + return result + + if timeout_sec is None or timeout_sec == "": + timeout_sec = 60 + else: + try: + timeout_sec = int(timeout_sec) + except (TypeError, ValueError): + console.call_from_thread( + console.logger.log_msg, "[ROS] [ERROR] 'timeout_sec' must be an integer." + ) + return result + result["topic"] = topic_name if count <= 0: From a3a8ff5401c8e9cabb6db8f88b9ff7d72c3f7271 Mon Sep 17 00:00:00 2001 From: danipiza Date: Tue, 3 Mar 2026 07:36:11 +0100 Subject: [PATCH 19/24] [#23897] Applied revision (subscribe tool, scroll end, ros2 topic pub, ...) Signed-off-by: danipiza --- src/vulcanai/console/console.py | 5 +- src/vulcanai/console/utils.py | 28 +- .../console/widget_custom_log_text_area.py | 17 +- src/vulcanai/console/widget_spinner.py | 20 +- src/vulcanai/core/executor.py | 10 - src/vulcanai/tools/default_tools.py | 348 +++++------------- src/vulcanai/tools/utils.py | 65 ++-- 7 files changed, 193 insertions(+), 300 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index c90b2f8..da64f85 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -648,12 +648,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...") diff --git a/src/vulcanai/console/utils.py b/src/vulcanai/console/utils.py index db61e5c..4c8dce6 100644 --- a/src/vulcanai/console/utils.py +++ b/src/vulcanai/console/utils.py @@ -55,10 +55,18 @@ def __init__(self, spinner_status): self.spinner_status = spinner_status def on_request_start(self, text="Querying LLM..."): - self.spinner_status.start(text) + app = getattr(self.spinner_status, "app", None) + if app is not None and threading.current_thread() is not threading.main_thread(): + app.call_from_thread(self.spinner_status.start, text) + else: + self.spinner_status.start(text) def on_request_end(self): - self.spinner_status.stop() + app = getattr(self.spinner_status, "app", None) + if app is not None and threading.current_thread() is not threading.main_thread(): + app.call_from_thread(self.spinner_status.stop) + else: + self.spinner_status.stop() def attach_ros_logger_to_console(console): @@ -229,6 +237,8 @@ def _launcher() -> None: 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: if task.cancelled(): @@ -246,18 +256,12 @@ def _on_done(task: asyncio.Task) -> None: 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. + # Worker threads may have their own asyncio loop; only run directly on UI thread. + if threading.current_thread() is threading.main_thread(): _launcher() + else: + console.app.call_from_thread(_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) diff --git a/src/vulcanai/console/widget_custom_log_text_area.py b/src/vulcanai/console/widget_custom_log_text_area.py index 37838d3..bc624a4 100644 --- a/src/vulcanai/console/widget_custom_log_text_area.py +++ b/src/vulcanai/console/widget_custom_log_text_area.py @@ -73,6 +73,17 @@ def __init__(self, **kwargs): # 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 _trim_highlights(self) -> None: """ Function used to trim the CustomLogTextArea to the maximum number of lines. @@ -286,7 +297,7 @@ def append_line(self, text: str) -> bool: with self._lock: # Terminal-like behavior: # keep following output only if the user was already at the bottom. - should_follow_output = self.is_vertical_scroll_end + should_follow_output = self.is_near_vertical_scroll_end() # Append via document API to keep row tracking consistent # Only add a newline before the new line if there is already content @@ -312,7 +323,9 @@ def append_line(self, text: str) -> bool: # Scroll to end only when the user was already at the bottom. if should_follow_output: - self.scroll_end(animate=False, immediate=False, x_axis=False) + self.scroll_end(animate=False, immediate=True, x_axis=False) + # Ensure we stay anchored after any pending layout updates. + self.call_after_refresh(self.scroll_end, animate=False, immediate=True, x_axis=False) # Rebuild highlights and refresh self._rebuild_highlights() diff --git a/src/vulcanai/console/widget_spinner.py b/src/vulcanai/console/widget_spinner.py index a2019ed..b8c7a40 100644 --- a/src/vulcanai/console/widget_spinner.py +++ b/src/vulcanai/console/widget_spinner.py @@ -46,9 +46,14 @@ 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, "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,10 +64,19 @@ 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, "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 @@ -71,5 +85,9 @@ def stop(self) -> None: 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 9598f74..61d13f7 100644 --- a/src/vulcanai/core/executor.py +++ b/src/vulcanai/core/executor.py @@ -315,16 +315,6 @@ def _call_tool( + "with result:" ) - # TODO. danip Extra print of 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/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index be5cd3a..1136c1b 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -24,26 +24,14 @@ from concurrent.futures import Future from vulcanai import AtomicTool, vulcanai_tool -from vulcanai.tools.utils import execute_subprocess, run_oneshot_cmd, suggest_string +from vulcanai.tools.utils import execute_subprocess, last_output_lines, run_oneshot_cmd, suggest_string # ROS2 imports try: import rclpy - from std_msgs.msg import String except ImportError: raise ImportError("Unable to load default tools because no ROS 2 installation was found.") -"""topics = topic_name_list_str.splitlines() - -# TODO. in all commands -# Will be updated in the TUI Migration PR. -# The PR adds a modalscreen to select the most similar string), -# this applies to all ros cli commands. Though, not implemented -# in the rest commands from this PR -if topic_name not in topics: - topic_similar = search_similar(topics, topic_name) - topic_name = topic_similar""" - """ - ros2 node Commands: @@ -116,13 +104,12 @@ class Ros2NodeTool(AtomicTool): "Run any subcommand: 'list', 'info'" "With an optional argument 'node_name' for 'info' subcommand." ) - description = "List ROS2 nodes and optionally get detailed info for a specific node." tags = ["ros2", "nodes", "cli", "info", "diagnostics"] + # - `command` lets you pick a single subcommand (list/info). input_schema = [ - ("node_name", "string?") # (optional) Name of the ros2 node. - # if the node is not provided the command is `ros2 node list`. - # otherwise `ros2 node info ` + ("command", "string"), # Command + ("topic_name", "string?"), # (optional) Topic name. (info/bw/delay/hz/type/pub) ] output_schema = { @@ -135,26 +122,28 @@ def run(self, **kwargs): if console is None: raise Exception("Could not find console, aborting...") - # Get the node name if provided by the query - node_name = kwargs.get("node_name", None) + command = kwargs.get("command", None) # optional explicit subcommand + node_name = kwargs.get("node_name", "") result = { "output": "", } + command = command.lower() + # -- Node name suggestions -- node_name_list_str = run_oneshot_cmd(["ros2", "node", "list"]) node_name_list = node_name_list_str.splitlines() # -- Run `ros2 node list` --------------------------------------------- - if node_name is None: + if command == "list": result["output"] = node_name_list_str # -- Run `ros2 node info ` -------------------------------------- else: # Check if the topic is not available ros2 topic list # if it is not create a window for the user to choose a correct topic name - suggested_topic = suggest_string(console, self.name, "Topic", node_name, node_name_list) + suggested_topic = suggest_string(console, self.name, "Node", node_name, node_name_list) if suggested_topic is not None: node_name = suggested_topic @@ -172,17 +161,17 @@ class Ros2TopicTool(AtomicTool): name = "ros2_topic" description = ( "Wrapper for `ros2 topic` CLI." - "Run any subcommand: 'list', 'info', 'find', 'type', 'echo', 'bw', 'delay', 'hz', 'pub'." + "Run any subcommand: 'list', 'info', 'find', 'type', 'bw', 'delay', 'hz', 'pub'." "With optional arguments like 'topic_name', 'message_type', 'max_duration' or 'max_lines'" ) tags = ["ros2", "topics", "cli", "info"] - # - `command` lets you pick a single subcommand (echo/bw/hz/delay/find/pub/type). + # - `command` lets you pick a single subcommand (bw/hz/delay/find/pub/type). input_schema = [ ("command", "string"), # Command - ("topic_name", "string?"), # (optional) Topic name. (info/echo/bw/delay/hz/type/pub) + ("topic_name", "string?"), # (optional) Topic name. (info/bw/delay/hz/type/pub) ("msg_type", "string?"), # (optional) Message type (`find` , `pub` ) - ("max_duration", "number?"), # (optional) Seconds for streaming commands (echo/bw/hz/delay) + ("max_duration", "number?"), # (optional) Seconds for streaming commands (bw/hz/delay) ("max_lines", "int?"), # (optional) Cap number of lines for streaming commands ] @@ -250,47 +239,23 @@ def run(self, **kwargs): type_output = run_oneshot_cmd(["ros2", "topic", "type", topic_name]) result["output"] = type_output - # -- ros2 topic echo ------------------------------------- - elif command == "echo": - base_args = ["ros2", "topic", "echo", topic_name] - execute_subprocess(console, self.name, base_args, max_duration, max_lines) - - result["output"] = "True" - # -- ros2 topic bw --------------------------------------- elif command == "bw": base_args = ["ros2", "topic", "bw", topic_name] - execute_subprocess(console, self.name, base_args, max_duration, max_lines) - - result["output"] = "True" + ret = execute_subprocess(console, self.name, base_args, max_duration, max_lines) + result["output"] = last_output_lines(console, self.name, ret, max_lines=10) # -- ros2 topic delay ------------------------------------ elif command == "delay": base_args = ["ros2", "topic", "delay", topic_name] - execute_subprocess(console, self.name, base_args, max_duration, max_lines) - - result["output"] = "True" + ret = execute_subprocess(console, self.name, base_args, max_duration, max_lines) + result["output"] = last_output_lines(console, self.name, ret, max_lines=10) # -- ros2 topic hz --------------------------------------- elif command == "hz": base_args = ["ros2", "topic", "hz", topic_name] - execute_subprocess(console, self.name, base_args, max_duration, max_lines) - - result["output"] = "True" - - # -- publisher -------------------------------------------------------- - elif command == "pub": - # One-shot publish using `-1` - # ros2 topic pub -1 "" - # ros2 topic pub -1 /rosout2 std_msgs/msg/String "{data: 'Hello'}" - - if not msg_type: - raise ValueError("`command='pub'` requires `msg_type`.") - - base_args = ["ros2", "topic", "pub", topic_name, msg_type] - execute_subprocess(console, self.name, base_args, max_duration, max_lines) - - result["output"] = "True" + ret = execute_subprocess(console, self.name, base_args, max_duration, max_lines) + result["output"] = last_output_lines(console, self.name, ret, max_lines=10) # -- unknown ---------------------------------------------------------- else: @@ -403,9 +368,8 @@ def run(self, **kwargs): # -- ros2 service echo service_name ----------------------------------- elif command == "echo": base_args = ["ros2", "service", "echo", service_name] - execute_subprocess(console, self.name, base_args, max_duration, max_lines) - - result["output"] = "True" + ret = execute_subprocess(console, self.name, base_args, max_duration, max_lines) + result["output"] = last_output_lines(console, self.name, ret, max_lines=10) # -- unknown ------------------------------------------------------------ else: @@ -717,19 +681,8 @@ def run(self, **kwargs): interface_name_list_str = run_oneshot_cmd(["ros2", "interface", "list"]) interface_name_list = interface_name_list_str.splitlines() - # -- Interface name suggestions -- - if command in ["package", "show"]: - # Check if the topic is not available ros2 topic list - # if it is not create a window for the user to choose a correct topic name - suggested_interface_name = suggest_string( - console, self.name, "Interface", interface_name, interface_name_list - ) - if suggested_interface_name is not None: - interface_name = suggested_interface_name - - # Check if the interface_name is null (suggest_string() failed) - if not interface_name: - raise ValueError("`command='{}'` requires `interface_name`.".format(command)) + package_name_list_str = run_oneshot_cmd(["ros2", "interface", "packages"]) + package_name_list = package_name_list_str.splitlines() # -- ros2 interface list ---------------------------------------------- if interface_name is None: @@ -737,21 +690,37 @@ def run(self, **kwargs): # -- ros2 interface packages ------------------------------------------ elif command == "packages": - interface_pkg_name_list = run_oneshot_cmd(["ros2", "interface", "packages"]) - result["output"] = interface_pkg_name_list + result["output"] = package_name_list_str # -- ros2 interface package -------------------------------- elif command == "package": + package_name = interface_name + # Check if the topic is not available ros2 topic list + # if it is not create a window for the user to choose a correct topic name + suggested_package_name = suggest_string(console, self.name, "Interface", package_name, package_name_list) + if suggested_package_name is not None: + package_name = suggested_package_name + + # Check if the interface_name is null (suggest_string() failed) if not interface_name: - raise ValueError("`command='package'` requires `interface_name`.") + raise ValueError("`command='{}'` requires `interface_name`.".format(command)) - info_output = run_oneshot_cmd(["ros2", "topic", "package", interface_name]) + info_output = run_oneshot_cmd(["ros2", "topic", "package", package_name]) result["output"] = info_output # -- ros2 interface show -------------------------------- elif command == "show": + # Check if the topic is not available ros2 topic list + # if it is not create a window for the user to choose a correct topic name + suggested_interface_name = suggest_string( + console, self.name, "Interface", interface_name, interface_name_list + ) + if suggested_interface_name is not None: + interface_name = suggested_interface_name + + # Check if the interface_name is null (suggest_string() failed) if not interface_name: - raise ValueError("`command='package'` requires `interface_name`.") + raise ValueError("`command='{}'` requires `interface_name`.".format(command)) info_output = run_oneshot_cmd(["ros2", "topic", "show", interface_name]) result["output"] = info_output @@ -793,7 +762,8 @@ def import_msg_type(type_str: str, node): class Ros2PublishTool(AtomicTool): name = "ros_publish" description = ( - "Publish one or more messages to a given ROS 2 topic. " + "Publish one or more messages to a given ROS 2 topic . " + "Or execute 'ros2 topic pub '. " "Supports both simple string messages (for std_msgs/msg/String) and custom message types. " "For custom types, pass message_data as a JSON object with field names and values. " "By default 10 messages 'Hello from VulcanAI PublishTool!' " @@ -807,12 +777,18 @@ class Ros2PublishTool(AtomicTool): ("topic", "string"), # e.g. "/chatter" ("message_data", "string?"), # (optional) payload - string for std_msgs/String or JSON for custom types ("msg_type", "string?"), # (optional) e.g. "std_msgs/msg/String" or "my_pkg/msg/CustomMsg" - ("count", "int?"), # (optional) number of messages to publish + ("max_lines", "int?"), # (optional) number of messages to publish + ("max_duration", "int?"), # (optional) stop after this seconds ("period_sec", "float?"), # (optional) delay between publishes (in seconds) ("message", "string?"), # (deprecated) use message_data instead ] - output_schema = {"published": "bool", "count": "int", "topic": "string"} + output_schema = { + "published": "bool", + "count": "int", + "topic": "string", + "output": "string", + } def msg_from_dict(self, msg, values: dict): """ @@ -843,9 +819,9 @@ def run(self, **kwargs): result = { "published": "False", - "published_msgs": "", "count": "0", "topic": "", + "output": "", } panel_enabled = console is not None and hasattr(console, "show_subprocess_panel") @@ -859,8 +835,13 @@ def run(self, **kwargs): message_data = kwargs.get("message_data", kwargs.get("message", "Hello from VulcanAI PublishTool!")) msg_type_str = kwargs.get("msg_type", "std_msgs/msg/String") - # Number of messages the publisher is going to write. - count = kwargs.get("count", 10) + max_duration = kwargs.get("max_duration", 60) + if not isinstance(max_duration, int): + max_duration = 60 + + max_lines = kwargs.get("max_lines", 200) + if not isinstance(max_lines, int): + max_lines = 200 period_sec = kwargs.get("period_sec", 0.1) @@ -880,23 +861,12 @@ def run(self, **kwargs): console.call_from_thread(console.logger.log_msg, "[ROS] [ERROR] No topic provided.") return result - if count is None or count == "": - count = 10 - else: - try: - count = int(count) - except (TypeError, ValueError): - console.call_from_thread( - console.logger.log_msg, "[ROS] [ERROR] 'count' must be an integer." - ) - return result - result["topic"] = topic_name - if count <= 0: + if max_lines <= 0: # No messages to publish console.call_from_thread( - console.logger.log_msg, "[ROS] [WARN] Count <= 0, nothing to publish." + console.logger.log_msg, "[ROS] [WARN] max_lines <= 0, nothing to publish." ) return result @@ -906,7 +876,7 @@ def run(self, **kwargs): console.set_stream_task(cancel_token) console.logger.log_tool("[tool]Publisher created![tool]", tool_name=self.name) - for _ in range(count): + for _ in range(max_lines): if cancel_token.cancelled(): console.logger.log_tool("[tool]Ctrl+C received:[/tool] stopping publish...", tool_name=self.name) break @@ -959,6 +929,7 @@ def run(self, **kwargs): except Exception: pass + result["subscribed"] = "True" result["published_msgs"] = published_msgs result["count"] = len(published_msgs) return result @@ -967,51 +938,25 @@ def run(self, **kwargs): @vulcanai_tool class Ros2SubscribeTool(AtomicTool): name = "ros_subscribe" - description = "Subscribe to a topic and stop after receiving N messages or a timeout." description = ( - "Subscribe to a given ROS 2 topic and stop after receiving N messages or a timeout." - "By default 100 messages and 300 seconds duration" + "Subscribe to a topic or execute 'ros2 topic echo ' " + "and stop after receiving N messages or max duration." ) tags = ["ros2", "subscribe", "topic", "std_msgs"] input_schema = [ ("topic", "string"), # topic name - ("msg_type", "string"), # e.g. "std_msgs/msg/String" - ("output_format", "string"), # "data" | "dict" - ("count", "int?"), # (optional) stop after this number of messages - ("timeout_sec", "int?"), # (optional) stop after this seconds + ("max_lines", "int?"), # (optional) stop after this number of messages + ("max_duration", "int?"), # (optional) stop after this seconds ] output_schema = { - "subscribed": "False", - "received_msgs": "", - "count": "0", - "topic": "", + "subscribed": "bool", + "count": "int", + "topic": "string", + "output": "string", } - def msg_to_dict(self, msg): - """ - Convert a ROS 2 message instance into a Python dictionary. - - This function recursively converts a ROS 2 message into a dictionary - using ROS 2 Python introspection (`__slots__`). - - Supports: - - Primitive fields - - Nested ROS 2 messages - """ - out = {} - for field in getattr(msg, "__slots__", []): - key = field.lstrip("_") - val = getattr(msg, field) - if hasattr(val, "__slots__"): - out[key] = self.msg_to_dict(val) - elif isinstance(val, (list, tuple)): - out[key] = [self.msg_to_dict(v) if hasattr(v, "__slots__") else v for v in val] - else: - out[key] = val - return out - def run(self, **kwargs): # Ros2 node to create the Publisher and print the log information node = self.bb.get("main_node", None) @@ -1021,134 +966,31 @@ def run(self, **kwargs): console = self.bb.get("console", None) result = { - "published": "False", - "published_msgs": "", + "subscribed": "False", + "subscribed_msgs": "", "count": "0", "topic": "", } - # Check if the console have the function show_subprocess_panel() to open - 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", None) - - msg_type_str = kwargs.get("msg_type", "std_msgs/msg/String") - # Ensure "msg_type_str" is not null or empty to avoid errors - if msg_type_str is None or msg_type_str == "": - msg_type_str = "std_msgs/msg/String" - - count = kwargs.get("count", 100) - timeout_sec = kwargs.get("timeout_sec", 60) - - qos_depth = 10 - - output_format = kwargs.get("output_format", "data") - - def callback(msg: String): - # received_msgs.append(msg.data) - if output_format == "data" and hasattr(msg, "data"): - received_msgs.append(msg.data) - console.call_from_thread(console.logger.log_msg, f"[ROS] [INFO] I heard: [{msg.data}]") - else: - d = self.msg_to_dict(msg) - received_msgs.append(d["data"] if "data" in d else d) - console.call_from_thread(console.logger.log_msg, f"[ROS] [INFO] I heard: [{d['data']}]") - - if console is None: - print("[ERROR] Console not is None") - return result - - try: - if not topic_name: - console.call_from_thread(console.logger.log_msg, "[ROS] [ERROR] No topic provided.") - return result - - if count is None or count == "": - count = 10 - else: - try: - count = int(count) - except (TypeError, ValueError): - console.call_from_thread( - console.logger.log_msg, "[ROS] [ERROR] 'count' must be an integer." - ) - return result - - if timeout_sec is None or timeout_sec == "": - timeout_sec = 60 - else: - try: - timeout_sec = int(timeout_sec) - except (TypeError, ValueError): - console.call_from_thread( - console.logger.log_msg, "[ROS] [ERROR] 'timeout_sec' must be an integer." - ) - return result - + max_duration = kwargs.get("max_duration", 60) + if not isinstance(max_duration, int): + max_duration = 60 + + max_lines = kwargs.get("max_lines", 200) + if not isinstance(max_lines, int): + max_lines = 200 + + # "--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) + result["output"] = last_output_lines(console, self.name, ret, n_lines=10) + + if ret is not None: + result["subscribed"] = "True" + result["count"] = len(ret) result["topic"] = topic_name - if count <= 0: - # No messages to publish - console.call_from_thread( - console.logger.log_msg, "[ROS] [WARN] Count <= 0, nothing to publish." - ) - return result - - topic_name_list_str = run_oneshot_cmd(["ros2", "topic", "list"]) - topic_name_list = topic_name_list_str.splitlines() - # Check if the topic is not available ros2 topic list - # if it is not create a window for the user to choose a correct topic name - suggested_topic_name = suggest_string(console, self.name, "Topic", topic_name, topic_name_list) - if suggested_topic_name is not None: - topic_name = suggested_topic_name - - received_msgs = [] - - MsgType = import_msg_type(msg_type_str, node) - sub = node.create_subscription(MsgType, topic_name, callback, qos_depth) - - start = time.monotonic() - cancel_token = None - - # Console Ctrl+C signal - cancel_token = Future() - console.set_stream_task(cancel_token) - console.logger.log_tool("[tool]Subscription created![tool]", tool_name=self.name) - - while rclpy.ok(): - if cancel_token is not None and cancel_token.cancelled(): - console.logger.log_tool( - "[tool]Ctrl+C received:[/tool] stopping subscription...", tool_name=self.name - ) - break - - # Stop conditions - if len(received_msgs) >= count: - break - if (time.monotonic() - start) >= timeout_sec: - break - - rclpy.spin_once(node, timeout_sec=0.1) - - except KeyboardInterrupt: - console.logger.log_tool("[tool]Ctrl+C received:[/tool] stopping subscription...", tool_name=self.name) - - finally: - console.set_stream_task(None) - if panel_enabled: - if hasattr(console, "change_route_logs"): - console.call_from_thread(console.change_route_logs, False) - console.call_from_thread(console.hide_subprocess_panel) - try: - node.destroy_subscription(sub) - except Exception: - pass - - result["received_msgs"] = received_msgs - result["count"] = len(received_msgs) - return result diff --git a/src/vulcanai/tools/utils.py b/src/vulcanai/tools/utils.py index 6a03eaa..abef914 100644 --- a/src/vulcanai/tools/utils.py +++ b/src/vulcanai/tools/utils.py @@ -17,6 +17,7 @@ import difflib import heapq import subprocess +import threading import time from textual.markup import escape # To remove potential errors in textual terminal @@ -28,6 +29,7 @@ async def run_streaming_cmd_async( # Unpack the command cmd, *cmd_args = args + captured_lines: list[str] = [] process = None try: # Create the subprocess @@ -49,6 +51,14 @@ async def run_streaming_cmd_async( # Print the line if echo: + if args[:3] == ["ros2", "topic", "echo"] and line: + msg = line.strip() + if msg == "---": + continue + msg = msg.strip("'\"") + line = f"[ROS] [INFO] I heard: [{msg}]" + + captured_lines.append(line) line_processed = escape(line) if hasattr(console, "add_subprocess_line"): console.add_subprocess_line(line_processed) @@ -77,7 +87,7 @@ async def run_streaming_cmd_async( console.logger.log_tool("[tool]Cancellation received:[/tool] terminating subprocess...", tool_name=tool_name) if process is not None: process.terminate() - raise + # Not necessary, textual terminal get the keyboard input except KeyboardInterrupt: # Ctrl+C pressed → stop subprocess @@ -98,11 +108,13 @@ async def run_streaming_cmd_async( if hasattr(console, "hide_subprocess_panel"): console.hide_subprocess_panel() - return "Process stopped due to Ctrl+C" + return "\n".join(captured_lines) def execute_subprocess(console, tool_name, base_args, max_duration, max_lines): stream_task = None + done_event = threading.Event() + result = {"output": ""} def _launcher() -> None: nonlocal stream_task @@ -120,37 +132,35 @@ def _launcher() -> None: 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: - 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() + 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) - # result["output"] = False - return + finally: + done_event.set() 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. + # `/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) - 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) + # 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: @@ -242,3 +252,16 @@ def _get_suggestions(real_string_list_comp: list[str], string_comp: str) -> tupl 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:]) From 2d71704ba2f384722363d8d53351720956fc5d47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Ferreira=20Gonz=C3=A1lez?= Date: Wed, 18 Mar 2026 15:30:44 +0100 Subject: [PATCH 20/24] [#23897] Move default node and initialize it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Carlos Ferreira González --- src/vulcanai/console/console.py | 11 ++++- src/vulcanai/tools/__init__.pyi | 9 +++- src/vulcanai/tools/custom_node.py | 64 --------------------------- src/vulcanai/tools/default_tools.py | 68 +++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 67 deletions(-) delete mode 100644 src/vulcanai/tools/custom_node.py diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index da64f85..29084e2 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -213,7 +213,7 @@ async def on_mount(self) -> None: sys.stdout = StreamToTextual(self, "stdout") sys.stderr = StreamToTextual(self, "stderr") - if self.main_node is not None: + if self.main_node is not None or self.default_tools: attach_ros_logger_to_console(self) self.loop = asyncio.get_running_loop() @@ -296,6 +296,15 @@ async def bootstrap(self) -> None: except Exception: pass + # Load a default ROS 2 node if default tools are enabled but no node is provided + if self.default_tools and self.main_node is None: + try: + from vulcanai.tools.default_tools import ROS2DefaultToolNode + + self.main_node = ROS2DefaultToolNode() + except ImportError: + self.logger.log_console("Unable to load ROS 2 default node for default tools.") + # -- Register tools (file I/O - run in executor) -- # File paths tools for tool_file_path in self.register_from_file: diff --git a/src/vulcanai/tools/__init__.pyi b/src/vulcanai/tools/__init__.pyi index 0ded42c..55a6cdf 100644 --- a/src/vulcanai/tools/__init__.pyi +++ b/src/vulcanai/tools/__init__.pyi @@ -12,8 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -from .custom_node import CustomNode from .tool_registry import ToolRegistry, vulcanai_tool from .tools import AtomicTool, CompositeTool, ValidationTool -__all__ = ["AtomicTool", "CompositeTool", "ToolRegistry", "ValidationTool", "vulcanai_tool", "CustomNode"] +__all__ = [ + "AtomicTool", + "CompositeTool", + "ToolRegistry", + "ValidationTool", + "vulcanai_tool", +] diff --git a/src/vulcanai/tools/custom_node.py b/src/vulcanai/tools/custom_node.py deleted file mode 100644 index 4f2edfe..0000000 --- a/src/vulcanai/tools/custom_node.py +++ /dev/null @@ -1,64 +0,0 @@ -import threading - -import rclpy -from rclpy.node import Node -from rclpy.task import Future - - -class CustomNode(Node): - def __init__(self, name: str = "vulcanai_custom_node"): - super().__init__(name) - # 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() - - 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) - - rclpy.spin_until_future_complete(self, future, timeout_sec=timeout_sec) - self.destroy_subscription(sub) - - if future.done(): - return future.result() - return None diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index 1136c1b..4be3258 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -20,6 +20,7 @@ import importlib import json +import threading import time from concurrent.futures import Future @@ -29,10 +30,77 @@ # 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.") + +# 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"): + if not rclpy.ok(): + rclpy.init() + super().__init__(name) + # 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() + + 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) + + rclpy.spin_until_future_complete(self, future, timeout_sec=timeout_sec) + self.destroy_subscription(sub) + + if future.done(): + return future.result() + return None + + """ +Available ROS 2 CLI commands that can be run with the tools in this file: + - ros2 node Commands: info Output information about a node From 8a557fcf651e426618558884b7fa9254ec3a008b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Ferreira=20Gonz=C3=A1lez?= Date: Wed, 18 Mar 2026 15:42:32 +0100 Subject: [PATCH 21/24] [#23897] Improve help message with rerun command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Carlos Ferreira González --- src/vulcanai/console/console.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index 29084e2..675f833 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -517,7 +517,7 @@ def cmd_help(self, _) -> None: "/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" @@ -1051,7 +1051,7 @@ async def on_key(self, event: events.Key) -> None: def set_stream_task(self, input_stream): """ Function used in the tools to set the current streaming task. - with this variable the user can finish the execution of the + With this variable the user can finish the execution of the task by using the signal "Ctrl + C" """ self.stream_task = input_stream From dccc5e5182ad20b86093316e9e20e863f0d5bb01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Ferreira=20Gonz=C3=A1lez?= Date: Wed, 18 Mar 2026 15:42:47 +0100 Subject: [PATCH 22/24] [#23897] Fix bug in several tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Carlos Ferreira González --- src/vulcanai/tools/default_tools.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index 4be3258..1f5dfb8 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -311,19 +311,19 @@ def run(self, **kwargs): elif command == "bw": base_args = ["ros2", "topic", "bw", topic_name] ret = execute_subprocess(console, self.name, base_args, max_duration, max_lines) - result["output"] = last_output_lines(console, self.name, ret, max_lines=10) + result["output"] = last_output_lines(console, self.name, ret, n_lines=10) # -- ros2 topic delay ------------------------------------ elif command == "delay": base_args = ["ros2", "topic", "delay", topic_name] ret = execute_subprocess(console, self.name, base_args, max_duration, max_lines) - result["output"] = last_output_lines(console, self.name, ret, max_lines=10) + result["output"] = last_output_lines(console, self.name, ret, n_lines=10) # -- ros2 topic hz --------------------------------------- elif command == "hz": base_args = ["ros2", "topic", "hz", topic_name] ret = execute_subprocess(console, self.name, base_args, max_duration, max_lines) - result["output"] = last_output_lines(console, self.name, ret, max_lines=10) + result["output"] = last_output_lines(console, self.name, ret, n_lines=10) # -- unknown ---------------------------------------------------------- else: @@ -437,7 +437,7 @@ def run(self, **kwargs): elif command == "echo": base_args = ["ros2", "service", "echo", service_name] ret = execute_subprocess(console, self.name, base_args, max_duration, max_lines) - result["output"] = last_output_lines(console, self.name, ret, max_lines=10) + result["output"] = last_output_lines(console, self.name, ret, n_lines=10) # -- unknown ------------------------------------------------------------ else: From d5be74bc4c2ec4bd0af0c1c79381356be30d99d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Ferreira=20Gonz=C3=A1lez?= Date: Wed, 18 Mar 2026 16:24:28 +0100 Subject: [PATCH 23/24] [#23897] Add default tools tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Carlos Ferreira González --- src/vulcanai/tools/default_tools.py | 2 +- tests/unittest/test_default_tools.py | 438 +++++++++++++++++++++++++++ 2 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 tests/unittest/test_default_tools.py diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index 1f5dfb8..841e6fa 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -229,7 +229,7 @@ class Ros2TopicTool(AtomicTool): name = "ros2_topic" description = ( "Wrapper for `ros2 topic` CLI." - "Run any subcommand: 'list', 'info', 'find', 'type', 'bw', 'delay', 'hz', 'pub'." + "Run a subcommand from: 'list', 'info', 'find', 'type', 'bw', 'delay', 'hz'." "With optional arguments like 'topic_name', 'message_type', 'max_duration' or 'max_lines'" ) tags = ["ros2", "topics", "cli", "info"] diff --git a/tests/unittest/test_default_tools.py b/tests/unittest/test_default_tools.py new file mode 100644 index 0000000..58c4871 --- /dev/null +++ b/tests/unittest/test_default_tools.py @@ -0,0 +1,438 @@ +# 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_topic). + +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) + + def stop(self): + self._loop.call_soon_threadsafe(self._loop.stop) + self._thread.join(timeout=5) + + +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 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() + + +# =========================================================================== +# Test class +# =========================================================================== +@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") + + +if __name__ == "__main__": + unittest.main() From 3efd9e41f1dc6e78f55e05ece709a983baf94708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Ferreira=20Gonz=C3=A1lez?= Date: Wed, 18 Mar 2026 16:59:35 +0100 Subject: [PATCH 24/24] [#23897] Add default tools workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Carlos Ferreira González --- .github/workflows/tests.yml | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 757b8f4..3fa0410 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -35,7 +35,28 @@ jobs: - name: Run unit tests run: | - python -m unittest discover -s tests/unittest -t . -p "test*.py" -v + python -m unittest discover -s tests/unittest -t . -p "test*.py" --ignore-patterns "test_default_tools.py" -v + + ros2_unittests: + name: ROS 2 unit tests (default tools) + runs-on: ubuntu-24.04 + container: + image: eprosima/vulcanexus:kilted-desktop + + steps: + - name: Sync repository + uses: eProsima/eProsima-CI/external/checkout@v0 + + - name: Install VulcanAI library + run: | + python3 -m pip install -U pip --break-system-packages + python3 -m pip install -e .[test] --break-system-packages + + - name: Run ROS 2 default tools tests + shell: bash + run: | + source /opt/ros/jazzy/setup.bash + python3 -m unittest discover -s tests/unittest -t . -p "test_default_tools.py" -v integration: name: Integration tests (pytest)