From 9cb084c6c4747164cf1f9bac544e515747a8df92 Mon Sep 17 00:00:00 2001 From: danipiza Date: Wed, 3 Dec 2025 16:24:32 +0100 Subject: [PATCH 01/55] [#23896] Initial commit - ros2 cli commands Signed-off-by: danipiza --- src/vulcanai/console/utils.py | 26 +- src/vulcanai/tools/default_tools.py | 726 ++++++++++++++++++++++++++++ 2 files changed, 750 insertions(+), 2 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 1d00da9..6cc8d94 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 SpinnerHook: """ @@ -66,6 +70,23 @@ def flush(self): self.real_stream.flush() + +class SpinnerHook: + """ + Single entrant spinner controller for console. + - Starts the spinner on the LLM request. + - Stops the spinner when LLM request is over. + """ + + def __init__(self, spinner_status): + self.spinner_status = spinner_status + + def on_request_start(self, text="Querying LLM..."): + self.spinner_status.start(text) + + 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) @@ -157,10 +178,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 @@ -230,6 +251,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 c50f365c79fba481130792a4db50b4efe73b7f0f Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 4 Dec 2025 09:07:30 +0100 Subject: [PATCH 02/55] [#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 064ceab..bd3b1fe 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -42,6 +42,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.""" @@ -195,6 +199,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 = [] @@ -383,8 +408,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 6cc8d94..780e589 100644 --- a/src/vulcanai/console/utils.py +++ b/src/vulcanai/console/utils.py @@ -207,8 +207,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) @@ -234,8 +233,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) @@ -251,7 +248,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 89ba038f773e4aedfa50707502b4642cdcb3eca0 Mon Sep 17 00:00:00 2001 From: danipiza Date: Fri, 5 Dec 2025 13:44:29 +0100 Subject: [PATCH 03/55] [#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 bd3b1fe..64c13b5 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -42,9 +42,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: @@ -206,7 +211,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 780e589..94ca8a7 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 SpinnerHook: """ @@ -146,7 +147,6 @@ def patched_log(self, msg, level, *args, **kwargs): RcutilsLogger.log = patched_log RcutilsLogger._textual_patched = True - # endregion # region TEXTUAL @@ -181,7 +181,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 42fe120548159bac8a6d204df27cf75f91cdf8f4 Mon Sep 17 00:00:00 2001 From: danipiza Date: Fri, 19 Dec 2025 14:07:20 +0100 Subject: [PATCH 04/55] [#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 64c13b5..38207fb 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -204,8 +204,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 d47a4f5a2593d4d3f30fcfb0e5140b8e1faa9799 Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 8 Jan 2026 10:27:57 +0100 Subject: [PATCH 05/55] [#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 a932a863a48101d8001a0f042c34cb490a552b37 Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 8 Jan 2026 14:21:38 +0100 Subject: [PATCH 06/55] [#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 5b81b66b841b39d559fdabb371b920d6441a1879 Mon Sep 17 00:00:00 2001 From: danipiza Date: Tue, 27 Jan 2026 10:19:58 +0100 Subject: [PATCH 07/55] [#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 38207fb..c82822a 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -204,8 +204,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 e92d15cecc69e119dbf785362d6a9246e1f5b099 Mon Sep 17 00:00:00 2001 From: danipiza Date: Wed, 28 Jan 2026 12:31:38 +0100 Subject: [PATCH 08/55] [#23897] Fixed rebase changes Signed-off-by: danipiza --- src/vulcanai/console/console.py | 19 ++++++------------- src/vulcanai/console/utils.py | 10 +++------- 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index c82822a..fc91f6a 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -43,15 +43,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.""" @@ -205,11 +196,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): @@ -315,7 +309,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 94ca8a7..e803093 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 SpinnerHook: """ @@ -88,6 +82,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) @@ -178,6 +173,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: @@ -206,7 +202,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 e427daff92fb9a392fd0c1d4f2f9ce84ef39ea91 Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 29 Jan 2026 16:45:13 +0100 Subject: [PATCH 09/55] [#23897] Applied revision Signed-off-by: danipiza --- pyproject.toml | 2 +- src/vulcanai/console/console.py | 7 +- src/vulcanai/console/utils.py | 4 +- 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, 261 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 fc91f6a..9ad61ae 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -148,6 +148,7 @@ def __init__( tools_from_entrypoints: str = "", user_context: str = "", main_node=None, + default_tools: bool = True, ): # Used to set the same textual colors in a docker container os.environ.setdefault("COLORTERM", "truecolor") @@ -172,6 +173,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 @@ -358,7 +361,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) @@ -1154,7 +1157,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 e803093..9991f45 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. @@ -173,7 +173,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: @@ -371,5 +370,4 @@ def _get_suggestions(real_string_list_comp: list[str], string_comp: str) -> tupl return ret - # endregion 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 901f442..14445e2 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 0093057643d2eec9dfa39e9fafacbbe814e072da Mon Sep 17 00:00:00 2001 From: danipiza Date: Mon, 2 Feb 2026 08:19:30 +0100 Subject: [PATCH 10/55] [#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 e8afc63195bf185a065790e1152a7ecaecf6f8b6 Mon Sep 17 00:00:00 2001 From: danipiza Date: Mon, 2 Feb 2026 07:55:34 +0100 Subject: [PATCH 11/55] [#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 14445e2..0cbab74 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 4230d5559f39af31e509014d345fcddb2b7a5122 Mon Sep 17 00:00:00 2001 From: danipiza Date: Wed, 4 Feb 2026 11:30:57 +0100 Subject: [PATCH 12/55] [#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 9ad61ae..61b626e 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -173,7 +173,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 @@ -1157,7 +1157,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 0cbab74..d2fa2fc 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 99fcc8bbf82bbe3278c656eb69022b4224f42ed3 Mon Sep 17 00:00:00 2001 From: danipiza Date: Tue, 10 Feb 2026 16:11:38 +0100 Subject: [PATCH 13/55] [#23897] Applied Ruff + notify error in textual pop-up Signed-off-by: danipiza --- src/vulcanai/console/console.py | 27 +- .../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 +++++++----------- 6 files changed, 120 insertions(+), 203 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index 61b626e..6c358c3 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -208,29 +208,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 - - 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() - self._gnome_profile_schema: str | None = None self._gnome_scrollbar_policy_backup: str | None = None @@ -408,10 +385,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/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 49826fb51b8ec52f4a1efecc31433d3f7896320e Mon Sep 17 00:00:00 2001 From: danipiza Date: Tue, 10 Feb 2026 16:18:14 +0100 Subject: [PATCH 14/55] [#23897] Applied Ruff after rebase Signed-off-by: danipiza --- src/vulcanai/console/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/vulcanai/console/utils.py b/src/vulcanai/console/utils.py index 9991f45..07a20ba 100644 --- a/src/vulcanai/console/utils.py +++ b/src/vulcanai/console/utils.py @@ -173,6 +173,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: @@ -370,4 +371,5 @@ def _get_suggestions(real_string_list_comp: list[str], string_comp: str) -> tupl return ret + # endregion From c1d155ae6f22fba5e8951516d019c323eef40857 Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 19 Feb 2026 11:09:05 +0100 Subject: [PATCH 15/55] [#23897] Applied revision Signed-off-by: danipiza --- src/vulcanai/console/console.py | 86 +++++++++++++++++-- src/vulcanai/console/modal_screens.py | 7 +- .../console/widget_custom_log_text_area.py | 14 ++- src/vulcanai/core/executor.py | 1 + src/vulcanai/tools/utils.py | 57 ++++++++---- 5 files changed, 134 insertions(+), 31 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index 6c358c3..172d05b 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -94,6 +94,13 @@ class VulcanConsole(App): color: #ffffff; } + #streamcontent { + height: 0; + min-height: 0; + border: tall #56AA08; + display: none; + } + #llm_spinner { height: 0; display: none; @@ -178,7 +185,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)) @@ -204,6 +213,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() @@ -229,7 +240,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) @@ -264,6 +276,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 @@ -490,17 +505,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) @@ -700,7 +716,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() @@ -709,6 +727,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. @@ -722,20 +790,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/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 8977a6c24ae2e931e5657a776ac5bdbeaf5a40e0 Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 19 Feb 2026 15:51:00 +0100 Subject: [PATCH 16/55] [#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 172d05b..ba40c9c 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -58,6 +58,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; @@ -97,7 +119,7 @@ class VulcanConsole(App): #streamcontent { height: 0; min-height: 0; - border: tall #56AA08; + border: solid #56AA08; display: none; } @@ -740,17 +762,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 9c246471aa08759fc3af4dc1690bc41924816d36 Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 19 Feb 2026 16:32:53 +0100 Subject: [PATCH 17/55] [#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 | 2 - src/vulcanai/tools/default_tools.py | 58 +++++++++++++++------------ src/vulcanai/tools/utils.py | 2 +- 5 files changed, 37 insertions(+), 32 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index ba40c9c..2680c79 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -59,7 +59,6 @@ class VulcanConsole(App): # Right panel: 48 characters length # Left panel: fills remaining space - # #right { # width: 48; # layout: vertical; @@ -527,7 +526,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 07a20ba..79efced 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 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 93dfffcd5afab1561a7870fb70c3eb6794c334ae Mon Sep 17 00:00:00 2001 From: danipiza Date: Tue, 24 Feb 2026 15:10:52 +0100 Subject: [PATCH 18/55] [#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 2680c79..6473502 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -59,26 +59,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 ec51e63e202dd62ab50374e84069910cc98e5446 Mon Sep 17 00:00:00 2001 From: danipiza Date: Tue, 3 Mar 2026 07:36:11 +0100 Subject: [PATCH 19/55] [#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 6473502..4784cf8 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -686,12 +686,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 79efced..5397dee 100644 --- a/src/vulcanai/console/utils.py +++ b/src/vulcanai/console/utils.py @@ -75,10 +75,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): @@ -253,6 +261,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(): @@ -270,18 +280,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 e825905c2996d54d053aac86c58123cbf0db52da 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/55] [#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 4784cf8..97af1e2 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -251,7 +251,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() @@ -334,6 +334,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 7eecf454d74523d256371defb1e4bd6e8d2465b1 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/55] [#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 97af1e2..ffe9b49 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -555,7 +555,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" @@ -1089,7 +1089,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 2cdcd72070b2406d736428e71ba9862c1eabec8f 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/55] [#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 025c0a5b42be4197283615a251cb6ea2f1a4ade0 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/55] [#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 f981da87defcdf492560f84a2bd7ac347720f972 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/55] [#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) From 75024edb29e41c1778d441e135af0e0ab685d2f0 Mon Sep 17 00:00:00 2001 From: danipiza Date: Tue, 24 Mar 2026 16:17:32 +0100 Subject: [PATCH 25/55] [#23897] Discussed upgrades Signed-off-by: danipiza --- src/vulcanai/console/console.py | 94 +++++++++- src/vulcanai/console/logger.py | 20 ++- src/vulcanai/console/utils.py | 6 - .../console/widget_custom_log_text_area.py | 4 +- src/vulcanai/tools/default_tools.py | 163 ++++++++++++++---- src/vulcanai/tools/utils.py | 81 +++++++-- 6 files changed, 301 insertions(+), 67 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index ffe9b49..0e80959 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -157,6 +157,7 @@ def __init__( user_context: str = "", main_node=None, default_tools: bool = True, + debug: bool = False, ): # Used to set the same textual colors in a docker container os.environ.setdefault("COLORTERM", "truecolor") @@ -185,6 +186,8 @@ def __init__( self.default_tools = default_tools # Iterative mode self.iterative = iterative + # Enable debug-only logs + self.debug_flag = debug # CustomLogTextArea instance self.main_pannel = None # Subprocess output panel @@ -315,6 +318,7 @@ async def bootstrap(self) -> None: "/edit_tools": self.cmd_edit_tools, "/change_k": self.cmd_change_k, "/history": self.cmd_history_index, + "/debug": self.cmd_debug, "/show_history": self.cmd_show_history, "/clear_history": self.cmd_clear_history, "/plan": self.cmd_plan, @@ -363,6 +367,7 @@ async def bootstrap(self) -> None: self.is_ready = True self.logger.log_console("VulcanAI Interactive Console") + self.logger.log_console("Clipboard: select text and press F4 to copy. Use Ctrl+V or middle-click to paste.") self.logger.log_console("Use '/exit' or press 'Ctrl+Q' to quit.") # Activate the terminal input @@ -385,6 +390,7 @@ def worker(user_input: str = "") -> None: self.set_input_enabled(False) try: + bb_before_ids = {k: id(v) for k, v in self.manager.bb.items()} images = [] # Add the images @@ -405,9 +411,13 @@ def worker(user_input: str = "") -> None: # Print the backboard state bb_ret = result.get("blackboard", None) - if bb_ret: - bb_ret = bb_ret.text_snapshot().replace("<", "'").replace(">", "'") - self.logger.log_console(f"Output of plan: {bb_ret}") + if bb_ret and self.debug_flag: + plan_ret = result.get("plan", None) + updated_keys = self._updated_blackboard_keys(bb_before_ids, bb_ret) + if not updated_keys: + updated_keys = self._plan_tool_keys(plan_ret) + bb_ret_str = self._format_blackboard_subset(bb_ret, updated_keys) + self.logger.log_console(f"Output of plan: {bb_ret_str}") except KeyboardInterrupt: if self.stream_task is None: @@ -427,6 +437,47 @@ def worker(user_input: str = "") -> None: # region Utilities + def _updated_blackboard_keys(self, before_ids: dict[str, int], bb_after) -> list[str]: + """ + Return blackboard keys that were created or replaced during the latest execution. + """ + updated_keys = [] + for key, value in bb_after.items(): + if key not in before_ids or before_ids[key] != id(value): + updated_keys.append(key) + return updated_keys + + def _plan_tool_keys(self, plan) -> list[str]: + """ + Return ordered unique tool names declared in the main plan path. + """ + if plan is None or not hasattr(plan, "plan"): + return [] + + seen = set() + keys = [] + for node in getattr(plan, "plan", []): + for step in getattr(node, "steps", []): + tool_name = getattr(step, "tool", None) + if tool_name and tool_name not in seen: + seen.add(tool_name) + keys.append(tool_name) + return keys + + def _format_blackboard_subset(self, bb, keys: list[str]) -> str: + """ + Format a subset of blackboard keys for debug logging in Textual console. + """ + if not keys: + return "{}" + + subset = {k: bb.get(k) for k in keys if k in bb} + if not subset: + return "{}" + + # Keep output on a single line while avoiding Textual tag parsing issues. + return repr(subset).replace("<", "'").replace(">", "'") + def _apply_history_to_input(self) -> None: """ Function used to apply the current history index to the input box. @@ -552,6 +603,8 @@ def cmd_help(self, _) -> None: " or show the current value if no 'int' is provided\n" "/history 'int' - Change the history depth or show the current value if no" " 'int' is provided\n" + "/debug 'bool' - Set debug mode to true/false, or show current value if no" + " bool is provided\n" "/show_history - Show the current history\n" "/clear_history - Clear the history\n" "/plan - Show the last generated plan\n" @@ -567,7 +620,7 @@ def cmd_help(self, _) -> None: "Available keybinds:\n" "‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\n" "F2 - Show this help message\n" - "F3 - Copy selection area\n" + "F4 - Copy selection area\n" "Ctrl+Q - Exit the console\n" "Ctrl+L - Clears the console screen\n" "Ctrl+U - Clears the entire command line input\n" @@ -629,6 +682,23 @@ def cmd_history_index(self, args) -> None: # Update right panel info self._update_variables_panel() + def cmd_debug(self, args) -> None: + if len(args) == 0: + self.logger.log_console(f"Current 'debug' is {self.debug_flag}") + return + + if len(args) != 1: + self.logger.log_console(f"Usage: /debug 'bool' - Actual 'debug' is {self.debug_flag}") + return + + value = args[0].strip().lower() + if value not in ("true", "false"): + self.logger.log_console(f"Usage: /debug 'bool' - Actual 'debug' is {self.debug_flag}") + return + + self.debug_flag = value == "true" + self.logger.log_console(f"Set 'debug' to {self.debug_flag}") + def cmd_show_history(self, _) -> None: if not self.manager.history: self.logger.log_console("No history available.") @@ -716,7 +786,8 @@ async def _rerun_worker(self, args) -> None: # UI updates must happen on the app thread: def apply_result(): self.last_bb = last_bb - self.logger.log_console(f"Output of rerun: {last_bb_parsed}") + if self.debug_flag: + self.logger.log_console(f"Output of rerun: {last_bb_parsed}") self.call_from_thread(apply_result) @@ -749,6 +820,8 @@ def show_subprocess_panel(self) -> None: if self.stream_pannel is None: return + self.logger.log_tool("Streaming terminal opened. Press Ctrl+C to stop the running process.", color="tool") + self.stream_pannel.clear_console() self.stream_pannel.display = True self.stream_pannel.styles.height = 12 @@ -1151,6 +1224,15 @@ def action_stop_streaming_task(self) -> None: # Cancel the streaming task self.stream_task.cancel() # Triggers CancelledError in the task self.stream_task = None + # Close popup terminal explicitly on user Ctrl+C. + self.change_route_logs(False) + self.hide_subprocess_panel() + + elif self.stream_pannel is not None and self.stream_pannel.display: + # No active stream, but popup is still visible (e.g. stopped by limits). + # Let Ctrl+C close it explicitly. + self.change_route_logs(False) + self.hide_subprocess_panel() else: # No streaming task running, just notify the user @@ -1264,6 +1346,7 @@ def main() -> None: parser.add_argument( "-i", "--iterative", action="store_true", default=False, help="Enable Iterative Manager (default: off)" ) + parser.add_argument("--debug", action="store_true", default=False, help="Enable debug logs (default: off)") args = parser.parse_args() @@ -1273,6 +1356,7 @@ def main() -> None: model=args.model, k=args.k, iterative=args.iterative, + debug=args.debug, ) console.run_console() diff --git a/src/vulcanai/console/logger.py b/src/vulcanai/console/logger.py index 9c781d5..d3ce661 100644 --- a/src/vulcanai/console/logger.py +++ b/src/vulcanai/console/logger.py @@ -107,6 +107,20 @@ def parse_rich_markup(self, msg: str) -> str: msg = re.sub(r"<(/?)(#[0-9a-fA-F]{6})>", r"[\1\2]", msg) return msg + def _apply_color_to_each_line(self, msg: str, color_begin: str, color_end: str) -> str: + """Wrap each logical line with color tags, preserving original line breaks.""" + if msg == "": + return f"{color_begin}{msg}{color_end}" + + colored_lines = [] + for line in msg.splitlines(keepends=True): + # Keep newlines outside style tags so each line has a complete ... pair. + line_content = line.rstrip("\r\n") + line_break = line[len(line_content) :] + colored_lines.append(f"{color_begin}{line_content}{color_end}{line_break}") + + return "".join(colored_lines) + def process_msg(self, msg: str, prefix: str = "", color: str = "") -> str: """Process the message by adding prefix, applying color formatting and rich markup if enabled.""" color_begin = color_end = color @@ -116,7 +130,9 @@ def process_msg(self, msg: str, prefix: str = "", color: str = "") -> str: color_begin = f"<{color}>" color_end = f"" - msg = f"{prefix}{color_begin}{msg}{color_end}" + msg = self._apply_color_to_each_line(msg, color_begin, color_end) + + msg = f"{prefix}{msg}" return self.parse_rich_markup((self.parse_color(msg))) @@ -158,6 +174,8 @@ def log_tool(self, msg: str, tool_name: str = "", error: bool = False, color: st processed_msg = self.process_msg(msg, prefix=prefix, color=color) self.sink.write(processed_msg) + return processed_msg + def log_registry(self, msg: str, error: bool = False, color: str = ""): if error: prefix = "[error][REGISTRY] [ERROR][/error] " diff --git a/src/vulcanai/console/utils.py b/src/vulcanai/console/utils.py index 5397dee..98f4a84 100644 --- a/src/vulcanai/console/utils.py +++ b/src/vulcanai/console/utils.py @@ -12,14 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. - -import asyncio -import subprocess import sys import threading -import time - -from textual.markup import escape # To remove potential errors in textual terminal class SpinnerHook: """ diff --git a/src/vulcanai/console/widget_custom_log_text_area.py b/src/vulcanai/console/widget_custom_log_text_area.py index bc624a4..4168a12 100644 --- a/src/vulcanai/console/widget_custom_log_text_area.py +++ b/src/vulcanai/console/widget_custom_log_text_area.py @@ -35,7 +35,7 @@ class CustomLogTextArea(TextArea): """ BINDINGS = [ - ("f3", "copy_selection", "Copy selection"), + ("f4", "copy_selection", "Copy selection"), ] # Maximum number of lines to keep in the log @@ -214,7 +214,7 @@ def _ensure_style_token(self, style: str) -> str: # Gray color is not supported if st == "gray": - color = "#8D8D8D" + color = "#A5A2A2" token_parts.append("hex_" + color[1:]) else: # Assume named color like "red", "yellow" diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index 841e6fa..5274a69 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -25,7 +25,7 @@ from concurrent.futures import Future from vulcanai import AtomicTool, vulcanai_tool -from vulcanai.tools.utils import execute_subprocess, last_output_lines, run_oneshot_cmd, suggest_string +from vulcanai.tools.utils import execute_subprocess, run_oneshot_cmd, suggest_string, print_tool_output, log_tool_in_stream_and_main # ROS2 imports try: @@ -221,6 +221,7 @@ def run(self, **kwargs): info_output = run_oneshot_cmd(["ros2", "node", "info", node_name]) result["output"] = info_output + print_tool_output(console, result["output"], self.name) return result @@ -257,8 +258,8 @@ def run(self, **kwargs): 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) + max_duration = kwargs.get("max_duration", None) + max_lines = kwargs.get("max_lines", None) result = { "output": "", @@ -311,19 +312,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, n_lines=10) + result["output"] = ret#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, n_lines=10) + result["output"] = ret#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, n_lines=10) + result["output"] = ret#last_output_lines(console, self.name, ret, n_lines=10) # -- unknown ---------------------------------------------------------- else: @@ -331,6 +332,7 @@ def run(self, **kwargs): f"Unknown command '{command}'. Expected one of: list, info, echo, bw, delay, hz, find, pub, type." ) + print_tool_output(console, result["output"], self.name) return result @@ -371,8 +373,8 @@ 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_lines = kwargs.get("max_lines", 50) + max_duration = kwargs.get("max_duration", None) + max_lines = kwargs.get("max_lines", None) result = { "output": "", @@ -437,12 +439,13 @@ 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, n_lines=10) + result["output"] = ret#last_output_lines(console, self.name, ret, n_lines=10) # -- unknown ------------------------------------------------------------ else: raise ValueError(f"Unknown command '{command}'. Expected one of: list, info, type, call, echo, find.") + print_tool_output(console, result["output"], self.name) return result @@ -533,6 +536,7 @@ def run(self, **kwargs): else: raise ValueError(f"Unknown command '{command}'. Expected one of: list, info, type, send_goal.") + print_tool_output(console, result["output"], self.name) return result @@ -663,6 +667,7 @@ def run(self, **kwargs): f"Unknown command '{command}'. Expected one of: list, get, describe, set, delete, dump, load." ) + print_tool_output(console, result["output"], self.name) return result @@ -683,6 +688,11 @@ class Ros2PkgTool(AtomicTool): } def run(self, **kwargs): + # Used in the suggestion string + 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 command = kwargs.get("command", None) result = { @@ -705,6 +715,7 @@ def run(self, **kwargs): else: raise ValueError(f"Unknown command '{command}'. Expected one of: list, executables, prefix, xml") + print_tool_output(console, result["output"], self.name) return result @@ -799,6 +810,7 @@ def run(self, **kwargs): f"Unknown command '{command}'. Expected one of: list, info, echo, bw, delay, hz, find, pub, type." ) + print_tool_output(console, result["output"], self.name) return result @@ -830,11 +842,11 @@ 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 . " - "Or execute 'ros2 topic pub '. " + "Publish one or more messages to a given ROS 2 topic [topic_name]. " + "Or execute 'ros2 topic pub [topic_name]'. " "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!' " + "By default it keeps publishing messages until Ctrl+C is pressed. " "with type 'std_msgs/msg/String' in topic '/chatter' " "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"}\'' @@ -903,13 +915,15 @@ 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") - max_duration = kwargs.get("max_duration", 60) - if not isinstance(max_duration, int): - max_duration = 60 + max_duration = kwargs.get("max_duration", None) + if max_duration is not None and not isinstance(max_duration, (int, float)): + max_duration = None + if max_duration is not None and max_duration <= 0: + max_duration = None - max_lines = kwargs.get("max_lines", 200) - if not isinstance(max_lines, int): - max_lines = 200 + max_lines = kwargs.get("max_lines", None) + if max_lines is not None and not isinstance(max_lines, int): + max_lines = None period_sec = kwargs.get("period_sec", 0.1) @@ -921,6 +935,7 @@ def run(self, **kwargs): return result published_msgs = [] + output_lines = [] publisher = None cancel_token = None @@ -931,7 +946,7 @@ def run(self, **kwargs): result["topic"] = topic_name - if max_lines <= 0: + if max_lines is not None and max_lines <= 0: # No messages to publish console.call_from_thread( console.logger.log_msg, "[ROS] [WARN] max_lines <= 0, nothing to publish." @@ -943,10 +958,37 @@ def run(self, **kwargs): cancel_token = Future() console.set_stream_task(cancel_token) console.logger.log_tool("[tool]Publisher created![tool]", tool_name=self.name) + #output_lines.append(f"[TOOL {self.name}] Publisher created!") + log_tool_in_stream_and_main( + console, + "[tool]Publisher created![tool]", + tool_name=self.name, + ) - for _ in range(max_lines): + start_time = time.monotonic() + published_count = 0 + + while True: if cancel_token.cancelled(): - console.logger.log_tool("[tool]Ctrl+C received:[/tool] stopping publish...", tool_name=self.name) + log_tool_in_stream_and_main( + console, + "[tool]Ctrl+C received:[/tool] stopping publish...", + tool_name=self.name, + ) + break + if max_lines is not None and published_count >= max_lines: + log_tool_in_stream_and_main( + console, + f"[tool]Stopping:[/tool] Reached max_lines = {max_lines}", + tool_name=self.name, + ) + break + if max_duration is not None and (time.monotonic() - start_time) >= max_duration: + log_tool_in_stream_and_main( + console, + f"[tool]Stopping:[/tool] Exceeded max_duration = {max_duration}s", + tool_name=self.name, + ) break msg = MsgType() @@ -969,16 +1011,20 @@ def run(self, **kwargs): return result if hasattr(msg, "data"): + publish_line = f"[ROS] [INFO] Publishing: '{msg.data}'" console.call_from_thread( - console.logger.log_msg, f"[ROS] [INFO] Publishing: '{msg.data}'" + console.logger.log_msg, f"{publish_line}" ) else: + publish_line = f"[ROS] [INFO] Publishing custom message to '{topic_name}'" console.call_from_thread( console.logger.log_msg, - f"[ROS] [INFO] Publishing custom message to '{topic_name}'", + f"{publish_line}", ) + output_lines.append(publish_line) publisher.publish(msg) published_msgs.append(msg.data if hasattr(msg, "data") else str(msg)) + published_count += 1 rclpy.spin_once(node, timeout_sec=0.05) @@ -990,16 +1036,25 @@ def run(self, **kwargs): 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["subscribed"] = "True" - result["published_msgs"] = published_msgs - result["count"] = len(published_msgs) + result["output"] = "\n".join(output_lines) + + if published_msgs is not None: + result["published"] = "True" + result["count"] = len(published_msgs) + + print_tool_output(console, result["output"], self.name) + + # if panel_enabled: + # console.logger.log_msg(result["output"], color="gray") + # console.logger.log_msg("\n") + # else: + # print_tool_output(console, result["output"], self.name) return result @@ -1007,7 +1062,7 @@ def run(self, **kwargs): class Ros2SubscribeTool(AtomicTool): name = "ros_subscribe" description = ( - "Subscribe to a topic or execute 'ros2 topic echo ' " + "Subscribe to a topic [topic] or execute 'ros2 topic echo [topic]' " "and stop after receiving N messages or max duration." ) tags = ["ros2", "subscribe", "topic", "std_msgs"] @@ -1035,30 +1090,62 @@ def run(self, **kwargs): result = { "subscribed": "False", - "subscribed_msgs": "", "count": "0", "topic": "", + "output": "", } topic_name = kwargs.get("topic", None) - max_duration = kwargs.get("max_duration", 60) - if not isinstance(max_duration, int): - max_duration = 60 + max_duration = kwargs.get("max_duration", None) + if max_duration is not None and not isinstance(max_duration, (int, float)): + max_duration = None - max_lines = kwargs.get("max_lines", 200) - if not isinstance(max_lines, int): - max_lines = 200 + max_lines = kwargs.get("max_lines", None) + if max_lines is not None and not isinstance(max_lines, int): + max_lines = None + + output_lines = [] + panel_enabled = console is not None and hasattr(console, "change_route_logs") + if panel_enabled: + console.call_from_thread(console.change_route_logs, True) + + # Start line (stream panel/main panel with tool color) + console.logger.log_tool("[tool]Subscriber created![/tool]", tool_name=self.name) # "--field data" prints only the data field from each message # instead of the full YAML message # "--no-arr" do not print array fields of messages base_args = ["ros2", "topic", "echo", topic_name, "--field", "data", "--no-arr"] - ret = execute_subprocess(console, self.name, base_args, max_duration, max_lines) - result["output"] = last_output_lines(console, self.name, ret, n_lines=10) + ret = execute_subprocess(console, self.name, base_args, max_duration, max_lines, log_created=False) + + ret_lines = ret.splitlines() if isinstance(ret, str) and ret else [] + + + result["output"] = "\n".join(ret_lines) if ret is not None: result["subscribed"] = "True" - result["count"] = len(ret) + result["count"] = len(ret_lines) result["topic"] = topic_name + print_tool_output(console, result["output"], self.name) + + # if panel_enabled: + # console.call_from_thread(console.change_route_logs, False) + # if ret_lines: + # console.logger.log_msg("\n".join(f"{line}" for line in ret_lines)) + # # if stop_reason: + # # if max_lines is not None and len(ret_lines) >= max_lines: + # # console.logger.log_tool( + # # f"[tool]Stopping:[/tool] Reached max_lines = {max_lines}", + # # tool_name=self.name, + # # ) + # # elif max_duration is not None: + # # console.logger.log_tool( + # # f"[tool]Stopping:[/tool] Exceeded max_duration = {max_duration}s", + # # tool_name=self.name, + # # ) + # console.logger.log_msg("\n") + # else: + # print_tool_output(console, result["output"], self.name) return result diff --git a/src/vulcanai/tools/utils.py b/src/vulcanai/tools/utils.py index abef914..d643793 100644 --- a/src/vulcanai/tools/utils.py +++ b/src/vulcanai/tools/utils.py @@ -24,7 +24,12 @@ async def run_streaming_cmd_async( - console, args: list[str], max_duration: float = 60, max_lines: int = 1000, echo: bool = True, tool_name="" + console, + args: list[str], + max_duration: float | None = None, + max_lines: int | None = None, + echo: bool = True, + tool_name="", ) -> str: # Unpack the command cmd, *cmd_args = args @@ -48,6 +53,8 @@ async def run_streaming_cmd_async( # Subprocess main loop. Read line by line async for raw_line in process.stdout: line = raw_line.decode(errors="ignore").rstrip("\n") + captured_line = line + display_line = line # Print the line if echo: @@ -56,10 +63,11 @@ async def run_streaming_cmd_async( if msg == "---": continue msg = msg.strip("'\"") - line = f"[ROS] [INFO] I heard: [{msg}]" + captured_line = f"[ROS] [INFO] I heard: '{msg}'" + display_line = f"{captured_line}" - captured_lines.append(line) - line_processed = escape(line) + captured_lines.append(captured_line) + line_processed = display_line if display_line != captured_line else escape(display_line) if hasattr(console, "add_subprocess_line"): console.add_subprocess_line(line_processed) else: @@ -68,14 +76,19 @@ async def run_streaming_cmd_async( # 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) + log_tool_in_stream_and_main( + console, + f"[tool]Stopping:[/tool] Reached max_lines = {max_lines}", + tool_name=tool_name, + ) console.set_stream_task(None) process.terminate() break # Check duration - if max_duration and (time.monotonic() - start_time) >= max_duration: - console.logger.log_tool( + if max_duration is not None and (time.monotonic() - start_time) >= max_duration: + log_tool_in_stream_and_main( + console, f"[tool]Stopping:[/tool] Exceeded max_duration = {max_duration}s", tool_name=tool_name ) console.set_stream_task(None) @@ -84,14 +97,22 @@ 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) + log_tool_in_stream_and_main( + console, + "[tool]Cancellation received:[/tool] terminating subprocess...", + tool_name=tool_name, + ) if process is not None: process.terminate() # Not necessary, textual terminal get the keyboard input except KeyboardInterrupt: # Ctrl+C pressed → stop subprocess - console.logger.log_tool("[tool]Ctrl+C received:[/tool] terminating subprocess...", tool_name=tool_name) + log_tool_in_stream_and_main( + console, + "[tool]Ctrl+C received:[/tool] terminating subprocess...", + tool_name=tool_name, + ) if process is not None: process.terminate() @@ -100,18 +121,26 @@ async def run_streaming_cmd_async( 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) + log_tool_in_stream_and_main( + console, + "Subprocess didn't exit in time → killing it.", + tool_name=tool_name, + error=True, + ) if process is not None: process.kill() await process.wait() finally: - if hasattr(console, "hide_subprocess_panel"): - console.hide_subprocess_panel() - + # Always restore default logging route and close the popup panel + # when a streaming subprocess ends. The popup stays visible and is + # closed explicitly by user Ctrl+C in console actions. + if hasattr(console, "change_route_logs"): + console.change_route_logs(False) + console.set_stream_task(None) return "\n".join(captured_lines) -def execute_subprocess(console, tool_name, base_args, max_duration, max_lines): +def execute_subprocess(console, tool_name, base_args, max_duration, max_lines, log_created: bool = True): stream_task = None done_event = threading.Event() result = {"output": ""} @@ -154,7 +183,8 @@ def _on_done(task: asyncio.Task) -> None: # `console.app` is your Textual App instance. console.app.call_from_thread(_launcher) - console.logger.log_tool("[tool]Subprocess created![tool]", tool_name=tool_name) + if log_created: + console.logger.log_tool("[tool]Subprocess created![tool]", tool_name=tool_name) # Wait for streaming command to finish and return collected lines. # In UI thread we avoid blocking to prevent deadlocks. if threading.current_thread() is threading.main_thread(): @@ -265,3 +295,24 @@ def last_output_lines(console, tool_name: str, output: str, n_lines: int = 10) - tool_name=tool_name, ) return "\n".join(lines[-n_lines:]) + +def print_tool_output(console, result, name): + console.logger.log_tool("[tool]Output:[tool]", tool_name=name) + console.logger.log_msg(result, color="gray") + console.logger.log_msg("\n") + +def log_tool_in_stream_and_main(console, msg: str, tool_name: str = "", error: bool = False, color: str = "") -> None: + """ + Log a tool message in both Logs (streaming and main) + """ + processed_msg = console.logger.log_tool(msg, tool_name=tool_name, error=error, color=color) + + if not getattr(console, "_route_logs_to_stream_panel", False): + return + + main_panel = getattr(console, "main_pannel", None) + if main_panel is None: + return + + for line in processed_msg.splitlines(): + main_panel.append_line(line) \ No newline at end of file From b00b37738524c37c7ffa37965e0ef845fab1b2ff Mon Sep 17 00:00:00 2001 From: danipiza Date: Mon, 30 Mar 2026 07:22:38 +0200 Subject: [PATCH 26/55] [#23897] Added more tools tests Signed-off-by: danipiza --- src/vulcanai/tools/default_tools.py | 9 +- tests/unittest/test_default_tools.py | 1144 +++++++++++++++++++++++++- 2 files changed, 1120 insertions(+), 33 deletions(-) diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index 5274a69..28bc20b 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -177,7 +177,7 @@ class Ros2NodeTool(AtomicTool): # - `command` lets you pick a single subcommand (list/info). input_schema = [ ("command", "string"), # Command - ("topic_name", "string?"), # (optional) Topic name. (info/bw/delay/hz/type/pub) + ("node_name", "string?"), # (optional) Topic name. (info/bw/delay/hz/type/pub) ] output_schema = { @@ -619,6 +619,11 @@ def run(self, **kwargs): # -- ros2 param get ------------------------------------ elif command == "get": get_output = run_oneshot_cmd(["ros2", "param", "get", node_name, param_name]) + # In newer ROS 2 releases this case can return exit code 0 while + # still reporting an unset parameter. Surface it as an error so + # callers can reliably detect deleted/missing values. + if "parameter not set" in get_output.lower(): + raise Exception(f"Parameter '{param_name}' is not set on node '{node_name}'.") result["output"] = get_output # -- ros2 param describe ------------------------------- @@ -764,7 +769,7 @@ def run(self, **kwargs): package_name_list = package_name_list_str.splitlines() # -- ros2 interface list ---------------------------------------------- - if interface_name is None: + if command == "list": result["output"] = interface_name_list_str # -- ros2 interface packages ------------------------------------------ diff --git a/tests/unittest/test_default_tools.py b/tests/unittest/test_default_tools.py index 58c4871..173a0c7 100644 --- a/tests/unittest/test_default_tools.py +++ b/tests/unittest/test_default_tools.py @@ -13,7 +13,14 @@ # limitations under the License. """ -Unit tests for the ROS 2 default tools (ros2_topic). +Unit tests for the ROS 2 default tools + - ros2_node + - ros2_topic + - ros2_service + - ros2_action + - ros2_param + - ros2 pkg + - ros2 interfaces These tests require a working ROS 2 environment (rclpy importable and ros2 CLI available). Tests are automatically skipped when ROS 2 is not @@ -35,9 +42,9 @@ import time import unittest -# --------------------------------------------------------------------------- +# ----------------------------------------------------------------------------- # Skip entire module when ROS 2 is not available -# --------------------------------------------------------------------------- +# ----------------------------------------------------------------------------- try: import rclpy # noqa: F401 @@ -60,9 +67,9 @@ 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.""" @@ -90,11 +97,12 @@ def __init__(self): self._thread.start() def call_from_thread(self, fn, *args, **kwargs): - self._loop.call_soon_threadsafe(fn) + self._loop.call_soon_threadsafe(fn, *args, **kwargs) def stop(self): self._loop.call_soon_threadsafe(self._loop.stop) self._thread.join(timeout=5) + self._loop.close() class MockConsole: @@ -125,6 +133,9 @@ def __init__(self): def set_stream_task(self, task): self._stream_task = task + def call_from_thread(self, fn, *args, **kwargs): + self.app.call_from_thread(fn, *args, **kwargs) + def show_subprocess_panel(self): pass @@ -148,9 +159,9 @@ def stop(self): self.app.stop() -# --------------------------------------------------------------------------- +# ----------------------------------------------------------------------------- # Helper: background ROS 2 publisher -# --------------------------------------------------------------------------- +# ----------------------------------------------------------------------------- def start_background_publisher( topic: str, msg_type: str = "std_msgs/msg/String", @@ -186,10 +197,131 @@ def stop_background_publisher(proc: subprocess.Popen): except subprocess.TimeoutExpired: proc.kill() proc.wait() + if proc.stdout is not None: + proc.stdout.close() + if proc.stderr not in (None, subprocess.STDOUT): + proc.stderr.close() + + +def start_background_ros2_executable(package: str, executable: str, wait_sec: float = 2.0): + """Launch ``ros2 run `` in a subprocess.""" + proc = subprocess.Popen( + ["ros2", "run", package, executable], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + time.sleep(wait_sec) + return proc + + +def stop_background_process(proc: subprocess.Popen): + """Terminate a generic background process gracefully.""" + if proc.poll() is None: + proc.send_signal(signal.SIGINT) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + if proc.stdout is not None: + proc.stdout.close() + if proc.stderr not in (None, subprocess.STDOUT): + proc.stderr.close() # =========================================================================== -# Test class +# Test class TestRos2NodeTool +# =========================================================================== +@unittest.skipUnless( + ROS2_AVAILABLE and ROS2_CLI_AVAILABLE, + "ROS 2 (rclpy + ros2 CLI) not available — skipping", +) +class TestRos2NodeTool(unittest.TestCase): + """Direct tests for ``Ros2NodeTool.run()``.""" + + # ------------------------------------------------------------------------- + # Fixtures + # ------------------------------------------------------------------------- + @classmethod + def setUpClass(cls): + default_tools_mod = importlib.import_module("vulcanai.tools.default_tools") + cls.Ros2NodeTool = default_tools_mod.Ros2NodeTool + cls.ROS2DefaultToolNode = default_tools_mod.ROS2DefaultToolNode + + def setUp(self): + self.console = MockConsole() + self.node = self.ROS2DefaultToolNode() + self.node_name = f"/{self.node.get_name()}" + self._bg_processes = [] + + def tearDown(self): + for proc in self._bg_processes: + stop_background_process(proc) + self._bg_processes.clear() + self.node.destroy_node() + self.console.stop() + + @classmethod + def tearDownClass(cls): + if rclpy.ok(): + rclpy.shutdown() + + # ------------------------------------------------------------------------- + # Helpers + # ------------------------------------------------------------------------- + def _run_node(self, **kwargs): + tool = self.Ros2NodeTool() + tool.bb = {"console": self.console, "main_node": self.node} + return tool.run(**kwargs) + + def _wait_for_node(self, timeout_sec: float = 5.0): + start_time = time.monotonic() + while (time.monotonic() - start_time) <= timeout_sec: + result = self._run_node(command="list") + if self.node_name in result["output"]: + return + time.sleep(0.2) + self.fail(f"Node '{self.node_name}' not visible within {timeout_sec} seconds") + + # ------------------------------------------------------------------------- + # Tests — Node name suggestion + # ------------------------------------------------------------------------- + def test_node_name_suggestions(self): + """Misspelled node names should be corrected through suggestions.""" + self._wait_for_node() + result = self._run_node(command="info", node_name=self.node.get_name()) + self.assertIn("Subscribers:", result["output"]) + + # ------------------------------------------------------------------------- + # Tests — ros2 node list + # ------------------------------------------------------------------------- + def test_node_list(self): + """`list` should include the default tool node name.""" + self._wait_for_node() + result = self._run_node(command="list") + self.assertIn(self.node_name, result["output"]) + + # ------------------------------------------------------------------------- + # Tests — ros2 node info + # ------------------------------------------------------------------------- + def test_node_info(self): + """`info` should provide standard node metadata.""" + self._wait_for_node() + result = self._run_node(command="info", node_name=self.node_name) + self.assertIn("Subscribers:", result["output"]) + self.assertIn("Publishers:", result["output"]) + + # ------------------------------------------------------------------------- + # Tests — Error + # ------------------------------------------------------------------------- + def test_node_info_missing_name_raises(self): + """`info` with explicit None node_name should raise.""" + with self.assertRaises((ValueError, TypeError)): + self._run_node(command="info", node_name=None) + + +# =========================================================================== +# Test class Ros2TopicTool # =========================================================================== @unittest.skipUnless( ROS2_AVAILABLE and ROS2_CLI_AVAILABLE, @@ -202,9 +334,9 @@ class TestRos2TopicTool(unittest.TestCase): MockConsole and a ROS2DefaultToolNode, and calls ``run()`` directly. """ - # ----------------------------------------------------------------------- + # ------------------------------------------------------------------------- # Fixtures - # ----------------------------------------------------------------------- + # ------------------------------------------------------------------------- @classmethod def setUpClass(cls): """Import the default tools module and the ROS2DefaultToolNode.""" @@ -230,9 +362,9 @@ 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() @@ -262,9 +394,9 @@ def worker(): 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") @@ -283,9 +415,9 @@ def test_topic_list_with_background_pub(self): 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" @@ -297,9 +429,9 @@ def test_topic_info(self): 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" @@ -310,9 +442,9 @@ def test_topic_type(self): 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" @@ -323,9 +455,9 @@ def test_topic_find(self): 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" @@ -343,9 +475,9 @@ def test_topic_bw(self): 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" @@ -363,9 +495,9 @@ def test_topic_hz(self): 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. @@ -388,9 +520,9 @@ def test_topic_delay(self): 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" @@ -407,9 +539,9 @@ def test_topic_hz_custom_limits(self): 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)): @@ -434,5 +566,955 @@ def test_topic_find_missing_msg_type_raises(self): self._run_topic(command="find") +# =========================================================================== +# Test class Ros2ServiceTool +# =========================================================================== +@unittest.skipUnless( + ROS2_AVAILABLE and ROS2_CLI_AVAILABLE, + "ROS 2 (rclpy + ros2 CLI) not available — skipping", +) +class TestRos2ServiceTool(unittest.TestCase): + """Direct tests for ``Ros2ServiceTool.run()``.""" + + # ------------------------------------------------------------------------- + # Fixtures + # ------------------------------------------------------------------------- + @classmethod + def setUpClass(cls): + default_tools_mod = importlib.import_module("vulcanai.tools.default_tools") + cls.Ros2ServiceTool = default_tools_mod.Ros2ServiceTool + cls.ROS2DefaultToolNode = default_tools_mod.ROS2DefaultToolNode + + def setUp(self): + self.console = MockConsole() + self.node = self.ROS2DefaultToolNode() + self.get_parameters_service = f"/{self.node.get_name()}/get_parameters" + self._bg_processes = [] + + def tearDown(self): + for proc in self._bg_processes: + stop_background_process(proc) + self._bg_processes.clear() + self.node.destroy_node() + self.console.stop() + + @classmethod + def tearDownClass(cls): + if rclpy.ok(): + rclpy.shutdown() + + # ------------------------------------------------------------------------- + # Helpers + # ------------------------------------------------------------------------- + def _run_service(self, **kwargs): + tool = self.Ros2ServiceTool() + tool.bb = {"console": self.console, "main_node": self.node} + return tool.run(**kwargs) + + def _run_service_threaded(self, **kwargs): + result = {} + error = {} + + def worker(): + try: + result["value"] = self._run_service(**kwargs) + except Exception as e: + error["exc"] = e + + t = threading.Thread(target=worker) + t.start() + t.join(timeout=30) + if "exc" in error: + raise error["exc"] + return result.get("value") + + def _wait_for_service(self, service_name: str, timeout_sec: float = 5.0): + start_time = time.monotonic() + while (time.monotonic() - start_time) <= timeout_sec: + result = self._run_service(command="list") + if service_name in result["output"]: + return + time.sleep(0.2) + self.fail(f"Service '{service_name}' not visible within {timeout_sec} seconds") + + def _start_add_two_ints_service(self): + try: + executables = subprocess.check_output( + ["ros2", "pkg", "executables", "examples_rclpy_minimal_service"], + text=True, + timeout=10, + ) + except Exception: + self.skipTest("examples_rclpy_minimal_service package is not available") + return + + if " service" not in executables: + self.skipTest("examples_rclpy_minimal_service/service executable is not available") + + proc = start_background_ros2_executable("examples_rclpy_minimal_service", "service", wait_sec=2.0) + self._bg_processes.append(proc) + self._wait_for_service("/add_two_ints", timeout_sec=8.0) + + # ------------------------------------------------------------------------- + # Tests — ros2 service list + # ------------------------------------------------------------------------- + def test_service_list(self): + """`list` should include parameter services exposed by the tool node.""" + self._wait_for_service(self.get_parameters_service) + result = self._run_service(command="list") + + self.assertIn("output", result) + self.assertIn(self.get_parameters_service, result["output"]) + + # ------------------------------------------------------------------------- + # Tests — ros2 service info + # ------------------------------------------------------------------------- + def test_service_info(self): + """`info` should report type metadata for a known service.""" + self._wait_for_service(self.get_parameters_service) + result = self._run_service(command="info", service_name=self.get_parameters_service) + + self.assertIn("Type:", result["output"]) + self.assertIn("rcl_interfaces/srv/GetParameters", result["output"]) + + # ------------------------------------------------------------------------- + # Tests — ros2 service type + # ------------------------------------------------------------------------- + def test_service_type(self): + """`type` should return the service type for a known service.""" + self._wait_for_service(self.get_parameters_service) + result = self._run_service(command="type", service_name=self.get_parameters_service) + + self.assertEqual("rcl_interfaces/srv/GetParameters", result["output"]) + + # ------------------------------------------------------------------------- + # Tests — ros2 service find + # ------------------------------------------------------------------------- + def test_service_find(self): + """`find` should list services by service type.""" + self._wait_for_service(self.get_parameters_service) + result = self._run_service(command="find", service_type="rcl_interfaces/srv/GetParameters") + + self.assertIn(self.get_parameters_service, result["output"]) + + # ------------------------------------------------------------------------- + # Tests — explicit type + # ------------------------------------------------------------------------- + def test_service_call_with_explicit_type(self): + """`call` should work with an explicit service type.""" + self._start_add_two_ints_service() + result = self._run_service( + command="call", + service_name="/add_two_ints", + service_type="example_interfaces/srv/AddTwoInts", + args="{a: 2, b: 3}", + ) + self.assertIn("sum=5", result["output"]) + + # ------------------------------------------------------------------------- + # Tests — autodetects type + # ------------------------------------------------------------------------- + def test_service_call_auto_detects_type(self): + """`call` should auto-detect service type when omitted.""" + self._start_add_two_ints_service() + result = self._run_service( + command="call", + service_name="/add_two_ints", + args="{a: 4, b: 5}", + ) + self.assertIn("sum=9", result["output"]) + + # ------------------------------------------------------------------------- + # Tests — streaming + # ------------------------------------------------------------------------- + def test_service_echo(self): + """`echo` should run and produce output for service event topic status.""" + self._start_add_two_ints_service() + result = self._run_service_threaded( + command="echo", + service_name="/add_two_ints", + max_duration=5.0, + max_lines=1, + ) + if result is None: + self.skipTest("ros2 service echo did not yield output in time") + return + self.assertIn("output", result) + self.assertIsInstance(result["output"], str) + self.assertNotEqual("", result["output"].strip()) + + # ------------------------------------------------------------------------- + # Tests — errors + # ------------------------------------------------------------------------- + def test_service_call_missing_args_raises(self): + """`call` without args should raise ValueError.""" + self._wait_for_service(self.get_parameters_service) + with self.assertRaises(ValueError): + self._run_service( + command="call", + service_name=self.get_parameters_service, + service_type="rcl_interfaces/srv/GetParameters", + ) + + def test_service_info_missing_service_name_raises(self): + """`info` without a service_name should raise.""" + with self.assertRaises((ValueError, TypeError)): + self._run_service(command="info") + + def test_service_unknown_command_raises(self): + """Unknown service subcommands should raise ValueError.""" + self._wait_for_service(self.get_parameters_service) + with self.assertRaises(ValueError): + self._run_service(command="nonexistent", service_name=self.get_parameters_service) + + +# =========================================================================== +# Test class TestRos2ActionTool +# =========================================================================== +@unittest.skipUnless( + ROS2_AVAILABLE and ROS2_CLI_AVAILABLE, + "ROS 2 (rclpy + ros2 CLI) not available — skipping", +) +class TestRos2ActionTool(unittest.TestCase): + """Direct tests for ``Ros2ActionTool.run()``.""" + + # ------------------------------------------------------------------------- + # Fixtures + # ------------------------------------------------------------------------- + @classmethod + def setUpClass(cls): + default_tools_mod = importlib.import_module("vulcanai.tools.default_tools") + cls.Ros2ActionTool = default_tools_mod.Ros2ActionTool + cls.ROS2DefaultToolNode = default_tools_mod.ROS2DefaultToolNode + + def setUp(self): + self.console = MockConsole() + self.node = self.ROS2DefaultToolNode() + self._bg_processes = [] + + def tearDown(self): + for proc in self._bg_processes: + stop_background_process(proc) + self._bg_processes.clear() + self.node.destroy_node() + self.console.stop() + + @classmethod + def tearDownClass(cls): + if rclpy.ok(): + rclpy.shutdown() + + # ------------------------------------------------------------------------- + # Helpers + # ------------------------------------------------------------------------- + def _run_action(self, **kwargs): + tool = self.Ros2ActionTool() + tool.bb = {"console": self.console, "main_node": self.node} + return tool.run(**kwargs) + + def _wait_for_action(self, action_name: str, timeout_sec: float = 8.0): + start_time = time.monotonic() + while (time.monotonic() - start_time) <= timeout_sec: + result = self._run_action(command="list") + if action_name in result["output"]: + return + time.sleep(0.2) + self.fail(f"Action '{action_name}' not visible within {timeout_sec} seconds") + + def _start_fibonacci_action_server(self): + try: + executables = subprocess.check_output( + ["ros2", "pkg", "executables", "examples_rclpy_minimal_action_server"], + text=True, + timeout=10, + ) + except Exception: + self.skipTest("examples_rclpy_minimal_action_server package is not available") + return + + if " server" not in executables: + self.skipTest("examples_rclpy_minimal_action_server/server executable is not available") + + proc = start_background_ros2_executable("examples_rclpy_minimal_action_server", "server", wait_sec=2.5) + self._bg_processes.append(proc) + self._wait_for_action("/fibonacci", timeout_sec=8.0) + + # ------------------------------------------------------------------------- + # Tests — Action name suggestion + # ------------------------------------------------------------------------- + def test_action_name_suggestions(self): + """Misspelled names should be corrected through suggestion flow.""" + self._start_fibonacci_action_server() + result = self._run_action(command="info", action_name="fibonacc") + self.assertIn("Action: /fibonacci", result["output"]) + + # ------------------------------------------------------------------------- + # Tests — ros2 action list + # ------------------------------------------------------------------------- + def test_action_list(self): + """`list` should pass through ros2 action list output.""" + self._start_fibonacci_action_server() + result = self._run_action(command="list") + self.assertIn("/fibonacci", result["output"]) + + # ------------------------------------------------------------------------- + # Tests — ros2 action info + # ------------------------------------------------------------------------- + def test_action_info(self): + """`info` should invoke ros2 action info for the given name.""" + self._start_fibonacci_action_server() + result = self._run_action(command="info", action_name="/fibonacci") + self.assertIn("Action: /fibonacci", result["output"]) + self.assertIn("Action servers:", result["output"]) + + # ------------------------------------------------------------------------- + # Tests — ros2 action type + # ------------------------------------------------------------------------- + def test_action_type(self): + """`type` should invoke ros2 action type for the given action name.""" + self._start_fibonacci_action_server() + result = self._run_action(command="type", action_name="/fibonacci") + self.assertIn("example_interfaces/action/Fibonacci", result["output"]) + + # ------------------------------------------------------------------------- + # Tests — error cases + # ------------------------------------------------------------------------- + def test_action_info_missing_action_name_raises(self): + """`info` without action_name should raise.""" + with self.assertRaises((ValueError, TypeError)): + self._run_action(command="info") + + def test_action_type_missing_action_name_raises(self): + """`type` without action_name should raise.""" + with self.assertRaises((ValueError, TypeError)): + self._run_action(command="type") + + def test_action_unknown_command_raises(self): + """Unknown action subcommands should raise ValueError.""" + self._start_fibonacci_action_server() + with self.assertRaises(ValueError): + self._run_action(command="nonexistent", action_name="/fibonacci") + + +@unittest.skipUnless( + ROS2_AVAILABLE and ROS2_CLI_AVAILABLE, + "ROS 2 (rclpy + ros2 CLI) not available — skipping", +) +class TestRos2ParamTool(unittest.TestCase): + """Direct tests for ``Ros2ParamTool.run()``.""" + + # ------------------------------------------------------------------------- + # Fixtures + # ------------------------------------------------------------------------- + @classmethod + def setUpClass(cls): + default_tools_mod = importlib.import_module("vulcanai.tools.default_tools") + cls.Ros2ParamTool = default_tools_mod.Ros2ParamTool + cls.ROS2DefaultToolNode = default_tools_mod.ROS2DefaultToolNode + + def setUp(self): + self.console = MockConsole() + self.node = self.ROS2DefaultToolNode() + self.node_name = f"/{self.node.get_name()}" + self._bg_processes = [] + + def tearDown(self): + for proc in self._bg_processes: + stop_background_process(proc) + self._bg_processes.clear() + self.node.destroy_node() + self.console.stop() + + @classmethod + def tearDownClass(cls): + if rclpy.ok(): + rclpy.shutdown() + + # ------------------------------------------------------------------------- + # Helpers + # ------------------------------------------------------------------------- + def _run_param(self, **kwargs): + tool = self.Ros2ParamTool() + tool.bb = {"console": self.console, "main_node": self.node} + return tool.run(**kwargs) + + def _run_param_without_param_suggestion(self, **kwargs): + """Run param tool preserving explicit param_name even if it's not a topic.""" + self.console.suggestion_index = -1 + self.console.suggestion_index_changed.set() + return self._run_param(**kwargs) + + def _wait_for_node(self, timeout_sec: float = 5.0): + start_time = time.monotonic() + while (time.monotonic() - start_time) <= timeout_sec: + try: + node_list = subprocess.check_output(["ros2", "node", "list"], text=True) + except Exception: + node_list = "" + if self.node_name in node_list: + return + time.sleep(0.2) + self.fail(f"Node '{self.node_name}' not visible within {timeout_sec} seconds") + + def _start_parameter_blackboard(self): + try: + executables = subprocess.check_output( + ["ros2", "pkg", "executables", "demo_nodes_cpp"], + text=True, + timeout=10, + ) + except Exception: + self.skipTest("demo_nodes_cpp package is not available") + return + + if " parameter_blackboard" not in executables: + self.skipTest("demo_nodes_cpp/parameter_blackboard executable is not available") + + proc = start_background_ros2_executable("demo_nodes_cpp", "parameter_blackboard", wait_sec=2.0) + self._bg_processes.append(proc) + start_time = time.monotonic() + while (time.monotonic() - start_time) <= 8.0: + try: + node_list = subprocess.check_output(["ros2", "node", "list"], text=True, timeout=5) + except Exception: + node_list = "" + if "/parameter_blackboard" in node_list: + return + time.sleep(0.2) + self.fail("Node '/parameter_blackboard' not visible within timeout") + + # ------------------------------------------------------------------------- + # Tests — ros2 param list + # ------------------------------------------------------------------------- + def test_param_list(self): + """`list` should return a non-empty output string.""" + result = self._run_param(command="list") + self.assertIn("output", result) + self.assertIsInstance(result["output"], str) + self.assertNotEqual("", result["output"].strip()) + + def test_param_list_with_node_name_returns_node_list(self): + """When node_name is provided, `list` returns node listing.""" + self._wait_for_node() + result = self._run_param(command="list", node_name=self.node_name) + self.assertIn(self.node_name, result["output"]) + + # ------------------------------------------------------------------------- + # Tests — ros2 param get + # ------------------------------------------------------------------------- + def test_param_get(self): + """`get` should return the value of a parameter.""" + self._start_parameter_blackboard() + param_name = "vulcan_test_param_get" + self._run_param_without_param_suggestion( + command="set", + node_name="/parameter_blackboard", + param_name=param_name, + set_value="456", + ) + result = self._run_param_without_param_suggestion( + command="get", + node_name="/parameter_blackboard", + param_name=param_name, + ) + self.assertIn("456", result["output"]) + + # ------------------------------------------------------------------------- + # Tests — ros2 param describe + # ------------------------------------------------------------------------- + def test_param_describe(self): + """`describe` should return metadata for a known parameter.""" + self._start_parameter_blackboard() + result = self._run_param_without_param_suggestion( + command="describe", + node_name="/parameter_blackboard", + param_name="use_sim_time", + ) + self.assertIn("Type:", result["output"]) + + # ------------------------------------------------------------------------- + # Tests — ros2 param set + # ------------------------------------------------------------------------- + def test_param_set(self): + """`set` should update a parameter on parameter_blackboard.""" + self._start_parameter_blackboard() + result = self._run_param_without_param_suggestion( + command="set", + node_name="/parameter_blackboard", + param_name="vulcan_test_param_set", + set_value="123", + ) + self.assertIn("Set parameter", result["output"]) + # ------------------------------------------------------------------------- + # Tests — ros2 param delete + # ------------------------------------------------------------------------- + def test_param_delete(self): + """`delete` should remove a previously set parameter.""" + self._start_parameter_blackboard() + param_name = "vulcan_test_param_delete" + self._run_param_without_param_suggestion( + command="set", + node_name="/parameter_blackboard", + param_name=param_name, + set_value="789", + ) + delete_result = self._run_param_without_param_suggestion( + command="delete", + node_name="/parameter_blackboard", + param_name=param_name, + ) + self.assertNotEqual("", delete_result["output"].strip()) + + with self.assertRaises(Exception): + self._run_param_without_param_suggestion( + command="get", + node_name="/parameter_blackboard", + param_name=param_name, + ) + # ------------------------------------------------------------------------- + # Tests — ros2 param dump [file_path] + # ------------------------------------------------------------------------- + def test_param_dump(self): + """`dump` should produce YAML for a running parameter node.""" + self._start_parameter_blackboard() + result = self._run_param(command="dump", node_name="/parameter_blackboard") + self.assertIn("/parameter_blackboard:", result["output"]) + self.assertIn("ros__parameters", result["output"]) + + def test_param_dump_with_output_file(self): + """`dump` with file path currently errors on unsupported CLI option.""" + self._start_parameter_blackboard() + dump_path = "/tmp/vulcan_param_dump.yaml" + if os.path.exists(dump_path): + os.remove(dump_path) + with self.assertRaises(Exception): + self._run_param(command="dump", node_name="/parameter_blackboard", file_path=dump_path) + + # ------------------------------------------------------------------------- + # Tests — ros2 param load + # ------------------------------------------------------------------------- + def test_param_load(self): + """`load` should restore parameters from a YAML dump file.""" + self._start_parameter_blackboard() + param_name = "vulcan_test_param_load" + load_path = "/tmp/vulcan_param_dump_and_load.yaml" + try: + self._run_param_without_param_suggestion( + command="set", + node_name="/parameter_blackboard", + param_name=param_name, + set_value="321", + ) + # -- DUMP ----------------- + dump_result = self._run_param(command="dump", node_name="/parameter_blackboard") + with open(load_path, "w", encoding="utf-8") as f: + f.write(dump_result["output"]) + + self._run_param_without_param_suggestion( + command="set", + node_name="/parameter_blackboard", + param_name=param_name, + set_value="999", + ) + + # -- LOAD ----------------- + load_result = self._run_param( + command="load", + node_name="/parameter_blackboard", + file_path=load_path, + ) + self.assertNotEqual("", load_result["output"].strip()) + + get_result = self._run_param_without_param_suggestion( + command="get", + node_name="/parameter_blackboard", + param_name=param_name, + ) + self.assertIn("321", get_result["output"]) + finally: + if os.path.exists(load_path): + os.remove(load_path) + + # ------------------------------------------------------------------------- + # Tests — Error + # ------------------------------------------------------------------------- + def test_param_set_missing_set_value_raises(self): + """`set` requires set_value.""" + self._wait_for_node() + with self.assertRaises(ValueError): + self._run_param(command="set", node_name=self.node_name, param_name="/rosout") + + def test_param_load_missing_file_path_raises(self): + """`load` requires file_path.""" + self._wait_for_node() + with self.assertRaises(ValueError): + self._run_param(command="load", node_name=self.node_name) + + def test_param_unknown_command_raises(self): + """Unknown param subcommands should raise ValueError.""" + self._wait_for_node() + with self.assertRaises(ValueError): + self._run_param(command="nonexistent", node_name=self.node_name, param_name="/rosout") + + +@unittest.skipUnless( + ROS2_AVAILABLE and ROS2_CLI_AVAILABLE, + "ROS 2 (rclpy + ros2 CLI) not available — skipping", +) +class TestRos2PkgTool(unittest.TestCase): + """Direct tests for ``Ros2PkgTool.run()``.""" + + # ------------------------------------------------------------------------- + # Fixtures + # ------------------------------------------------------------------------- + @classmethod + def setUpClass(cls): + default_tools_mod = importlib.import_module("vulcanai.tools.default_tools") + cls.Ros2PkgTool = default_tools_mod.Ros2PkgTool + + def setUp(self): + self.console = MockConsole() + + def tearDown(self): + self.console.stop() + + # ------------------------------------------------------------------------- + # Helpers + # ------------------------------------------------------------------------- + def _run_pkg(self, **kwargs): + tool = self.Ros2PkgTool() + tool.bb = {"console": self.console} + return tool.run(**kwargs) + + # ------------------------------------------------------------------------- + # Tests — ros2 pkg list + # ------------------------------------------------------------------------- + def test_pkg_list(self): + """`list` should pass through ros2 pkg list output.""" + result = self._run_pkg(command="list") + self.assertIn("rclpy", result["output"]) + + # ------------------------------------------------------------------------- + # Tests — ros2 pkg executables + # ------------------------------------------------------------------------- + def test_pkg_executables(self): + """`executables` should pass through ros2 pkg executables output.""" + result = self._run_pkg(command="executables") + self.assertIn("examples_rclpy_minimal_publisher", result["output"]) + + # ------------------------------------------------------------------------- + # Tests — Error + # ------------------------------------------------------------------------- + def test_pkg_unknown_command_raises(self): + """Unknown pkg subcommands should raise ValueError.""" + with self.assertRaises(ValueError): + self._run_pkg(command="nonexistent") + + +@unittest.skipUnless( + ROS2_AVAILABLE and ROS2_CLI_AVAILABLE, + "ROS 2 (rclpy + ros2 CLI) not available — skipping", +) +class TestRos2InterfaceTool(unittest.TestCase): + """Direct tests for ``Ros2InterfaceTool.run()``.""" + + # ------------------------------------------------------------------------- + # Fixtures + # ------------------------------------------------------------------------- + @classmethod + def setUpClass(cls): + default_tools_mod = importlib.import_module("vulcanai.tools.default_tools") + cls.Ros2InterfaceTool = default_tools_mod.Ros2InterfaceTool + + def setUp(self): + self.console = MockConsole() + + def tearDown(self): + self.console.stop() + + # ------------------------------------------------------------------------- + # Helpers + # ------------------------------------------------------------------------- + def _run_interface(self, **kwargs): + tool = self.Ros2InterfaceTool() + tool.bb = {"console": self.console} + return tool.run(**kwargs) + + # ------------------------------------------------------------------------- + # Tests — ros2 interface list + # ------------------------------------------------------------------------- + def test_interface_without_name_returns_interface_list(self): + """When interface_name is None, tool should return interface list.""" + result = self._run_interface(command="list", interface_name=None) + self.assertIn("std_msgs/msg/String", result["output"]) + + # ------------------------------------------------------------------------- + # Tests — ros2 interface packages + # ------------------------------------------------------------------------- + def test_interface_packages_command_returns_packages(self): + """`packages` branch should return package names.""" + result = self._run_interface(command="packages", interface_name="std_msgs/msg/String") + self.assertIn("std_msgs", result["output"]) + + # ------------------------------------------------------------------------- + # Tests — ros2 interface package + # ------------------------------------------------------------------------- + def test_interface_package_branch_calls_current_cli(self): + """`package` branch should use the current CLI call path.""" + with self.assertRaises(Exception) as exc_info: + self._run_interface(command="package", interface_name="std_msgs") + self.assertIn("ros2 topic package", str(exc_info.exception)) + + # ------------------------------------------------------------------------- + # Tests — ros2 interface show + # ------------------------------------------------------------------------- + def test_interface_show_branch_calls_current_cli(self): + """`show` branch should use the current CLI call path.""" + with self.assertRaises(Exception) as exc_info: + self._run_interface(command="show", interface_name="std_msgs/msg/String") + self.assertIn("ros2 topic show", str(exc_info.exception)) + + # ------------------------------------------------------------------------- + # Tests — Error + # ------------------------------------------------------------------------- + def test_interface_unknown_command_raises(self): + """Unknown interface subcommands should raise ValueError.""" + with self.assertRaises(ValueError): + self._run_interface(command="nonexistent", interface_name="std_msgs/msg/String") + + +@unittest.skipUnless( + ROS2_AVAILABLE and ROS2_CLI_AVAILABLE, + "ROS 2 (rclpy + ros2 CLI) not available — skipping", +) +class TestRos2PublishTool(unittest.TestCase): + """Direct tests for ``Ros2PublishTool.run()``.""" + + # ------------------------------------------------------------------------- + # Fixtures + # ------------------------------------------------------------------------- + @classmethod + def setUpClass(cls): + default_tools_mod = importlib.import_module("vulcanai.tools.default_tools") + cls.Ros2PublishTool = default_tools_mod.Ros2PublishTool + cls.ROS2DefaultToolNode = default_tools_mod.ROS2DefaultToolNode + + def setUp(self): + self.console = MockConsole() + self.node = self.ROS2DefaultToolNode() + self._bg_publishers = [] + + def tearDown(self): + for proc in self._bg_publishers: + stop_background_publisher(proc) + self._bg_publishers.clear() + self.node.destroy_node() + self.console.stop() + + @classmethod + def tearDownClass(cls): + if rclpy.ok(): + rclpy.shutdown() + + # ------------------------------------------------------------------------- + # Helpers + # ------------------------------------------------------------------------- + def _run_publish(self, node=None, **kwargs): + tool = self.Ros2PublishTool() + tool.bb = {"console": self.console, "main_node": node if node is not None else self.node} + return tool.run(**kwargs) + + # ------------------------------------------------------------------------- + # Tests — Publish max_lines + # ------------------------------------------------------------------------- + def test_publish_with_max_lines(self): + """Publisher should stop after max_lines and report count/output.""" + result = self._run_publish( + topic="/vulcan_publish_tool_test", + message_data="hello_publish", + max_lines=2, + period_sec=0.01, + max_duration=2.0, + ) + + self.assertEqual("True", result["published"]) + self.assertEqual(2, result["count"]) + self.assertEqual("/vulcan_publish_tool_test", result["topic"]) + self.assertIn("Publishing: 'hello_publish'", result["output"]) + + # ------------------------------------------------------------------------- + # Tests — Publish max_duration + # ------------------------------------------------------------------------- + def test_publish_with_max_duration(self): + """Publisher should stop after max_lines and report count/output.""" + result = self._run_publish( + topic="/vulcan_publish_tool_test", + message_data="hello_publish", + max_lines=10, + period_sec=1.0, + max_duration=10.0, + ) + + self.assertEqual("True", result["published"]) + self.assertEqual(10, result["count"]) + self.assertEqual("/vulcan_publish_tool_test", result["topic"]) + self.assertIn("Publishing: 'hello_publish'", result["output"]) + + # ------------------------------------------------------------------------- + # Tests — Errors + # ------------------------------------------------------------------------- + + # -- No topic --------------------- + def test_publish_with_empty_topic_returns_unpublished(self): + """Empty topic should return default unpublished result.""" + result = self._run_publish(topic="", max_lines=1) + self.assertEqual("False", result["published"]) + self.assertEqual("0", result["count"]) + + # -- Max lines <= 0 --------------------- + def test_publish_with_non_positive_max_lines_returns_unpublished(self): + """max_lines <= 0 should stop immediately with default result.""" + result = self._run_publish(topic="/vulcan_publish_tool_test", max_lines=0) + self.assertEqual("False", result["published"]) + self.assertEqual("0", result["count"]) + + # -- Message invalid -------------- + def test_publish_custom_message_invalid_json(self): + """Invalid JSON for custom message type should return default result.""" + try: + result = self._run_publish( + topic="/dummy_topic", + msg_type="geometry_msgs/msg/Twist", + message_data="{invalid_json", + max_lines=1, + ) + except ModuleNotFoundError: + self.skipTest("geometry_msgs package is not available") + return + + self.assertEqual("False", result["published"]) + self.assertEqual("0", result["count"]) + self.assertEqual("/dummy_topic", result["topic"]) + + # -- No main node ----------------- + def test_publish_missing_main_node_raises(self): + """Tool should raise when blackboard has no shared node.""" + tool = self.Ros2PublishTool() + tool.bb = {"console": self.console} + with self.assertRaises(Exception): + tool.run(topic="/vulcan_publish_tool_test") + + +@unittest.skipUnless( + ROS2_AVAILABLE and ROS2_CLI_AVAILABLE, + "ROS 2 (rclpy + ros2 CLI) not available — skipping", +) +class TestRos2SubscribeTool(unittest.TestCase): + """Direct tests for ``Ros2SubscribeTool.run()``.""" + + # ------------------------------------------------------------------------- + # Fixtures + # ------------------------------------------------------------------------- + @classmethod + def setUpClass(cls): + default_tools_mod = importlib.import_module("vulcanai.tools.default_tools") + cls.Ros2SubscribeTool = default_tools_mod.Ros2SubscribeTool + cls.ROS2DefaultToolNode = default_tools_mod.ROS2DefaultToolNode + + def setUp(self): + self.console = MockConsole() + self.node = self.ROS2DefaultToolNode() + self._bg_publishers = [] + + def tearDown(self): + for proc in self._bg_publishers: + stop_background_publisher(proc) + self._bg_publishers.clear() + self.node.destroy_node() + self.console.stop() + + @classmethod + def tearDownClass(cls): + if rclpy.ok(): + rclpy.shutdown() + + # ------------------------------------------------------------------------- + # Helpers + # ------------------------------------------------------------------------- + def _run_subscribe(self, **kwargs): + tool = self.Ros2SubscribeTool() + tool.bb = {"console": self.console, "main_node": self.node} + return tool.run(**kwargs) + + def _run_subscribe_threaded(self, **kwargs): + result = {} + error = {} + + def worker(): + try: + result["value"] = self._run_subscribe(**kwargs) + except Exception as e: + error["exc"] = e + + t = threading.Thread(target=worker) + t.start() + t.join(timeout=30) + if "exc" in error: + raise error["exc"] + return result.get("value") + + # ------------------------------------------------------------------------- + # Tests — Subscribe max_lines + # ------------------------------------------------------------------------- + def test_subscribe_with_max_lines(self): + """`run` should receive live topic data and count lines.""" + topic = "/vulcan_test_subscribe" + proc = start_background_publisher(topic, message="hello_subscribe", rate=10) + self._bg_publishers.append(proc) + + result = self._run_subscribe_threaded(topic=topic, max_duration=5.0, max_lines=5) + + self.assertEqual("True", result["subscribed"]) + self.assertEqual(5, result["count"]) + self.assertEqual(topic, result["topic"]) + self.assertIn("I heard", result["output"]) + + # ------------------------------------------------------------------------- + # Tests — Subscribe max_duration + # ------------------------------------------------------------------------- + def test_subscribe_with_max_lines(self): + """`run` should receive live topic data and count lines.""" + topic = "/vulcan_test_subscribe" + proc = start_background_publisher(topic, message="hello_subscribe", rate=1) + self._bg_publishers.append(proc) + + result = self._run_subscribe_threaded(topic=topic, max_duration=5.0, max_lines=50) + + self.assertEqual("True", result["subscribed"]) + self.assertEqual(5, result["count"]) + self.assertEqual(topic, result["topic"]) + self.assertIn("I heard", result["output"]) + + # ------------------------------------------------------------------------- + # Tests — Subscribe (no publisher) + # ------------------------------------------------------------------------- + def test_subscribe_empty_output(self): + """Empty subprocess output still returns subscribed state.""" + result = self._run_subscribe_threaded(topic="/vulcan_test_no_pub", max_duration=1.0, max_lines=1) + self.assertEqual("True", result["subscribed"]) + self.assertGreaterEqual(result["count"], 0) + self.assertIsInstance(result["output"], str) + + # ------------------------------------------------------------------------- + # Tests — Error + # ------------------------------------------------------------------------- + def test_subscribe_missing_main_node_raises(self): + """Tool should raise when blackboard has no shared node.""" + tool = self.Ros2SubscribeTool() + tool.bb = {"console": self.console} + with self.assertRaises(Exception): + tool.run(topic="/my_topic") + + if __name__ == "__main__": unittest.main() From 9221b5d5be23f202b4f4ca5fa7c1256c29620e0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Ferreira=20Gonz=C3=A1lez?= Date: Wed, 1 Apr 2026 14:44:14 +0200 Subject: [PATCH 27/55] [#23897] Fix tests 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 | 6 ++++++ tests/unittest/test_default_tools.py | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3fa0410..8356609 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -47,6 +47,12 @@ jobs: - name: Sync repository uses: eProsima/eProsima-CI/external/checkout@v0 + - name: Install ROS 2 auxiliary test dependencies + run: | + apt-get update && apt-get install -y --no-install-recommends \ + ros-kilted-examples-rclpy-minimal-service \ + ros-kilted-examples-rclpy-minimal-action-server + - name: Install VulcanAI library run: | python3 -m pip install -U pip --break-system-packages diff --git a/tests/unittest/test_default_tools.py b/tests/unittest/test_default_tools.py index 173a0c7..a8fdbdc 100644 --- a/tests/unittest/test_default_tools.py +++ b/tests/unittest/test_default_tools.py @@ -1200,7 +1200,8 @@ def test_pkg_list(self): def test_pkg_executables(self): """`executables` should pass through ros2 pkg executables output.""" result = self._run_pkg(command="executables") - self.assertIn("examples_rclpy_minimal_publisher", result["output"]) + self.assertIn("demo_nodes_cpp talker", result["output"]) + self.assertIn("demo_nodes_cpp listener", result["output"]) # ------------------------------------------------------------------------- # Tests — Error From e820f6a5a6d5efb7d0e59e35a17d384ad2477341 Mon Sep 17 00:00:00 2001 From: danipiza Date: Wed, 1 Apr 2026 16:07:36 +0200 Subject: [PATCH 28/55] [#23897] Added uncommitted changes from the rebase Signed-off-by: danipiza --- src/vulcanai/console/console.py | 4 +- src/vulcanai/console/utils.py | 225 +--------------------------- src/vulcanai/tools/default_tools.py | 24 +-- src/vulcanai/tools/utils.py | 7 +- 4 files changed, 22 insertions(+), 238 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index 0e80959..e34a725 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -820,7 +820,9 @@ def show_subprocess_panel(self) -> None: if self.stream_pannel is None: return - self.logger.log_tool("Streaming terminal opened. Press Ctrl+C to stop the running process.", color="tool") + self.logger.log_tool( + "Streaming terminal opened. Press Ctrl+C to stop the running process.", color="tool" + ) self.stream_pannel.clear_console() self.stream_pannel.display = True diff --git a/src/vulcanai/console/utils.py b/src/vulcanai/console/utils.py index 98f4a84..e5076b5 100644 --- a/src/vulcanai/console/utils.py +++ b/src/vulcanai/console/utils.py @@ -15,6 +15,7 @@ import sys import threading + class SpinnerHook: """ Single entrant spinner controller for console. @@ -57,32 +58,6 @@ def flush(self): self.real_stream.flush() - -class SpinnerHook: - """ - Single entrant spinner controller for console. - - Starts the spinner on the LLM request. - - Stops the spinner when LLM request is over. - """ - - def __init__(self, spinner_status): - self.spinner_status = spinner_status - - def on_request_start(self, text="Querying LLM..."): - 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): - 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): """ Redirect ALL rclpy RcutilsLogger output (nodes + executor + rclpy internals) @@ -142,6 +117,7 @@ def patched_log(self, msg, level, *args, **kwargs): RcutilsLogger.log = patched_log RcutilsLogger._textual_patched = True + # endregion # region TEXTUAL @@ -172,200 +148,3 @@ def common_prefix(strings: str) -> str: common_prefix = tmp return common_prefix, commands - - -async def run_streaming_cmd_async( - console, args: list[str], max_duration: float = 60, max_lines: int = 1000, echo: bool = True, tool_name="" -) -> str: - # Unpack the command - cmd, *cmd_args = args - - # Create the subprocess - process = await asyncio.create_subprocess_exec( - cmd, - *cmd_args, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - ) - - assert process.stdout is not None - - start_time = time.monotonic() - line_count = 0 - - try: - # Subprocess main loop. Read line by line - async for raw_line in process.stdout: - line = raw_line.decode(errors="ignore").rstrip("\n") - - # Print the line - if echo: - line_processed = escape(line) - console.add_line(line_processed) - # Count the line - line_count += 1 - if max_lines is not None and line_count >= max_lines: - console.logger.log_tool(f"[tool]Stopping:[/tool] Reached max_lines = {max_lines}", tool_name=tool_name) - console.set_stream_task(None) - process.terminate() - break - - # Check duration - if max_duration and (time.monotonic() - start_time) >= max_duration: - console.logger.log_tool( - f"[tool]Stopping:[/tool] Exceeded max_duration = {max_duration}s", tool_name=tool_name - ) - console.set_stream_task(None) - process.terminate() - break - - except asyncio.CancelledError: - # Task was cancelled → stop the subprocess - console.logger.log_tool("[tool]Cancellation received:[/tool] terminating subprocess...", tool_name=tool_name) - process.terminate() - raise - # Not necessary, textual terminal get the keyboard input - except KeyboardInterrupt: - # Ctrl+C pressed → stop subprocess - console.logger.log_tool("[tool]Ctrl+C received:[/tool] terminating subprocess...", tool_name=tool_name) - 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 - ) - ) - # 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() - 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) - - # 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) - - console.logger.log_tool("[tool]Subprocess created![tool]", tool_name=tool_name) - - -def run_oneshot_cmd(args: list[str]) -> str: - try: - return subprocess.check_output(args, stderr=subprocess.STDOUT, text=True) - - except subprocess.CalledProcessError as e: - raise Exception(f"Failed to run '{' '.join(args)}': {e.output}") - - -def suggest_string(console, tool_name, string_name, input_string, real_string_list): - ret = None - - def _similarity(a: str, b: str) -> float: - """Return a similarity score between 0 and 1.""" - return difflib.SequenceMatcher(None, a, b).ratio() - - def _get_suggestions(real_string_list_comp: list[str], string_comp: str) -> tuple[str, list[str]]: - """ - Function used to search for the most "similar" string in a list. - - Used in ROS2 cli commands to used the "simmilar" in case - the queried topic does not exists. - - Example: - real_string_list_comp = [ - "/parameter_events", - "/rosout", - "/turtle1/cmd_vel", - "/turtle1/color_sensor", - "/turtle1/pose", - ] - string_comp = "trtle1" - - @return - str: the most "similar" string - list[str] a sorted list by a similitud value - """ - - topic_list_pq = [] - n = len(string_comp) - - for string in real_string_list_comp: - m = len(string) - # Calculate the similitud - score = _similarity(string_comp, string) - # Give more value for the nearest size comparations. - score -= abs(n - m) * 0.005 - # Max-heap (via negative score) - heapq.heappush(topic_list_pq, (-score, string)) - - # Pop ordered list - ret_list: list[str] = [] - _, most_topic_similar = heapq.heappop(topic_list_pq) - - ret_list.append(most_topic_similar) - - while topic_list_pq: - _, topic = heapq.heappop(topic_list_pq) - ret_list.append(topic) - - return most_topic_similar, ret_list - - if input_string not in real_string_list: - console.logger.log_tool(f'{string_name}: "{input_string}" does not exists', tool_name=tool_name) - - # Get the suggestions list sorted by similitud value - _, topic_sim_list = _get_suggestions(real_string_list, input_string) - - # Open the ModalScreen - console.open_radiolist(topic_sim_list, f"{tool_name}") - - # Wait for the user to select and item in the - # RadioList ModalScreen - console.suggestion_index_changed.wait() - - # Check if the user cancelled the suggestion - if console.suggestion_index >= 0: - ret = topic_sim_list[console.suggestion_index] - - # Reset suggestion index - console.suggestion_index = -1 - console.suggestion_index_changed.clear() - - return ret - - -# endregion diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index 28bc20b..41a49d0 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -25,7 +25,13 @@ from concurrent.futures import Future from vulcanai import AtomicTool, vulcanai_tool -from vulcanai.tools.utils import execute_subprocess, run_oneshot_cmd, suggest_string, print_tool_output, log_tool_in_stream_and_main +from vulcanai.tools.utils import ( + execute_subprocess, + log_tool_in_stream_and_main, + print_tool_output, + run_oneshot_cmd, + suggest_string, +) # ROS2 imports try: @@ -312,19 +318,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"] = ret#last_output_lines(console, self.name, ret, n_lines=10) + result["output"] = ret # 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"] = ret#last_output_lines(console, self.name, ret, n_lines=10) + result["output"] = ret # 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"] = ret#last_output_lines(console, self.name, ret, n_lines=10) + result["output"] = ret # last_output_lines(console, self.name, ret, n_lines=10) # -- unknown ---------------------------------------------------------- else: @@ -439,7 +445,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"] = ret#last_output_lines(console, self.name, ret, n_lines=10) + result["output"] = ret # last_output_lines(console, self.name, ret, n_lines=10) # -- unknown ------------------------------------------------------------ else: @@ -963,7 +969,7 @@ def run(self, **kwargs): cancel_token = Future() console.set_stream_task(cancel_token) console.logger.log_tool("[tool]Publisher created![tool]", tool_name=self.name) - #output_lines.append(f"[TOOL {self.name}] Publisher created!") + # output_lines.append(f"[TOOL {self.name}] Publisher created!") log_tool_in_stream_and_main( console, "[tool]Publisher created![tool]", @@ -1017,9 +1023,7 @@ def run(self, **kwargs): if hasattr(msg, "data"): publish_line = f"[ROS] [INFO] Publishing: '{msg.data}'" - console.call_from_thread( - console.logger.log_msg, f"{publish_line}" - ) + console.call_from_thread(console.logger.log_msg, f"{publish_line}") else: publish_line = f"[ROS] [INFO] Publishing custom message to '{topic_name}'" console.call_from_thread( @@ -1109,7 +1113,6 @@ def run(self, **kwargs): if max_lines is not None and not isinstance(max_lines, int): max_lines = None - output_lines = [] panel_enabled = console is not None and hasattr(console, "change_route_logs") if panel_enabled: console.call_from_thread(console.change_route_logs, True) @@ -1124,7 +1127,6 @@ def run(self, **kwargs): ret = execute_subprocess(console, self.name, base_args, max_duration, max_lines, log_created=False) ret_lines = ret.splitlines() if isinstance(ret, str) and ret else [] - result["output"] = "\n".join(ret_lines) diff --git a/src/vulcanai/tools/utils.py b/src/vulcanai/tools/utils.py index d643793..a2b8142 100644 --- a/src/vulcanai/tools/utils.py +++ b/src/vulcanai/tools/utils.py @@ -88,8 +88,7 @@ async def run_streaming_cmd_async( # Check duration if max_duration is not None and (time.monotonic() - start_time) >= max_duration: log_tool_in_stream_and_main( - console, - f"[tool]Stopping:[/tool] Exceeded max_duration = {max_duration}s", tool_name=tool_name + console, f"[tool]Stopping:[/tool] Exceeded max_duration = {max_duration}s", tool_name=tool_name ) console.set_stream_task(None) process.terminate() @@ -296,11 +295,13 @@ def last_output_lines(console, tool_name: str, output: str, n_lines: int = 10) - ) return "\n".join(lines[-n_lines:]) + def print_tool_output(console, result, name): console.logger.log_tool("[tool]Output:[tool]", tool_name=name) console.logger.log_msg(result, color="gray") console.logger.log_msg("\n") + def log_tool_in_stream_and_main(console, msg: str, tool_name: str = "", error: bool = False, color: str = "") -> None: """ Log a tool message in both Logs (streaming and main) @@ -315,4 +316,4 @@ def log_tool_in_stream_and_main(console, msg: str, tool_name: str = "", error: b return for line in processed_msg.splitlines(): - main_panel.append_line(line) \ No newline at end of file + main_panel.append_line(line) From c23986e6b9e2aed383e27dcf09a69a928467e7a3 Mon Sep 17 00:00:00 2001 From: danipiza Date: Wed, 1 Apr 2026 16:31:47 +0200 Subject: [PATCH 29/55] [#23897] Fixed multiple tools in streaming panel Signed-off-by: danipiza --- src/vulcanai/console/console.py | 18 ++++--- src/vulcanai/tools/default_tools.py | 4 -- src/vulcanai/tools/utils.py | 77 ++++++++++++++++++----------- 3 files changed, 58 insertions(+), 41 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index e34a725..aeb36cc 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -217,8 +217,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 + # Number of active streaming processes routing logs to subprocess panel + self._route_logs_to_stream_panel = 0 # Suggestion index for RadioListModal self.suggestion_index = -1 self.suggestion_index_changed = threading.Event() @@ -832,13 +832,15 @@ def show_subprocess_panel(self) -> None: def change_route_logs(self, value: bool = False) -> None: """ - Route logger sink output to stream panel. + Manage logger sink output routing to stream panel. - value = True -> Stream panel - value = False -> Main panel + value = True -> Increment active streaming routes. + value = False -> Decrement active streaming routes. """ - - self._route_logs_to_stream_panel = value + if value: + self._route_logs_to_stream_panel += 1 + else: + self._route_logs_to_stream_panel = max(0, self._route_logs_to_stream_panel - 1) def hide_subprocess_panel(self) -> None: """ @@ -879,7 +881,7 @@ def add_line(self, input: str, color: str = "", subprocess_flag: bool = False) - 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: + if self._route_logs_to_stream_panel > 0 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 diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index 41a49d0..af220e7 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -1113,10 +1113,6 @@ def run(self, **kwargs): if max_lines is not None and not isinstance(max_lines, int): max_lines = None - panel_enabled = console is not None and hasattr(console, "change_route_logs") - if panel_enabled: - console.call_from_thread(console.change_route_logs, True) - # Start line (stream panel/main panel with tool color) console.logger.log_tool("[tool]Subscriber created![/tool]", tool_name=self.name) diff --git a/src/vulcanai/tools/utils.py b/src/vulcanai/tools/utils.py index a2b8142..e9775c7 100644 --- a/src/vulcanai/tools/utils.py +++ b/src/vulcanai/tools/utils.py @@ -130,49 +130,62 @@ async def run_streaming_cmd_async( process.kill() await process.wait() finally: - # Always restore default logging route and close the popup panel - # when a streaming subprocess ends. The popup stays visible and is - # closed explicitly by user Ctrl+C in console actions. - if hasattr(console, "change_route_logs"): - console.change_route_logs(False) + # Keep Ctrl+C target in sync with subprocess lifecycle. console.set_stream_task(None) return "\n".join(captured_lines) def execute_subprocess(console, tool_name, base_args, max_duration, max_lines, log_created: bool = True): stream_task = None + route_enabled = False done_event = threading.Event() result = {"output": ""} def _launcher() -> None: - nonlocal stream_task + nonlocal stream_task, route_enabled if hasattr(console, "show_subprocess_panel"): console.show_subprocess_panel() + if hasattr(console, "change_route_logs"): + console.change_route_logs(True) + route_enabled = True + # This always runs in the Textual event-loop thread - 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 + try: + loop = asyncio.get_running_loop() + stream_task = loop.create_task( + run_streaming_cmd_async( + console, + base_args, + max_duration=max_duration, + max_lines=max_lines, + tool_name=tool_name, # tool_header_str + ) ) - ) - # Keep the real task reference so Ctrl+C can cancel it. - console.set_stream_task(stream_task) - - def _on_done(task: asyncio.Task) -> None: - try: - if not task.cancelled(): - result["output"] = task.result() or "" - except Exception as e: - console.logger.log_msg(f"Echo task error: {e!r}\n", error=True) - finally: - done_event.set() - - stream_task.add_done_callback(_on_done) + # Keep the real task reference so Ctrl+C can cancel it. + console.set_stream_task(stream_task) + + def _on_done(task: asyncio.Task) -> None: + nonlocal route_enabled + try: + if not task.cancelled(): + result["output"] = task.result() or "" + except Exception as e: + console.logger.log_msg(f"Echo task error: {e!r}\n", error=True) + finally: + if route_enabled and hasattr(console, "change_route_logs"): + console.change_route_logs(False) + route_enabled = False + done_event.set() + + stream_task.add_done_callback(_on_done) + + except Exception: + if route_enabled and hasattr(console, "change_route_logs"): + console.change_route_logs(False) + route_enabled = False + done_event.set() + raise # `/rerun` workers can have their own asyncio loop in a non-UI thread. # Route UI/task creation to Textual app thread unless we are already there. @@ -308,7 +321,13 @@ def log_tool_in_stream_and_main(console, msg: str, tool_name: str = "", error: b """ processed_msg = console.logger.log_tool(msg, tool_name=tool_name, error=error, color=color) - if not getattr(console, "_route_logs_to_stream_panel", False): + route_state = getattr(console, "_route_logs_to_stream_panel", 0) + if isinstance(route_state, bool): + route_enabled = route_state + else: + route_enabled = route_state > 0 + + if not route_enabled: return main_panel = getattr(console, "main_pannel", None) From c97d76c1801c39258fadb729c4d40146d8bdacd3 Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 16 Apr 2026 08:38:52 +0200 Subject: [PATCH 30/55] [#23897] Updated streaming process logic Signed-off-by: danipiza --- src/vulcanai/console/console.py | 227 ++++++++++++++---- .../console/widget_custom_log_text_area.py | 23 +- src/vulcanai/console/widget_spinner.py | 5 +- src/vulcanai/tools/default_tools.py | 39 +-- src/vulcanai/tools/utils.py | 11 +- 5 files changed, 217 insertions(+), 88 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index aeb36cc..a3f53b7 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -50,10 +50,20 @@ def __init__(self, textual_console) -> None: self.console = textual_console def write(self, msg: str, color: str = "") -> None: + # Logger calls can come from worker threads + # Route UI writes to Textual app thread + app_thread_id = getattr(self.console, "_thread_id", None) + if app_thread_id is not None and threading.get_ident() != app_thread_id: + self.console.call_from_thread(self.console.add_line, msg, color) + return + self.console.add_line(msg, color) class VulcanConsole(App): + DEFAULT_CMD_PLACEHOLDER = "> " + # Extra info when a streaming process is active + STREAM_CMD_PLACEHOLDER = "Press Ctrl+C to stop stream | > " # CSS Styles # Two panels: left (log + input) and right (history + variables) # Right panel: 48 characters length @@ -86,12 +96,11 @@ class VulcanConsole(App): } #logcontent { - height: auto; + height: 1fr; min-height: 1; - max-height: 1fr; border: tall #333333; - scrollbar-size-vertical: 0; - scrollbar-size-horizontal: 0; + scrollbar-size-vertical: 1; + scrollbar-size-horizontal: 1; color: #ffffff; } @@ -100,6 +109,9 @@ class VulcanConsole(App): min-height: 0; border: solid #56AA08; display: none; + overflow: auto auto; + scrollbar-size-vertical: 1; + scrollbar-size-horizontal: 1; } #llm_spinner { @@ -219,6 +231,11 @@ def __init__( self.stream_task = None # Number of active streaming processes routing logs to subprocess panel self._route_logs_to_stream_panel = 0 + # Visibility state for stream panel routing + self._stream_panel_visible = False + # Buffered main-log lines emitted while stream panel is open and waiting + # for user Ctrl+C closure + self._deferred_main_lines_after_stream: list[str] = [] # Suggestion index for RadioListModal self.suggestion_index = -1 self.suggestion_index_changed = threading.Event() @@ -280,16 +297,17 @@ 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 + # Subprocess stream area (hidden by default) + # Kept below the main log to avoid visually pushing main logs upward + streamcontent = CustomLogTextArea(id="streamcontent") + yield streamcontent # Spinner Area yield SpinnerStatus(logcontent, id="llm_spinner") # Input Area - yield Input(placeholder="> ", id="cmd") + yield Input(placeholder=self.DEFAULT_CMD_PLACEHOLDER, id="cmd") # Right with Vertical(id="right"): @@ -409,14 +427,13 @@ def worker(user_input: str = "") -> None: self.plans_list.append(result.get("plan", None)) self.last_bb = result.get("blackboard", None) - # Print the backboard state + # Print the blackboard state bb_ret = result.get("blackboard", None) if bb_ret and self.debug_flag: - plan_ret = result.get("plan", None) updated_keys = self._updated_blackboard_keys(bb_before_ids, bb_ret) - if not updated_keys: - updated_keys = self._plan_tool_keys(plan_ret) - bb_ret_str = self._format_blackboard_subset(bb_ret, updated_keys) + bb_ret_str = "No new output generated by executed tools." + if updated_keys: + bb_ret_str = self._format_blackboard_subset(bb_ret, updated_keys) self.logger.log_console(f"Output of plan: {bb_ret_str}") except KeyboardInterrupt: @@ -447,23 +464,6 @@ def _updated_blackboard_keys(self, before_ids: dict[str, int], bb_after) -> list updated_keys.append(key) return updated_keys - def _plan_tool_keys(self, plan) -> list[str]: - """ - Return ordered unique tool names declared in the main plan path. - """ - if plan is None or not hasattr(plan, "plan"): - return [] - - seen = set() - keys = [] - for node in getattr(plan, "plan", []): - for step in getattr(node, "steps", []): - tool_name = getattr(step, "tool", None) - if tool_name and tool_name not in seen: - seen.add(tool_name) - keys.append(tool_name) - return keys - def _format_blackboard_subset(self, bb, keys: list[str]) -> str: """ Format a subset of blackboard keys for debug logging in Textual console. @@ -813,22 +813,36 @@ def cmd_quit(self, _) -> None: # region Logging - def show_subprocess_panel(self) -> None: + def show_subprocess_panel(self, show_notice: bool = True) -> None: """ Show the dedicated subprocess output panel at the top of the main panel. """ + if self.stream_pannel is None: return - self.logger.log_tool( - "Streaming terminal opened. Press Ctrl+C to stop the running process.", color="tool" - ) + follow_main_output = False + if self.main_pannel is not None: + follow_main_output = self.main_pannel.is_near_vertical_scroll_end(tolerance=2) + + if show_notice: + self.logger.log_tool( + "Streaming terminal opened. Press Ctrl+C to stop the running process.", color="tool" + ) self.stream_pannel.clear_console() self.stream_pannel.display = True self.stream_pannel.styles.height = 12 + self.query_one("#left", Vertical).refresh(layout=True) self.stream_pannel.refresh(layout=True) self.refresh(layout=True) + self._stream_panel_visible = True + cmd = self.query_one("#cmd", Input) + cmd.placeholder = self.STREAM_CMD_PLACEHOLDER + if follow_main_output and self.main_pannel is not None: + self.main_pannel.scroll_end(animate=False, immediate=True, x_axis=False) + self.call_after_refresh(self.main_pannel.scroll_end, animate=False, immediate=True, x_axis=False) + self.call_later(self.main_pannel.scroll_end, animate=False, immediate=True, x_axis=False) def change_route_logs(self, value: bool = False) -> None: """ @@ -837,22 +851,92 @@ def change_route_logs(self, value: bool = False) -> None: value = True -> Increment active streaming routes. value = False -> Decrement active streaming routes. """ + if value: self._route_logs_to_stream_panel += 1 + if self.stream_pannel is not None and not self._stream_panel_visible: + # Routing is active -> ensure stream panel is visible + self.show_subprocess_panel(show_notice=False) else: self._route_logs_to_stream_panel = max(0, self._route_logs_to_stream_panel - 1) + if not self._is_stream_task_active(): + # No active stream: clear stale route counter/state, but keep the + # stream panel visible until user explicitly closes it with "Ctrl + C" + self._route_logs_to_stream_panel = 0 + self.stream_task = None + + def _is_stream_task_active(self) -> bool: + """ + Return True if a streaming task/future is currently active + """ + + if self.stream_task is None: + return False + try: + return not self.stream_task.done() + except Exception: + try: + return not self.stream_task.cancelled() + except Exception: + # Unknown task-like object + return True + + def reset_stream_state(self) -> None: + """ + Reset stream routing and panel visibility + """ + + self._route_logs_to_stream_panel = 0 + self.stream_task = None + if self._stream_panel_visible: + self.hide_subprocess_panel() + + def should_defer_line_until_stream_panel_close(self, line: str) -> bool: + """ + Return True when a line should be deferred until the stream panel is closed + """ + + return any(marker in line for marker in ("[TOOL", "[EXECUTOR]", "[ROS]", "Output of plan:")) + + def write_deferred_main_output(self) -> None: + """ + Flush buffered main-log lines captured while stream panel was open. + """ + + if not self._deferred_main_lines_after_stream or self.main_pannel is None: + return + + follow_main_output = self.main_pannel.is_near_vertical_scroll_end(tolerance=2) + pending = self._deferred_main_lines_after_stream + self._deferred_main_lines_after_stream = [] + + for text in pending: + if not self.main_pannel.append_line(text, force_follow_output=follow_main_output): + self.logger.log_console("Warning: Trying to add an empty deferred line.") def hide_subprocess_panel(self) -> None: """ Hide the subprocess output panel and return space to the main log panel. """ + if self.stream_pannel is None: return + follow_main_output = False + if self.main_pannel is not None: + follow_main_output = self.main_pannel.is_near_vertical_scroll_end(tolerance=2) + self.stream_pannel.display = False self.stream_pannel.styles.height = 0 self.stream_pannel.refresh(layout=True) self.refresh(layout=True) + self._stream_panel_visible = False + self.write_deferred_main_output() + cmd = self.query_one("#cmd", Input) + cmd.placeholder = self.DEFAULT_CMD_PLACEHOLDER + if follow_main_output and self.main_pannel is not None: + self.main_pannel.scroll_end(animate=False, immediate=True, x_axis=False) + self.call_after_refresh(self.main_pannel.scroll_end, animate=False, immediate=True, x_axis=False) def add_subprocess_line(self, input: str) -> None: """ @@ -862,6 +946,19 @@ def add_subprocess_line(self, input: str) -> None: self.add_line(input) return + # Reopen only if there is an active stream/routing context + if not self._stream_panel_visible: + # After user send sigint "Ctrl + C" reset both flags + # Late subprocess lines should go to main log + # without reopening the stream panel + should_reopen = self._route_logs_to_stream_panel > 0 or self._is_stream_task_active() + if should_reopen: + self.show_subprocess_panel(show_notice=False) + + if not self._stream_panel_visible: + self.add_line(input) + return + lines = input.splitlines() for line in lines: if not self.stream_pannel.append_line(line): @@ -871,6 +968,12 @@ def add_line(self, input: str, color: str = "", subprocess_flag: bool = False) - """ Function used to write an input in the VulcanAI terminal. """ + + app_thread_id = getattr(self, "_thread_id", None) + if app_thread_id is not None and threading.get_ident() != app_thread_id: + self.call_from_thread(self.add_line, input, color, subprocess_flag) + return + # Split incoming text into individual lines lines = input.splitlines() @@ -881,8 +984,17 @@ def add_line(self, input: str, color: str = "", subprocess_flag: bool = False) - color_end = f"" target_panel = self.main_pannel - if self._route_logs_to_stream_panel > 0 and self.stream_pannel is not None and self.stream_pannel.display: - target_panel = self.stream_pannel + if self._route_logs_to_stream_panel > 0 and self.stream_pannel is not None: + if not self._stream_panel_visible: + # If somehow the streaming panel is closed without the user + # reopen it before routing output to the stream area + self.show_subprocess_panel(show_notice=False) + if self._stream_panel_visible: + target_panel = self.stream_pannel + + force_follow_main = False + if target_panel is self.main_pannel: + force_follow_main = self.main_pannel.is_near_vertical_scroll_end(tolerance=2) # Append each line; deque automatically truncates old ones for line in lines: @@ -890,7 +1002,19 @@ def add_line(self, input: str, color: str = "", subprocess_flag: bool = False) - if subprocess_flag: line_processed = escape(line) text = f"{color_begin}{line_processed}{color_end}" - if not target_panel.append_line(text): + + # Keep tool/executor output hidden in main panel while stream panel remains + # visible after stream completion. Flushes on Ctrl+C panel close + if (target_panel is self.main_pannel + and self._stream_panel_visible + and not self._is_stream_task_active() + and self._route_logs_to_stream_panel == 0 + and self.should_defer_line_until_stream_panel_close(line_processed) + ): + self._deferred_main_lines_after_stream.append(text) + continue + + if not target_panel.append_line(text, force_follow_output=force_follow_main): self.logger.log_console("Warning: Trying to add an empty line.") def delete_last_line(self): @@ -936,6 +1060,11 @@ async def on_input_submitted(self, event: Input.Submitted) -> None: # Get the user input and strip leading/trailing spaces user_input = (event.value or "").strip() + # Defensive recovery: if no stream is active but routing stayed enabled, + # clear only routing (keep panel visible until Ctrl+C) + if (not self._is_stream_task_active()) and self._route_logs_to_stream_panel > 0: + self._route_logs_to_stream_panel = 0 + # The the user_input in the history navigation list (used when the up, down keys are pressed) self.history.append(user_input) self.history_index = len(self.history) @@ -1224,19 +1353,19 @@ def action_stop_streaming_task(self) -> None: Binding("ctrl+c", "stop_streaming_task", ...), """ - if self.stream_task is not None and not self.stream_task.done(): - # Cancel the streaming task - self.stream_task.cancel() # Triggers CancelledError in the task - self.stream_task = None - # Close popup terminal explicitly on user Ctrl+C. - self.change_route_logs(False) - self.hide_subprocess_panel() - elif self.stream_pannel is not None and self.stream_pannel.display: - # No active stream, but popup is still visible (e.g. stopped by limits). - # Let Ctrl+C close it explicitly. - self.change_route_logs(False) - self.hide_subprocess_panel() + if self._is_stream_task_active(): + # Cancel the active streaming task/future. + try: + self.stream_task.cancel() # Triggers CancelledError / cancelled flag + except Exception: + pass + self.reset_stream_state() + + elif self.stream_pannel is not None and ( + self._stream_panel_visible or self._route_logs_to_stream_panel > 0): + # No active stream, but stale stream UI/routing is visible + self.reset_stream_state() else: # No streaming task running, just notify the user diff --git a/src/vulcanai/console/widget_custom_log_text_area.py b/src/vulcanai/console/widget_custom_log_text_area.py index 4168a12..66d8fc3 100644 --- a/src/vulcanai/console/widget_custom_log_text_area.py +++ b/src/vulcanai/console/widget_custom_log_text_area.py @@ -239,7 +239,7 @@ def _ensure_style_token(self, style: str) -> str: # region LOG - def append_line(self, text: str) -> bool: + def append_line(self, text: str, force_follow_output: bool = False) -> bool: """ Function used to append a new line to the CustomLogTextArea. @@ -297,7 +297,9 @@ 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_near_vertical_scroll_end() + should_follow_output = force_follow_output or self.is_near_vertical_scroll_end() + previous_scroll_x = self.scroll_offset.x + previous_scroll_y = self.scroll_offset.y # Append via document API to keep row tracking consistent # Only add a newline before the new line if there is already content @@ -326,6 +328,23 @@ def append_line(self, text: str) -> bool: 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) + else: + # Keep user viewport stable while new lines arrive in the background + self.scroll_to( + x=previous_scroll_x, + y=previous_scroll_y, + animate=False, + immediate=True, + force=True, + ) + self.call_after_refresh( + self.scroll_to, + x=previous_scroll_x, + y=previous_scroll_y, + animate=False, + immediate=True, + force=True, + ) # Rebuild highlights and refresh self._rebuild_highlights() diff --git a/src/vulcanai/console/widget_spinner.py b/src/vulcanai/console/widget_spinner.py index b8c7a40..33c5c97 100644 --- a/src/vulcanai/console/widget_spinner.py +++ b/src/vulcanai/console/widget_spinner.py @@ -81,7 +81,10 @@ def stop(self) -> None: self.display = False self.styles.height = 0 if self._forced_compact: - self.logcontent.styles.height = "auto" + # Keep the log area in the same flexible layout mode used by the console. + # Restoring to "auto" can cause the stream panel to stop appearing on + # subsequent runs when the log grows. + self.logcontent.styles.height = "1fr" self._forced_compact = False self.refresh(layout=True) self.logcontent.refresh(layout=True) diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index af220e7..9c80be9 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -927,9 +927,7 @@ def run(self, **kwargs): msg_type_str = kwargs.get("msg_type", "std_msgs/msg/String") max_duration = kwargs.get("max_duration", None) - if max_duration is not None and not isinstance(max_duration, (int, float)): - max_duration = None - if max_duration is not None and max_duration <= 0: + if not isinstance(max_duration, (int, float)) or max_duration <= 0: max_duration = None max_lines = kwargs.get("max_lines", None) @@ -968,8 +966,6 @@ def run(self, **kwargs): 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) - # output_lines.append(f"[TOOL {self.name}] Publisher created!") log_tool_in_stream_and_main( console, "[tool]Publisher created![tool]", @@ -981,22 +977,13 @@ def run(self, **kwargs): while True: if cancel_token.cancelled(): - log_tool_in_stream_and_main( - console, - "[tool]Ctrl+C received:[/tool] stopping publish...", - tool_name=self.name, - ) + console.logger.log_tool("[tool]Ctrl+C received:[/tool] stopping publish...", tool_name=self.name) break if max_lines is not None and published_count >= max_lines: - log_tool_in_stream_and_main( - console, - f"[tool]Stopping:[/tool] Reached max_lines = {max_lines}", - tool_name=self.name, - ) + console.logger.log_tool(f"[tool]Stopping:[/tool] Reached max_lines = {max_lines}", tool_name=self.name) break if max_duration is not None and (time.monotonic() - start_time) >= max_duration: - log_tool_in_stream_and_main( - console, + console.logger.log_tool( f"[tool]Stopping:[/tool] Exceeded max_duration = {max_duration}s", tool_name=self.name, ) @@ -1133,22 +1120,4 @@ def run(self, **kwargs): print_tool_output(console, result["output"], self.name) - # if panel_enabled: - # console.call_from_thread(console.change_route_logs, False) - # if ret_lines: - # console.logger.log_msg("\n".join(f"{line}" for line in ret_lines)) - # # if stop_reason: - # # if max_lines is not None and len(ret_lines) >= max_lines: - # # console.logger.log_tool( - # # f"[tool]Stopping:[/tool] Reached max_lines = {max_lines}", - # # tool_name=self.name, - # # ) - # # elif max_duration is not None: - # # console.logger.log_tool( - # # f"[tool]Stopping:[/tool] Exceeded max_duration = {max_duration}s", - # # tool_name=self.name, - # # ) - # console.logger.log_msg("\n") - # else: - # print_tool_output(console, result["output"], self.name) return result diff --git a/src/vulcanai/tools/utils.py b/src/vulcanai/tools/utils.py index e9775c7..79306b3 100644 --- a/src/vulcanai/tools/utils.py +++ b/src/vulcanai/tools/utils.py @@ -334,5 +334,14 @@ def log_tool_in_stream_and_main(console, msg: str, tool_name: str = "", error: b if main_panel is None: return + app_thread_id = getattr(console, "_thread_id", None) + on_app_thread = app_thread_id is None or threading.get_ident() == app_thread_id + force_follow_main = False + for line in processed_msg.splitlines(): - main_panel.append_line(line) + if on_app_thread: + main_panel.append_line(line, force_follow_main) + elif hasattr(console, "call_from_thread"): + console.call_from_thread(main_panel.append_line, line, force_follow_main) + else: + main_panel.append_line(line, force_follow_main) From e5be0f238e6fee26420e5ce412ebd5adeae4e0e7 Mon Sep 17 00:00:00 2001 From: danipiza Date: Mon, 20 Apr 2026 16:10:06 +0200 Subject: [PATCH 31/55] [#23897] Split ROS 2 CLI default tools into granular per-command tools Signed-off-by: danipiza --- src/vulcanai/tools/default_tools.py | 2496 ++++++++++++++++++--------- 1 file changed, 1652 insertions(+), 844 deletions(-) diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index 9c80be9..b7defe8 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -47,7 +47,9 @@ class ROS2DefaultToolNode(Node): def __init__(self, name: str = "vulcanai_ros2_default_tools_node"): if not rclpy.ok(): rclpy.init() - super().__init__(name) + # This helper node is not spun continuously, so exposing parameter services + # would make `ros2 param list` hang while waiting on unanswered requests. + super().__init__(name, start_parameter_services=False) # Dictionary to store created clients self._vulcan_clients = {} # Dictionary to store created publishers @@ -170,954 +172,1760 @@ def callback(msg): """ -@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." - ) - tags = ["ros2", "nodes", "cli", "info", "diagnostics"] +def _require_console(bb): + console = bb.get("console", None) + if console is None: + raise Exception("Could not find console, aborting...") + return console - # - `command` lets you pick a single subcommand (list/info). - input_schema = [ - ("command", "string"), # Command - ("node_name", "string?"), # (optional) Topic name. (info/bw/delay/hz/type/pub) - ] - output_schema = { - "output": "string", # list of ros2 nodes or info of a node. - } +def _normalize_command(command): + if command is None: + raise ValueError("`command` is required.") + return str(command).lower() - def run(self, **kwargs): - # 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) # optional explicit subcommand - node_name = kwargs.get("node_name", "") +def _run_ros2_node_command(console, tool_name: str, command: str, node_name: str = None): + command = _normalize_command(command) + result = {"output": ""} - result = { - "output": "", - } + node_name_list_str = run_oneshot_cmd(["ros2", "node", "list"]) + node_name_list = node_name_list_str.splitlines() - command = command.lower() + if command == "list": + result["output"] = node_name_list_str + elif command == "info": + suggested_topic = suggest_string(console, tool_name, "Node", node_name, node_name_list) + if suggested_topic is not None: + node_name = suggested_topic - # -- Node name suggestions -- - node_name_list_str = run_oneshot_cmd(["ros2", "node", "list"]) - node_name_list = node_name_list_str.splitlines() + if not node_name: + raise ValueError("`command='{}'` requires `node_name`.".format("info")) - # -- Run `ros2 node list` --------------------------------------------- - if command == "list": - result["output"] = node_name_list_str + result["output"] = run_oneshot_cmd(["ros2", "node", "info", node_name]) + else: + raise ValueError(f"Unknown command '{command}'. Expected one of: list, info.") + + print_tool_output(console, result["output"], tool_name) + return result + + +def _run_ros2_topic_command( + console, + tool_name: str, + command: str, + topic_name: str = None, + msg_type: str = None, + max_duration: float = None, + max_lines: int = None, +): + command = _normalize_command(command) + result = {"output": ""} + + topic_name_list_str = run_oneshot_cmd(["ros2", "topic", "list"]) + topic_name_list = topic_name_list_str.splitlines() + + if command == "find": + if not msg_type: + raise ValueError("`command='find'` requires `msg_type`.") + elif command != "list": + suggested_topic_name = suggest_string(console, tool_name, "Topic", topic_name, topic_name_list) + if suggested_topic_name is not None: + topic_name = suggested_topic_name + + if not topic_name: + raise ValueError("`command='{}'` requires `topic_name`.".format(command)) + + if command == "list": + result["output"] = topic_name_list_str + elif command == "info": + result["output"] = run_oneshot_cmd(["ros2", "topic", "info", topic_name]) + elif command == "find": + find_output = run_oneshot_cmd(["ros2", "topic", "find", msg_type]) + find_topics = [line.strip() for line in find_output.splitlines() if line.strip()] + result["output"] = ", ".join(find_topics) + elif command == "type": + result["output"] = run_oneshot_cmd(["ros2", "topic", "type", topic_name]) + elif command == "bw": + result["output"] = execute_subprocess( + console, + tool_name, + ["ros2", "topic", "bw", topic_name], + max_duration, + max_lines, + ) + elif command == "delay": + result["output"] = execute_subprocess( + console, + tool_name, + ["ros2", "topic", "delay", topic_name], + max_duration, + max_lines, + ) + elif command == "hz": + result["output"] = execute_subprocess( + console, + tool_name, + ["ros2", "topic", "hz", topic_name], + max_duration, + max_lines, + ) + else: + raise ValueError( + f"Unknown command '{command}'. Expected one of: list, info, echo, bw, delay, hz, find, pub, type." + ) - # -- Run `ros2 node info ` -------------------------------------- + print_tool_output(console, result["output"], tool_name) + return result + + +def _run_ros2_service_command( + console, + tool_name: str, + command: str, + service_name: str = None, + service_type: str = None, + call_args: str = None, + max_duration: float = None, + max_lines: int = None, +): + command = _normalize_command(command) + result = {"output": ""} + + service_name_list_str = run_oneshot_cmd(["ros2", "service", "list"]) + service_name_list = service_name_list_str.splitlines() + + if command == "find": + if not service_type: + raise ValueError("`command='find'` requires `service_type`.") + elif command != "list": + suggested_service_name = suggest_string(console, tool_name, "Service", service_name, service_name_list) + if suggested_service_name is not None: + service_name = suggested_service_name + + if not service_name: + raise ValueError("`command='{}'` requires `service_name`.".format(command)) + + if command == "list": + result["output"] = service_name_list_str + elif command == "info": + result["output"] = run_oneshot_cmd(["ros2", "service", "info", service_name]) + elif command == "type": + result["output"] = run_oneshot_cmd(["ros2", "service", "type", service_name]).strip() + elif command == "find": + result["output"] = run_oneshot_cmd(["ros2", "service", "find", service_type]) + elif command == "call": + if call_args is None: + raise ValueError("`command='call'` requires `args`.") + if not service_type: + service_type = run_oneshot_cmd(["ros2", "service", "type", service_name]).strip() + result["output"] = run_oneshot_cmd(["ros2", "service", "call", service_name, service_type, call_args]) + elif command == "echo": + result["output"] = execute_subprocess( + console, + tool_name, + ["ros2", "service", "echo", service_name], + max_duration, + max_lines, + ) + else: + raise ValueError(f"Unknown command '{command}'. Expected one of: list, info, type, call, echo, find.") + + print_tool_output(console, result["output"], tool_name) + return result + + +def _run_ros2_action_command( + console, + tool_name: str, + command: str, + action_name: str = None, + action_type: str = None, + goal_args: str = None, +): + command = _normalize_command(command) + result = {"output": ""} + + action_name_list_str = run_oneshot_cmd(["ros2", "action", "list"]) + action_name_list = action_name_list_str.splitlines() + + if command != "list": + suggested_action_name = suggest_string(console, tool_name, "Action", action_name, action_name_list) + if suggested_action_name is not None: + action_name = suggested_action_name + + if not action_name: + raise ValueError("`command='{}'` requires `action_name`.".format(command)) + + if command == "list": + result["output"] = action_name_list_str + elif command == "info": + result["output"] = run_oneshot_cmd(["ros2", "action", "info", action_name]) + elif command == "type": + result["output"] = run_oneshot_cmd(["ros2", "action", "type", action_name]) + elif command == "send_goal": + if not action_type: + action_type = run_oneshot_cmd(["ros2", "action", "type", action_name]).strip() + args_list = ["ros2", "action", "send_goal", action_name, action_type] + if goal_args is not None: + args_list.append(goal_args) + result["output"] = run_oneshot_cmd(args_list) + else: + raise ValueError(f"Unknown command '{command}'. Expected one of: list, info, type, send_goal.") + + print_tool_output(console, result["output"], tool_name) + return result + + +def _run_ros2_param_command( + console, + tool_name: str, + command: str, + node_name: str = None, + param_name: str = None, + set_value: str = None, + file_path: str = None, +): + command = _normalize_command(command) + result = {"output": ""} + + param_name_list_str = run_oneshot_cmd(["ros2", "param", "list"]) + node_name_list_str = run_oneshot_cmd(["ros2", "node", "list"]) + + param_name_list = param_name_list_str.splitlines() + node_name_list = node_name_list_str.splitlines() + + if command != "list": + if command not in ["dump", "load"]: + suggested_param_name = suggest_string(console, tool_name, "Param", param_name, param_name_list) + if suggested_param_name is not None: + param_name = suggested_param_name + if not param_name: + raise ValueError("`command='{}'` requires `param_name`.".format(command)) + + suggested_node_name = suggest_string(console, tool_name, "Node", node_name, node_name_list) + if suggested_node_name is not None: + node_name = suggested_node_name + if not node_name: + raise ValueError("`command='{}'` requires `node_name`.".format(command)) + + if command == "list": + if node_name: + result["output"] = node_name_list_str 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, "Node", node_name, node_name_list) - if suggested_topic is not None: - node_name = suggested_topic + result["output"] = param_name_list_str + elif command == "get": + get_output = run_oneshot_cmd(["ros2", "param", "get", node_name, param_name]) + if "parameter not set" in get_output.lower(): + raise Exception(f"Parameter '{param_name}' is not set on node '{node_name}'.") + result["output"] = get_output + elif command == "describe": + result["output"] = run_oneshot_cmd(["ros2", "param", "describe", node_name, param_name]) + elif command == "set": + if set_value is None: + raise ValueError("`command='set'` requires `set_value`.") + result["output"] = run_oneshot_cmd(["ros2", "param", "set", node_name, param_name, set_value]) + elif command == "delete": + result["output"] = run_oneshot_cmd(["ros2", "param", "delete", node_name, param_name]) + elif command == "dump": + if file_path: + dump_output = run_oneshot_cmd(["ros2", "param", "dump", node_name, "--output-file", file_path]) + result["output"] = dump_output or f"Dumped parameters to {file_path}" + else: + result["output"] = run_oneshot_cmd(["ros2", "param", "dump", node_name]) + elif command == "load": + if not file_path: + raise ValueError("`command='load'` `file_path`.") + result["output"] = run_oneshot_cmd(["ros2", "param", "load", node_name, file_path]) + else: + raise ValueError(f"Unknown command '{command}'. Expected one of: list, get, describe, set, delete, dump, load.") + + print_tool_output(console, result["output"], tool_name) + return result + + +def _run_ros2_pkg_command(console, tool_name: str, command: str): + command = _normalize_command(command) + result = {"output": ""} + + if command == "list": + result["output"] = run_oneshot_cmd(["ros2", "pkg", "list"]) + elif command == "executables": + result["output"] = run_oneshot_cmd(["ros2", "pkg", "executables"]) + else: + raise ValueError(f"Unknown command '{command}'. Expected one of: list, executables, prefix, xml") + + print_tool_output(console, result["output"], tool_name) + return result + + +def _run_ros2_interface_command(console, tool_name: str, command: str, interface_name: str = None): + command = _normalize_command(command) + result = {"output": ""} + + interface_name_list_str = run_oneshot_cmd(["ros2", "interface", "list"]) + interface_name_list = interface_name_list_str.splitlines() + + package_name_list_str = run_oneshot_cmd(["ros2", "interface", "packages"]) + package_name_list = package_name_list_str.splitlines() + + if command == "list": + result["output"] = interface_name_list_str + elif command == "packages": + result["output"] = package_name_list_str + elif command == "package": + package_name = interface_name + suggested_package_name = suggest_string(console, tool_name, "Interface", package_name, package_name_list) + if suggested_package_name is not None: + package_name = suggested_package_name + if not interface_name: + raise ValueError("`command='{}'` requires `interface_name`.".format(command)) + # Keep existing command behavior for compatibility. + result["output"] = run_oneshot_cmd(["ros2", "topic", "package", package_name]) + elif command == "show": + suggested_interface_name = suggest_string(console, tool_name, "Interface", interface_name, interface_name_list) + if suggested_interface_name is not None: + interface_name = suggested_interface_name + if not interface_name: + raise ValueError("`command='{}'` requires `interface_name`.".format(command)) + # Keep existing command behavior for compatibility. + result["output"] = run_oneshot_cmd(["ros2", "topic", "show", interface_name]) + else: + raise ValueError( + f"Unknown command '{command}'. Expected one of: list, info, echo, bw, delay, hz, find, pub, type." + ) - if not node_name: - raise ValueError("`command='{}'` requires `node_name`.".format("info")) + print_tool_output(console, result["output"], tool_name) + return result - info_output = run_oneshot_cmd(["ros2", "node", "info", node_name]) - result["output"] = info_output - print_tool_output(console, result["output"], self.name) - return result +# --------------------------------------------------------------------------- +# Strict per-subcommand ROS 2 tools (planner-facing) +# --------------------------------------------------------------------------- @vulcanai_tool -class Ros2TopicTool(AtomicTool): - name = "ros2_topic" +class Ros2NodeListTool(AtomicTool): + name = "ros2_node_list" description = ( - "Wrapper for `ros2 topic` CLI." - "Run a subcommand from: 'list', 'info', 'find', 'type', 'bw', 'delay', 'hz'." - "With optional arguments like 'topic_name', 'message_type', 'max_duration' or 'max_lines'" + "List all currently available ROS 2 nodes. " + "Equivalent to `ros2 node list`. " + "Use when the user wants to list, show, display, or print ROS 2 nodes." ) - tags = ["ros2", "topics", "cli", "info"] + tags = [ + "ros2", + "ros 2", + "nodes", + "list", + "node list", + "ros2 node list", + "list ros2 nodes", + "show ros2 nodes", + "print ros2 nodes", + "available nodes", + ] + input_schema = [] + output_schema = {"output": "string"} - # - `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/bw/delay/hz/type/pub) - ("msg_type", "string?"), # (optional) Message type (`find` , `pub` ) - ("max_duration", "number?"), # (optional) Seconds for streaming commands (bw/hz/delay) - ("max_lines", "int?"), # (optional) Cap number of lines for streaming commands + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_node_command(console, self.name, "list") + + +@vulcanai_tool +class Ros2NodeInfoTool(AtomicTool): + name = "ros2_node_info" + description = ( + "Show details for a specific ROS 2 node. " + "Equivalent to `ros2 node info `." + "Use when the user wants to list, show, display, or print the information of a ROS 2 node." + ) + tags = [ + "ros2", + "ros 2", + "info", + "node info", + "ros2 node info", + "inspect node", + "show node details" ] + input_schema = [("node_name", "string")] + output_schema = {"output": "string"} + + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_node_command(console, self.name, "info", node_name=kwargs.get("node_name")) - output_schema = { - "output": "string", # output - } + +@vulcanai_tool +class Ros2TopicListTool(AtomicTool): + name = "ros2_topic_list" + description = ( + "List all currently available ROS 2 topics. " + "Equivalent to `ros2 topic list`. " + "Use when the user wants to list, show, display, or print ROS 2 topics." + ) + tags = [ + "ros2", + "ros 2", + "topics", + "list", + "topic list", + "ros2 topic list", + "list ros2 topics", + "show ros2 topics", + "print ros2 topics", + "available topics", + ] + input_schema = [] + output_schema = {"output": "string"} def run(self, **kwargs): - # 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) # 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", None) - max_lines = kwargs.get("max_lines", None) - - result = { - "output": "", - } - - command = command.lower() - - 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 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 is not 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_str - - # -- ros2 topic info ------------------------------------- - elif command == "info": - 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) - - # -- ros2 topic type ------------------------------------- - elif command == "type": - type_output = run_oneshot_cmd(["ros2", "topic", "type", topic_name]) - result["output"] = type_output - - # -- ros2 topic bw --------------------------------------- - elif command == "bw": - base_args = ["ros2", "topic", "bw", topic_name] - ret = execute_subprocess(console, self.name, base_args, max_duration, max_lines) - result["output"] = ret # 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"] = ret # 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"] = ret # last_output_lines(console, self.name, ret, n_lines=10) - - # -- unknown ---------------------------------------------------------- - else: - raise ValueError( - f"Unknown command '{command}'. Expected one of: list, info, echo, bw, delay, hz, find, pub, type." - ) + console = _require_console(self.bb) + return _run_ros2_topic_command(console, self.name, "list") + + +@vulcanai_tool +class Ros2TopicInfoTool(AtomicTool): + name = "ros2_topic_info" + description = ( + "Show details for a specific ROS 2 topic. " + "Equivalent to `ros2 topic info`. " + "Use when the user wants to list, show, display, or print the information of a ROS 2 topic." + ) + tags = [ + "ros2", + "ros 2", + "info", + "topic info", + "ros2 topic info", + "inspect topic", + "show topic details" + ] + input_schema = [("topic_name", "string")] + output_schema = {"output": "string"} - print_tool_output(console, result["output"], self.name) - return result + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_topic_command(console, self.name, "info", topic_name=kwargs.get("topic_name")) @vulcanai_tool -class Ros2ServiceTool(AtomicTool): - name = "ros2_service" +class Ros2TopicFindTool(AtomicTool): + name = "ros2_topic_find" description = ( - "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'" + "Find ROS 2 topics by message type. " + "Equivalent to `ros2 topic find `." + "Use when the user wants to find, show, display, or print the topic with a given ROS 2 topic type." ) - tags = ["ros2", "services", "cli", "info", "call"] + tags = [ + "ros2", + "ros 2", + "find", + "topic find", + "ros2 topic find", + "find topics by type", + "topics by message type", + "find ros2 topics", + "message type", + ] + input_schema = [("msg_type", "string")] + output_schema = {"output": "string"} - # - `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 + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_topic_command(console, self.name, "find", msg_type=kwargs.get("msg_type")) + + +@vulcanai_tool +class Ros2TopicTypeTool(AtomicTool): + name = "ros2_topic_type" + description = ( + "Show the message type used by a ROS 2 topic. " + "Equivalent to `ros2 topic type `." + "Use when the user wants to get, show, display, or print the type of a ROS 2 topic." + ) + tags = [ + "ros2", + "ros 2", + "type", + "topic type", + "ros2 topic type", + "topic message type", + "show topic type", + "get topic type", + "topic datatype", + ] + input_schema = [("topic_name", "string")] + output_schema = {"output": "string"} + + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_topic_command(console, self.name, "type", topic_name=kwargs.get("topic_name")) + + +@vulcanai_tool +class Ros2TopicBwTool(AtomicTool): + name = "ros2_topic_bw" + description = ( + "Stream and observe ROS 2 topic bandwidth. " + "Equivalent to `ros2 topic bw`. " + "Use when the user wants to show, display, or print the bandwidth of a ROS 2 topic that is publishing. " + "If optional limits are omitted, it defaults to 60 seconds and 100 lines." + ) + tags = [ + "ros2", + "ros 2", + "bw", + "bandwidth", + "topic bandwidth", + "ros2 topic bandwidth", + "inspect topic bandwidth", + "show topic bandwidth" ] - output_schema = { - "output": "string", # `ros2 service list` - } + input_schema = [ + ("topic_name", "string"), + ("max_duration", "float?"), + ("max_lines", "int?") + ] + input_defaults = {"max_duration": 60, "max_lines": 100} + output_schema = {"output": "string"} def run(self, **kwargs): - # 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) - service_name = kwargs.get("service_name", None) - service_type = kwargs.get("service_type", None) - call_args = kwargs.get("args", None) - # Streaming commands variables - max_duration = kwargs.get("max_duration", None) - max_lines = kwargs.get("max_lines", None) - - result = { - "output": "", - } - - command = command.lower() - - 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 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 is not 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_str - - # -- ros2 service info --------------------------------- - elif command == "info": - 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]) - result["output"] = type_output.strip() - - # -- ros2 service find ----------------------------------------- - elif command == "find": - 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 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": - base_args = ["ros2", "service", "echo", service_name] - ret = execute_subprocess(console, self.name, base_args, max_duration, max_lines) - result["output"] = ret # last_output_lines(console, self.name, ret, n_lines=10) - - # -- unknown ------------------------------------------------------------ - else: - raise ValueError(f"Unknown command '{command}'. Expected one of: list, info, type, call, echo, find.") + console = _require_console(self.bb) - print_tool_output(console, result["output"], self.name) - return result + # Streaming commands variables + max_duration = kwargs.get("max_duration") + if max_duration is None: + max_duration = 60 + + max_lines = kwargs.get("max_lines") + if max_lines is None: + max_lines = 100 + + return _run_ros2_topic_command( + console, + self.name, + "bw", + topic_name=kwargs.get("topic_name"), + max_duration=max_duration, + max_lines=max_lines, + ) @vulcanai_tool -class Ros2ActionTool(AtomicTool): - name = "ros2_action" +class Ros2TopicDelayTool(AtomicTool): + name = "ros2_topic_delay" description = ( - "Wrapper for `ros2 action` CLI." - "Run any subcommand: 'list', 'info', 'type', 'send_goal'." - "With optional arguments like 'action_name', 'action_type', " - "'goal_args'" + "Stream and observe ROS 2 topic delay. " + "Equivalent to `ros2 topic delay`. " + "Use when the user wants to show, display, or print the delay of a ROS 2 topic that is publishing." ) - tags = ["ros2", "actions", "cli", "info", "goal"] + tags = [ + "ros2", + "ros 2", + "delay", + "topic delay", + "ros2 topic delay", + "inspect topic delay", + "show topic delay" + ] + input_schema = [("topic_name", "string"), ("max_duration", "float"), ("max_lines", "int")] + output_schema = {"output": "string"} - # - `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}' + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_topic_command( + console, + self.name, + "delay", + topic_name=kwargs.get("topic_name"), + max_duration=kwargs.get("max_duration"), + max_lines=kwargs.get("max_lines"), + ) + + +@vulcanai_tool +class Ros2TopicHzTool(AtomicTool): + name = "ros2_topic_hz" + description = ( + "Stream and observe ROS 2 topic average receiving rate. " + "Equivalent to `ros2 topic hz`. " + "Use when the user wants to show, display, or print the average receiving rate of a ROS 2 topic that is publishing." + ) + tags = [ + "ros2", + "ros 2", + "hz" + "rate" + "receiving rate" + "average receiving rate" + "topic hz", + "ros2 topic hz", + "inspect topic hz", + "show topic hz" ] + input_schema = [("topic_name", "string"), ("max_duration", "float"), ("max_lines", "int")] + output_schema = {"output": "string"} - output_schema = { - "output": "string", # `ros2 action list` - } + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_topic_command( + console, + self.name, + "hz", + topic_name=kwargs.get("topic_name"), + max_duration=kwargs.get("max_duration"), + max_lines=kwargs.get("max_lines"), + ) + + +@vulcanai_tool +class Ros2ServiceListTool(AtomicTool): + name = "ros2_service_list" + description = ( + "List all currently available ROS 2 services. " + "Equivalent to `ros2 service list`. " + "Use when the user wants to list, show, display, or print ROS 2 services." + ) + tags = [ + "ros2", + "ros 2", + "services", + "list", + "service list", + "ros2 service list", + "list ros2 services", + "show ros2 services", + "print ros2 services", + "available services", + ] + input_schema = [] + output_schema = {"output": "string"} def run(self, **kwargs): - # 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) - action_type = kwargs.get("action_type", None) - goal_args = kwargs.get("goal_args", None) - - result = { - "output": "", - } - - command = command.lower() - - 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 is not 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_str - - # -- ros2 action info ----------------------------------- - elif command == "info": - info_output = run_oneshot_cmd(["ros2", "action", "info", action_name]) - result["output"] = info_output - - # -- ros2 action type `." + "Use when the user wants to get, show, display, or print the type of a ROS 2 service." ) - tags = ["ros2", "param", "parameters", "cli"] + tags = [ + "ros2", + "ros 2", + "type", + "service type", + "ros2 service type", + "service message type", + "show service type", + "get service type", + "service datatype", + ] + input_schema = [("service_name", "string")] + output_schema = {"output": "string"} - # - `command`: "list", "get", "describe", "set", "delete", "dump", "load" - input_schema = [ - ("command", "string"), # Command - ("param_name", "string?"), # (optional) Parameter name - ("node_name", "string?"), # (optional) Target node - ("set_value", "string?"), # (optional) value for set - ("file_path", "string?"), # (optional) for dump/load YAML file + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_service_command(console, self.name, "type", service_name=kwargs.get("service_name")) + + +@vulcanai_tool +class Ros2ServiceFindTool(AtomicTool): + name = "ros2_service_find" + description = ( + "Find ROS 2 services by message type. " + "Equivalent to `ros2 service find `." + "Use when the user wants to find, show, display, or print the service with a given ROS 2 sevice type." + ) + tags = [ + "ros2", + "ros 2", + "service find", + "ros2 service find", + "find services by type", + "services by message type", + "find ros2 services", + "message type", ] + input_schema = [("service_type", "string")] + output_schema = {"output": "string"} + + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_service_command(console, self.name, "find", service_type=kwargs.get("service_type")) - output_schema = { - "output": "string", - } + +@vulcanai_tool +class Ros2ServiceCallTool(AtomicTool): + name = "ros2_service_call" + description = ( + "Call a ROS 2 service. " + "Equivalent to `ros2 service call `." + "Use when the user wants to call a ROS 2 service ." + ) + tags = [ + "ros2", + "ros 2", + "call", + "ros2 service call", + "call service", + "call ros2 service", + ] + input_schema = [("service_name", "string"), ("service_type", "string"), ("args", "string")] + output_schema = {"output": "string"} def run(self, **kwargs): - # 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) - node_name = kwargs.get("node_name", None) - param_name = kwargs.get("param_name", None) - set_value = kwargs.get("set_value", None) - file_path = kwargs.get("file_path", None) - - result = { - "output": "", - } - - command = command.lower() - - param_name_list_str = run_oneshot_cmd(["ros2", "topic", "list"]) - node_name_list_str = run_oneshot_cmd(["ros2", "node", "list"]) - - param_name_list = param_name_list_str.splitlines() - node_name_list = node_name_list_str.splitlines() - - # -- Param/Node name suggestions -- - if command != "list": - # Check if the param_name is not available 'ros2 param list' - if command not in ["dump", "load"]: - suggested_param_name = suggest_string(console, self.name, "Param", param_name, param_name_list) - if suggested_param_name is not None: - param_name = suggested_param_name - - # Check if the param_name is null (suggest_string() failed) - if not param_name: - raise ValueError("`command='{}'` requires `param_name`.".format(command)) - - # Check if the node_name is not available 'ros2 node list' - suggested_node_name = suggest_string(console, self.name, "Node", node_name, node_name_list) - if suggested_node_name is not None: - node_name = suggested_node_name - - # Check if the node_name is null (suggest_string() failed) - if not node_name: - raise ValueError("`command='{}'` requires `node_name`.".format(command)) - - # -- ros2 param list` ------------------------------------------------- - if command == "list": - if node_name: - result["output"] = node_name_list_str - else: - result["output"] = param_name_list_str - - # -- ros2 param get ------------------------------------ - elif command == "get": - get_output = run_oneshot_cmd(["ros2", "param", "get", node_name, param_name]) - # In newer ROS 2 releases this case can return exit code 0 while - # still reporting an unset parameter. Surface it as an error so - # callers can reliably detect deleted/missing values. - if "parameter not set" in get_output.lower(): - raise Exception(f"Parameter '{param_name}' is not set on node '{node_name}'.") - result["output"] = get_output - - # -- ros2 param describe ------------------------------- - elif command == "describe": - describe_output = run_oneshot_cmd(["ros2", "param", "describe", node_name, param_name]) - result["output"] = describe_output - - # -- ros2 param set ------------------------ - elif command == "set": - 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]) - result["output"] = set_output - - # -- ros2 param delete ---------------------------------- - elif command == "delete": - delete_output = run_oneshot_cmd(["ros2", "param", "delete", node_name, param_name]) - result["output"] = delete_output - - # -- ros2 param dump [file_path] ------------------------------- - elif command == "dump": - # 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_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]) - result["output"] = dump_output - - # -- ros2 param load ------------------------------- - elif command == "load": - if not file_path: - raise ValueError("`command='load'` `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." - ) + console = _require_console(self.bb) + return _run_ros2_service_command( + console, + self.name, + "call", + service_name=kwargs.get("service_name"), + service_type=kwargs.get("service_type"), + call_args=kwargs.get("args"), + ) - print_tool_output(console, result["output"], self.name) - return result + +@vulcanai_tool +class Ros2ServiceEchoTool(AtomicTool): + name = "ros2_service_echo" + description = ( + "Stream and observe ROS 2 service traffic over time. " + "Equivalent to `ros2 service echo `." + "Use when the user wants to show, display, or print the information a ROS 2 service is publishing." + ) + tags = [ + "ros2", + "ros 2", + "service echo", + "ros2 service echo", + "show service traffic", + "stream service traffic", + "observe service", + "echo service", + ] + input_schema = [("service_name", "string"), ("max_duration", "float"), ("max_lines", "int")] + output_schema = {"output": "string"} + + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_service_command( + console, + self.name, + "echo", + service_name=kwargs.get("service_name"), + max_duration=kwargs.get("max_duration"), + max_lines=kwargs.get("max_lines"), + ) @vulcanai_tool -class Ros2PkgTool(AtomicTool): - name = "ros2_pkg" - description = "Wrapper for `ros2 pkg` CLI.Run any subcommand: 'list', 'executables'." - tags = ["ros2", "pkg", "packages", "cli", "introspection"] +class Ros2ActionListTool(AtomicTool): + name = "ros2_action_list" + description = ( + "List all currently available ROS 2 actions. " + "Equivalent to `ros2 action list`. " + "Use when the user wants to list, show, display, or print ROS 2 actions." + ) + tags = [ + "ros2", + "ros 2", + "actions", + "action list", + "ros2 action list", + "list ros2 actions", + "show ros2 actions", + "print ros2 actions", + "available actions", + ] + input_schema = [] + output_schema = {"output": "string"} - # If package_name is not provided, runs: `ros2 pkg list` - # If provided, runs: `ros2 pkg executables ` - input_schema = [ - ("command", "string"), # Command + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_action_command(console, self.name, "list") + + +@vulcanai_tool +class Ros2ActionInfoTool(AtomicTool): + name = "ros2_action_info" + description = ( + "Show details for a specific ROS 2 action. " + "Equivalent to `ros2 action info`. " + "Use when the user wants to list, show, display, or print the information of a ROS 2 action." + ) + tags = [ + "ros2", + "ros 2", + "action info", + "ros2 action info", + "inspect action", + "show action details" ] + input_schema = [("action_name", "string")] + output_schema = {"output": "string"} + + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_action_command(console, self.name, "info", action_name=kwargs.get("action_name")) - output_schema = { - "output": "string", # list of packages or list of executables for a package. - } + +@vulcanai_tool +class Ros2ActionTypeTool(AtomicTool): + name = "ros2_action_type" + description = ( + "Show the message type used by a ROS 2 action. " + "Equivalent to `ros2 action type `." + "Use when the user wants to get, show, display, or print the type of a ROS 2 action." + ) + tags = [ + "ros2", + "ros 2", + "action type", + "ros2 action type", + "action message type", + "show action type", + "get action type", + "action datatype", + ] + input_schema = [("action_name", "string")] + output_schema = {"output": "string"} def run(self, **kwargs): - # Used in the suggestion string - 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 - command = kwargs.get("command", None) - result = { - "output": "", - } - - command = command.lower() - - # -- Run `ros2 pkg list` ---------------------------------------------- - if command == "list": - pkg_name_list = run_oneshot_cmd(["ros2", "pkg", "list"]) - result["output"] = pkg_name_list - - # -- Run `ros2 pkg executables` --------------------------------------- - elif command == "executables": - pkg_name_list = run_oneshot_cmd(["ros2", "pkg", "executables"]) - result["output"] = pkg_name_list - - # -- unknown ---------------------------------------------------------- - else: - raise ValueError(f"Unknown command '{command}'. Expected one of: list, executables, prefix, xml") + console = _require_console(self.bb) + return _run_ros2_action_command(console, self.name, "type", action_name=kwargs.get("action_name")) - print_tool_output(console, result["output"], self.name) - return result + +@vulcanai_tool +class Ros2ActionSendGoalTool(AtomicTool): + name = "ros2_action_send_goal" + description = ( + "Send a goal to a ROS 2 action server. " + "Equivalent to `ros2 action send_goal `." + "Use when the user wants to send a ROS 2 action goal" + ) + tags = [ + "ros2", + "ros 2", + "goal", + "send goal", + "action send goal", + "ros2 action send_goal", + "send action goal", + "call action", + "trigger action" + ] + input_schema = [("action_name", "string"), ("action_type", "string"), ("goal_args", "string")] + output_schema = {"output": "string"} + + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_action_command( + console, + self.name, + "send_goal", + action_name=kwargs.get("action_name"), + action_type=kwargs.get("action_type"), + goal_args=kwargs.get("goal_args"), + ) @vulcanai_tool -class Ros2InterfaceTool(AtomicTool): - name = "ros2_interface" +class Ros2ParamListTool(AtomicTool): + name = "ros2_param_list" description = ( - "Wrapper for `ros2 interface` CLI." - "Run any subcommand: 'list', 'packages', 'package', 'show'." - "With optional arguments like 'interface_name'." + "List all currently available ROS 2 params. " + "Equivalent to `ros2 param list`. " + "Use when the user wants to list, show, display, or print ROS 2 params." ) - tags = ["ros2", "interface", "msg", "srv", "cli", "introspection"] + tags = [ + "ros2", + "ros 2", + "params", + "param list", + "ros2 param list", + "list ros2 params", + "show ros2 params", + "print ros2 params", + "available params", + ] + input_schema = [] + output_schema = {"output": "string"} - # - `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 `. + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_param_command(console, self.name, "list") + + +@vulcanai_tool +class Ros2ParamGetTool(AtomicTool): + name = "ros2_param_get" + description = ( + "Get the value of a parameter from a ROS 2 node. " + "Equivalent to `ros2 param get `." + "Use when the user want to get a Ros 2 parameter" + ) + tags = [ + "ros2", + "ros 2", + "get", + "param get", + "ros2 param get", + "get parameter", + "read parameter", + "show parameter value" ] + input_schema = [("node_name", "string"), ("param_name", "string")] + output_schema = {"output": "string"} + + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_param_command( + console, + self.name, + "get", + node_name=kwargs.get("node_name"), + param_name=kwargs.get("param_name"), + ) - output_schema = { - "output": "string", # list of interfaces (as list of strings) or full interface definition. - } + +@vulcanai_tool +class Ros2ParamDescribeTool(AtomicTool): + name = "ros2_param_describe" + description = ( + "Show the definition and metadata of a ROS 2 parameter. " + "Equivalent to `ros2 param describe `." + "Use when the user want to show, display or print a desciptive information about a ROS 2 parameter" + ) + tags = [ + "ros2", + "ros 2", + "describe", + "param describe", + "ros2 param describe", + "describe parameter", + "parameter metadata", + "parameter details", + ] + input_schema = [("node_name", "string"), ("param_name", "string")] + output_schema = {"output": "string"} def run(self, **kwargs): - # 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) - interface_name = kwargs.get("interface_name", None) - - result = { - "output": "", - } - - command = command.lower() - - interface_name_list_str = run_oneshot_cmd(["ros2", "interface", "list"]) - interface_name_list = interface_name_list_str.splitlines() - - package_name_list_str = run_oneshot_cmd(["ros2", "interface", "packages"]) - package_name_list = package_name_list_str.splitlines() - - # -- ros2 interface list ---------------------------------------------- - if command == "list": - result["output"] = interface_name_list_str - - # -- ros2 interface packages ------------------------------------------ - elif command == "packages": - 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='{}'` requires `interface_name`.".format(command)) - - 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='{}'` requires `interface_name`.".format(command)) - - 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." - ) + console = _require_console(self.bb) + return _run_ros2_param_command( + console, + self.name, + "describe", + node_name=kwargs.get("node_name"), + param_name=kwargs.get("param_name"), + ) - print_tool_output(console, result["output"], self.name) - return result +@vulcanai_tool +class Ros2ParamSetTool(AtomicTool): + name = "ros2_param_set" + description = ( + "Set the value of a parameter on a ROS 2 node. " + "Equivalent to `ros2 param set `." + "Use when the user want to set a ROS 2 parameter" + ) + tags = [ + "ros2", + "ros 2", + "set", + "param set", + "ros2 param set", + "set parameter", + "update parameter", + "change parameter value" + ] + input_schema = [("node_name", "string"), ("param_name", "string"), ("set_value", "string")] + output_schema = {"output": "string"} -def import_msg_type(type_str: str, node): - """ - Dynamically import a ROS 2 message type from its string identifier. + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_param_command( + console, + self.name, + "set", + node_name=kwargs.get("node_name"), + param_name=kwargs.get("param_name"), + set_value=kwargs.get("set_value"), + ) - 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." +@vulcanai_tool +class Ros2ParamDeleteTool(AtomicTool): + name = "ros2_param_delete" + description = ( + "Delete a parameter from a ROS 2 node. " + "Equivalent to `ros2 param delete `." + "Used when the user want to delete a ROS 2 parameter" + ) + tags = [ + "ros2", + "ros 2", + "delete", + "remove", + "param delete", + "ros2 param delete", + "delete parameter", + "remove parameter", + "unset parameter" + ] + input_schema = [("node_name", "string"), ("param_name", "string")] + output_schema = {"output": "string"} + + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_param_command( + console, + self.name, + "delete", + node_name=kwargs.get("node_name"), + param_name=kwargs.get("param_name"), ) - else: - pkg, _, msg_name = info_list - module = importlib.import_module(f"{pkg}.msg") - return getattr(module, msg_name) +@vulcanai_tool +class Ros2ParamDumpTool(AtomicTool): + name = "ros2_param_dump" + description = ( + "Export all parameters from a ROS 2 node. " + "Equivalent to `ros2 param dump `." + "Used when the user want to show, display or print all the parameters of a ROS 2 node" + ) + tags = [ + "ros2", + "ros 2", + "dump", + "yaml", + "param dump", + "ros2 param dump", + "export parameters", + "dump parameters", + "save node parameters" + ] + input_schema = [("node_name", "string")] + output_schema = {"output": "string"} + + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_param_command(console, self.name, "dump", node_name=kwargs.get("node_name")) @vulcanai_tool -class Ros2PublishTool(AtomicTool): - name = "ros_publish" +class Ros2ParamLoadTool(AtomicTool): + name = "ros2_param_load" description = ( - "Publish one or more messages to a given ROS 2 topic [topic_name]. " - "Or execute 'ros2 topic pub [topic_name]'. " - "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 it keeps publishing messages until Ctrl+C is pressed. " - "with type 'std_msgs/msg/String' in topic '/chatter' " - "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"}\'' + "Load parameters into a ROS 2 node from a file." + "Equivalent to `ros2 param load `." + "Used when the user want to load a parameter filer for a ROS 2 node" ) - tags = ["ros2", "publish", "message", "std_msgs"] + tags = [ + "ros2", + "ros 2", + "load", + "file", + "param load", + "ros2 param load", + "load parameters", + "import parameters", + "load params from file" + ] + input_schema = [("node_name", "string"), ("file_path", "string")] + output_schema = {"output": "string"} - 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" - ("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 + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_param_command( + console, + self.name, + "load", + node_name=kwargs.get("node_name"), + file_path=kwargs.get("file_path"), + ) + + +@vulcanai_tool +class Ros2PkgListTool(AtomicTool): + name = "ros2_pkg_list" + description = ( + "List all currently available ROS 2 pkgs. " + "Equivalent to `ros2 pkg list`. " + "Use when the user wants to list, show, display, or print ROS 2 packages." + ) + tags = [ + "ros2", + "ros 2", + "pkg", + "pkg list", + "ros2 pkg list", + "list ros2 pkgs", + "show ros2 pkgs", + "print ros2 pkgs", + "available pkgs", ] + input_schema = [] + output_schema = {"output": "string"} - output_schema = { - "published": "bool", - "count": "int", - "topic": "string", - "output": "string", - } + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_pkg_command(console, self.name, "list") - 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. +@vulcanai_tool +class Ros2PkgExecutablesTool(AtomicTool): + name = "ros2_pkg_executables" + description = ( + "List executable entry points provided by ROS 2 packages. " + "Equivalent to `ros2 pkg executables`." + "Used when the user show, display or print a list of ROS 2 package specific executables" + ) + tags = [ + "ros2", + "ros 2", + "exec", + "executables", + "pkg executables", + "ros2 pkg executables", + "list package executables", + "show executables", + "package binaries" + ] + input_schema = [] + output_schema = {"output": "string"} + + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_pkg_command(console, self.name, "executables") - 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) +@vulcanai_tool +class Ros2InterfaceListTool(AtomicTool): + name = "ros2_interface_list" + description = ( + "List all currently available ROS 2 interfaces. " + "Equivalent to `ros2 interface list`. " + "Use when the user wants to list, show, display, or print ROS 2 interfaces." + ) + tags = [ + "ros2", + "ros 2", + "interfaces", + "interface list", + "ros2 serviinterfacece list", + "list ros2 interfaces", + "show ros2 interfaces", + "print ros2 interfaces", + "available interfaces", + ] + input_schema = [] + output_schema = {"output": "string"} def run(self, **kwargs): - # Ros2 node to create the Publisher and print the log information - node = self.bb.get("main_node", None) - if node is None: - raise Exception("Could not find shared node, aborting...") - # Optional console handle to route logs to the subprocess panel. - console = self.bb.get("console", None) - - result = { - "published": "False", - "count": "0", - "topic": "", - "output": "", - } - - panel_enabled = console is not None and hasattr(console, "show_subprocess_panel") - if panel_enabled: - console.call_from_thread(console.show_subprocess_panel) - if hasattr(console, "change_route_logs"): - console.call_from_thread(console.change_route_logs, True) - - topic_name = kwargs.get("topic", "/chatter") - # Support both 'message_data' (new) and 'message' (deprecated) - message_data = kwargs.get("message_data", kwargs.get("message", "Hello from VulcanAI PublishTool!")) - msg_type_str = kwargs.get("msg_type", "std_msgs/msg/String") - - max_duration = kwargs.get("max_duration", None) - if not isinstance(max_duration, (int, float)) or max_duration <= 0: - max_duration = None - - max_lines = kwargs.get("max_lines", None) - if max_lines is not None and not isinstance(max_lines, int): - max_lines = None - - period_sec = kwargs.get("period_sec", 0.1) - - qos_depth = 10 - - if console is None: - print("[ERROR] Console not is None") - - return result - - published_msgs = [] - output_lines = [] - publisher = None - cancel_token = None - - try: - if not topic_name: - console.call_from_thread(console.logger.log_msg, "[ROS] [ERROR] No topic provided.") - return result - - result["topic"] = topic_name - - if max_lines is not None and max_lines <= 0: - # No messages to publish - console.call_from_thread( - console.logger.log_msg, "[ROS] [WARN] max_lines <= 0, nothing to publish." - ) - return result - - MsgType = import_msg_type(msg_type_str, node) - publisher = node.create_publisher(MsgType, topic_name, qos_depth) - cancel_token = Future() - console.set_stream_task(cancel_token) - log_tool_in_stream_and_main( - console, - "[tool]Publisher created![tool]", - tool_name=self.name, - ) - - start_time = time.monotonic() - published_count = 0 - - while True: - if cancel_token.cancelled(): - console.logger.log_tool("[tool]Ctrl+C received:[/tool] stopping publish...", tool_name=self.name) - break - if max_lines is not None and published_count >= max_lines: - console.logger.log_tool(f"[tool]Stopping:[/tool] Reached max_lines = {max_lines}", tool_name=self.name) - break - if max_duration is not None and (time.monotonic() - start_time) >= max_duration: - console.logger.log_tool( - f"[tool]Stopping:[/tool] Exceeded max_duration = {max_duration}s", - tool_name=self.name, - ) - break - - msg = MsgType() - - # Try to populate message based on message type - if hasattr(msg, "data"): - # Standard message type with a 'data' field (e.g., std_msgs/msg/String) - msg.data = message_data - else: - # Custom message type - parse message_data as JSON - try: - payload = json.loads(message_data) - self.msg_from_dict(msg, payload) - except json.JSONDecodeError as e: - console.call_from_thread( - console.logger.log_msg, - "[ROS] [ERROR] Failed to parse message_data as JSON for custom type" - + f"'{msg_type_str}': {e}", - ) - return result - - if hasattr(msg, "data"): - publish_line = f"[ROS] [INFO] Publishing: '{msg.data}'" - console.call_from_thread(console.logger.log_msg, f"{publish_line}") - else: - publish_line = f"[ROS] [INFO] Publishing custom message to '{topic_name}'" - console.call_from_thread( - console.logger.log_msg, - f"{publish_line}", - ) - output_lines.append(publish_line) - publisher.publish(msg) - published_msgs.append(msg.data if hasattr(msg, "data") else str(msg)) - published_count += 1 - - rclpy.spin_once(node, timeout_sec=0.05) - - if period_sec and period_sec > 0.0: - 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) - if publisher is not None: - try: - node.destroy_publisher(publisher) - except Exception: - pass - - result["output"] = "\n".join(output_lines) - - if published_msgs is not None: - result["published"] = "True" - result["count"] = len(published_msgs) - - print_tool_output(console, result["output"], self.name) - - # if panel_enabled: - # console.logger.log_msg(result["output"], color="gray") - # console.logger.log_msg("\n") - # else: - # print_tool_output(console, result["output"], self.name) - return result + console = _require_console(self.bb) + return _run_ros2_interface_command(console, self.name, "list") @vulcanai_tool -class Ros2SubscribeTool(AtomicTool): - name = "ros_subscribe" +class Ros2InterfacePackagesTool(AtomicTool): + name = "ros2_interface_packages" description = ( - "Subscribe to a topic [topic] or execute 'ros2 topic echo [topic]' " - "and stop after receiving N messages or max duration." + "List ROS 2 packages that provide interfaces." + "Equivalent to `ros2 interface packages`." + "Used when the user wants to list, show, display or print the packages of all ROS 2 interfaces" ) - tags = ["ros2", "subscribe", "topic", "std_msgs"] + tags = [ + "ros2", + "ros 2", + "list", + "pkgs", + "packages", + "interface packages", + "ros2 interface packages", + "list interface packages", + "packages with interfaces" + ] + input_schema = [] + output_schema = {"output": "string"} - input_schema = [ - ("topic", "string"), # topic name - ("max_lines", "int?"), # (optional) stop after this number of messages - ("max_duration", "int?"), # (optional) stop after this seconds + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_interface_command(console, self.name, "packages") + + +@vulcanai_tool +class Ros2InterfacePackageTool(AtomicTool): + name = "ros2_interface_package" + description = ( + "List the interfaces provided by a ROS 2 package. " + "Equivalent to `ros2 interface package `." + "Used when the user wants to list, show, display or print the ROS 2 interfaces of a package" + ) + tags = [ + "ros2", + "ros 2", + "interfaces" + "interface package", + "ros2 interface package", + "list package interfaces", + "show package interfaces", + "package interfaces" ] + input_schema = [("interface_name", "string")] + output_schema = {"output": "string"} + + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_interface_command(console, self.name, "package", interface_name=kwargs.get("interface_name")) - output_schema = { - "subscribed": "bool", - "count": "int", - "topic": "string", - "output": "string", - } + +@vulcanai_tool +class Ros2InterfaceShowTool(AtomicTool): + name = "ros2_interface_show" + description = ( + "Show the definition of a ROS 2 interface. " + "Equivalent to `ros2 interface show `." + "Used when the user wants show, display or print the ROS 2 interface information" + ) + tags = [ + "ros2", + "ros 2", + "info", + "information", + "interface show", + "ros2 interface show", + "show interface", + "interface definition", + "display interface" + ] + input_schema = [("interface_name", "string")] + output_schema = {"output": "string"} def run(self, **kwargs): - # Ros2 node to create the Publisher and print the log information - node = self.bb.get("main_node", None) - if node is None: - raise Exception("Could not find shared node, aborting...") - # Optional console handle to support Ctrl+C cancellation. - console = self.bb.get("console", None) - - result = { - "subscribed": "False", - "count": "0", - "topic": "", - "output": "", - } - - topic_name = kwargs.get("topic", None) - max_duration = kwargs.get("max_duration", None) - if max_duration is not None and not isinstance(max_duration, (int, float)): - max_duration = None - - max_lines = kwargs.get("max_lines", None) - if max_lines is not None and not isinstance(max_lines, int): - max_lines = None - - # Start line (stream panel/main panel with tool color) - console.logger.log_tool("[tool]Subscriber created![/tool]", tool_name=self.name) - - # "--field data" prints only the data field from each message - # instead of the full YAML message - # "--no-arr" do not print array fields of messages - base_args = ["ros2", "topic", "echo", topic_name, "--field", "data", "--no-arr"] - ret = execute_subprocess(console, self.name, base_args, max_duration, max_lines, log_created=False) - - ret_lines = ret.splitlines() if isinstance(ret, str) and ret else [] - - result["output"] = "\n".join(ret_lines) - - if ret is not None: - result["subscribed"] = "True" - result["count"] = len(ret_lines) - result["topic"] = topic_name - - print_tool_output(console, result["output"], self.name) - - return result + console = _require_console(self.bb) + return _run_ros2_interface_command(console, self.name, "show", interface_name=kwargs.get("interface_name")) + + +# --------------------------------------------------------------------------- +# Backward-compatible legacy wrappers (hidden from planner) +# --------------------------------------------------------------------------- + + +# @vulcanai_tool +# class Ros2NodeTool(AtomicTool): +# name = "ros2_node" +# planner_visible = False +# description = ( +# "[DEPRECATED] Backward-compatible wrapper for ROS2 node commands. " +# "Prefer strict tools: ros2_node_list and ros2_node_info." +# ) +# tags = ["ros2", "nodes", "cli", "info", "diagnostics"] +# input_schema = [("command", "string"), ("node_name", "string?")] +# output_schema = {"output": "string"} + +# def run(self, **kwargs): +# console = _require_console(self.bb) +# return _run_ros2_node_command( +# console, +# self.name, +# kwargs.get("command"), +# node_name=kwargs.get("node_name", ""), +# ) + + +# @vulcanai_tool +# class Ros2TopicTool(AtomicTool): +# name = "ros2_topic" +# planner_visible = False +# description = ( +# "[DEPRECATED] Backward-compatible wrapper for ROS2 topic commands. " +# "Prefer strict tools: ros2_topic_*." +# ) +# tags = ["ros2", "topics", "cli", "info"] +# input_schema = [ +# ("command", "string"), +# ("topic_name", "string?"), +# ("msg_type", "string?"), +# ("max_duration", "number?"), +# ("max_lines", "int?"), +# ] +# output_schema = {"output": "string"} + +# def run(self, **kwargs): +# console = _require_console(self.bb) +# return _run_ros2_topic_command( +# console, +# self.name, +# kwargs.get("command"), +# topic_name=kwargs.get("topic_name"), +# msg_type=kwargs.get("msg_type"), +# max_duration=kwargs.get("max_duration"), +# max_lines=kwargs.get("max_lines"), +# ) + + +# @vulcanai_tool +# class Ros2ServiceTool(AtomicTool): +# name = "ros2_service" +# planner_visible = False +# description = ( +# "[DEPRECATED] Backward-compatible wrapper for ROS2 service commands. " +# "Prefer strict tools: ros2_service_*." +# ) +# tags = ["ros2", "services", "cli", "info", "call"] +# input_schema = [ +# ("command", "string"), +# ("service_name", "string?"), +# ("service_type", "string?"), +# ("call", "bool?"), +# ("args", "string?"), +# ("max_duration", "number?"), +# ("max_lines", "int?"), +# ] +# output_schema = {"output": "string"} + +# def run(self, **kwargs): +# console = _require_console(self.bb) +# return _run_ros2_service_command( +# console, +# self.name, +# kwargs.get("command"), +# service_name=kwargs.get("service_name"), +# service_type=kwargs.get("service_type"), +# call_args=kwargs.get("args"), +# max_duration=kwargs.get("max_duration"), +# max_lines=kwargs.get("max_lines"), +# ) + + +# @vulcanai_tool +# class Ros2ActionTool(AtomicTool): +# name = "ros2_action" +# planner_visible = False +# description = ( +# "[DEPRECATED] Backward-compatible wrapper for ROS2 action commands. " +# "Prefer strict tools: ros2_action_*." +# ) +# tags = ["ros2", "actions", "cli", "info", "goal"] +# input_schema = [ +# ("command", "string"), +# ("action_name", "string?"), +# ("action_type", "string?"), +# ("send_goal", "bool?"), +# ("goal_args", "string?"), +# ] +# output_schema = {"output": "string"} + +# def run(self, **kwargs): +# console = _require_console(self.bb) +# return _run_ros2_action_command( +# console, +# self.name, +# kwargs.get("command"), +# action_name=kwargs.get("action_name"), +# action_type=kwargs.get("action_type"), +# goal_args=kwargs.get("goal_args"), +# ) + + +# @vulcanai_tool +# class Ros2ParamTool(AtomicTool): +# name = "ros2_param" +# planner_visible = False +# description = ( +# "[DEPRECATED] Backward-compatible wrapper for ROS2 param commands. " +# "Prefer strict tools: ros2_param_*." +# ) +# tags = ["ros2", "param", "parameters", "cli"] +# input_schema = [ +# ("command", "string"), +# ("param_name", "string?"), +# ("node_name", "string?"), +# ("set_value", "string?"), +# ("file_path", "string?"), +# ] +# output_schema = {"output": "string"} + +# def run(self, **kwargs): +# console = _require_console(self.bb) +# return _run_ros2_param_command( +# console, +# self.name, +# kwargs.get("command"), +# node_name=kwargs.get("node_name"), +# param_name=kwargs.get("param_name"), +# set_value=kwargs.get("set_value"), +# file_path=kwargs.get("file_path"), +# ) + + +# @vulcanai_tool +# class Ros2PkgTool(AtomicTool): +# name = "ros2_pkg" +# planner_visible = False +# description = ( +# "[DEPRECATED] Backward-compatible wrapper for ROS2 pkg commands. " +# "Prefer strict tools: ros2_pkg_list and ros2_pkg_executables." +# ) +# tags = ["ros2", "pkg", "packages", "cli", "introspection"] +# input_schema = [("command", "string")] +# output_schema = {"output": "string"} + +# def run(self, **kwargs): +# console = _require_console(self.bb) +# return _run_ros2_pkg_command(console, self.name, kwargs.get("command")) + + +# @vulcanai_tool +# class Ros2InterfaceTool(AtomicTool): +# name = "ros2_interface" +# planner_visible = False +# description = ( +# "[DEPRECATED] Backward-compatible wrapper for ROS2 interface commands. " +# "Prefer strict tools: ros2_interface_*." +# ) +# tags = ["ros2", "interface", "msg", "srv", "cli", "introspection"] +# input_schema = [("command", "string"), ("interface_name", "string?")] +# output_schema = {"output": "string"} + +# def run(self, **kwargs): +# console = _require_console(self.bb) +# return _run_ros2_interface_command( +# console, +# self.name, +# kwargs.get("command"), +# interface_name=kwargs.get("interface_name"), +# ) + + +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" +# description = ( +# "Publish one or more messages to a given ROS 2 topic [topic_name]. " +# "Or execute 'ros2 topic pub [topic_name]'. " +# "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 it keeps publishing messages until Ctrl+C is pressed. " +# "with type 'std_msgs/msg/String' in topic '/chatter' " +# "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"] + +# 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" +# ("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": "string", +# } + +# def msg_from_dict(self, msg, values: dict): +# """ +# Populate a ROS 2 message instance from a Python dictionary. + +# This function recursively assigns values from a dictionary to the +# corresponding fields of a ROS 2 message instance. + +# Supports: +# - Primitive fields (int, float, bool, string) +# - Nested ROS 2 messages + +# """ +# for field, value in values.items(): +# attr = getattr(msg, field) +# if hasattr(attr, "__slots__"): +# self.msg_from_dict(attr, value) +# else: +# setattr(msg, field, value) + +# def run(self, **kwargs): +# # Ros2 node to create the Publisher and print the log information +# node = self.bb.get("main_node", None) +# if node is None: +# raise Exception("Could not find shared node, aborting...") +# # Optional console handle to route logs to the subprocess panel. +# console = self.bb.get("console", None) + +# result = { +# "published": "False", +# "count": "0", +# "topic": "", +# "output": "", +# } + +# panel_enabled = console is not None and hasattr(console, "show_subprocess_panel") +# if panel_enabled: +# console.call_from_thread(console.show_subprocess_panel) +# if hasattr(console, "change_route_logs"): +# console.call_from_thread(console.change_route_logs, True) + +# topic_name = kwargs.get("topic", "/chatter") +# # Support both 'message_data' (new) and 'message' (deprecated) +# message_data = kwargs.get("message_data", kwargs.get("message", "Hello from VulcanAI PublishTool!")) +# msg_type_str = kwargs.get("msg_type", "std_msgs/msg/String") + +# max_duration = kwargs.get("max_duration", None) +# if not isinstance(max_duration, (int, float)) or max_duration <= 0: +# max_duration = None + +# max_lines = kwargs.get("max_lines", None) +# if max_lines is not None and not isinstance(max_lines, int): +# max_lines = None + +# period_sec = kwargs.get("period_sec", 0.1) + +# qos_depth = 10 + +# if console is None: +# print("[ERROR] Console not is None") + +# return result + +# published_msgs = [] +# output_lines = [] +# publisher = None +# cancel_token = None + +# try: +# if not topic_name: +# console.call_from_thread(console.logger.log_msg, "[ROS] [ERROR] No topic provided.") +# return result + +# result["topic"] = topic_name + +# if max_lines is not None and max_lines <= 0: +# # No messages to publish +# console.call_from_thread( +# console.logger.log_msg, "[ROS] [WARN] max_lines <= 0, nothing to publish." +# ) +# return result + +# MsgType = import_msg_type(msg_type_str, node) +# publisher = node.create_publisher(MsgType, topic_name, qos_depth) +# cancel_token = Future() +# console.set_stream_task(cancel_token) +# log_tool_in_stream_and_main( +# console, +# "[tool]Publisher created![tool]", +# tool_name=self.name, +# ) + +# start_time = time.monotonic() +# published_count = 0 + +# while True: +# if cancel_token.cancelled(): +# console.logger.log_tool("[tool]Ctrl+C received:[/tool] stopping publish...", tool_name=self.name) +# break +# if max_lines is not None and published_count >= max_lines: +# console.logger.log_tool(f"[tool]Stopping:[/tool] Reached max_lines = {max_lines}", tool_name=self.name) +# break +# if max_duration is not None and (time.monotonic() - start_time) >= max_duration: +# console.logger.log_tool( +# f"[tool]Stopping:[/tool] Exceeded max_duration = {max_duration}s", +# tool_name=self.name, +# ) +# break + +# msg = MsgType() + +# # Try to populate message based on message type +# if hasattr(msg, "data"): +# # Standard message type with a 'data' field (e.g., std_msgs/msg/String) +# msg.data = message_data +# else: +# # Custom message type - parse message_data as JSON +# try: +# payload = json.loads(message_data) +# self.msg_from_dict(msg, payload) +# except json.JSONDecodeError as e: +# console.call_from_thread( +# console.logger.log_msg, +# "[ROS] [ERROR] Failed to parse message_data as JSON for custom type" +# + f"'{msg_type_str}': {e}", +# ) +# return result + +# if hasattr(msg, "data"): +# publish_line = f"[ROS] [INFO] Publishing: '{msg.data}'" +# console.call_from_thread(console.logger.log_msg, f"{publish_line}") +# else: +# publish_line = f"[ROS] [INFO] Publishing custom message to '{topic_name}'" +# console.call_from_thread( +# console.logger.log_msg, +# f"{publish_line}", +# ) +# output_lines.append(publish_line) +# publisher.publish(msg) +# published_msgs.append(msg.data if hasattr(msg, "data") else str(msg)) +# published_count += 1 + +# rclpy.spin_once(node, timeout_sec=0.05) + +# if period_sec and period_sec > 0.0: +# 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) +# if publisher is not None: +# try: +# node.destroy_publisher(publisher) +# except Exception: +# pass + +# result["output"] = "\n".join(output_lines) + +# if published_msgs is not None: +# result["published"] = "True" +# result["count"] = len(published_msgs) + +# print_tool_output(console, result["output"], self.name) + +# # if panel_enabled: +# # console.logger.log_msg(result["output"], color="gray") +# # console.logger.log_msg("\n") +# # else: +# # print_tool_output(console, result["output"], self.name) +# return result + + +# @vulcanai_tool +# class Ros2SubscribeTool(AtomicTool): +# name = "ros_subscribe" +# description = ( +# "Subscribe to a topic [topic] or execute 'ros2 topic echo [topic]' " +# "and stop after receiving N messages or max duration." +# ) +# tags = ["ros2", "subscribe", "topic", "std_msgs"] + +# input_schema = [ +# ("topic", "string"), # topic name +# ("max_lines", "int?"), # (optional) stop after this number of messages +# ("max_duration", "int?"), # (optional) stop after this seconds +# ] + +# output_schema = { +# "subscribed": "bool", +# "count": "int", +# "topic": "string", +# "output": "string", +# } + +# def run(self, **kwargs): +# # Ros2 node to create the Publisher and print the log information +# node = self.bb.get("main_node", None) +# if node is None: +# raise Exception("Could not find shared node, aborting...") +# # Optional console handle to support Ctrl+C cancellation. +# console = self.bb.get("console", None) + +# result = { +# "subscribed": "False", +# "count": "0", +# "topic": "", +# "output": "", +# } + +# topic_name = kwargs.get("topic", None) +# max_duration = kwargs.get("max_duration", None) +# if max_duration is not None and not isinstance(max_duration, (int, float)): +# max_duration = None + +# max_lines = kwargs.get("max_lines", None) +# if max_lines is not None and not isinstance(max_lines, int): +# max_lines = None + +# # Start line (stream panel/main panel with tool color) +# console.logger.log_tool("[tool]Subscriber created![/tool]", tool_name=self.name) + +# # "--field data" prints only the data field from each message +# # instead of the full YAML message +# # "--no-arr" do not print array fields of messages +# base_args = ["ros2", "topic", "echo", topic_name, "--field", "data", "--no-arr"] +# ret = execute_subprocess(console, self.name, base_args, max_duration, max_lines, log_created=False) + +# ret_lines = ret.splitlines() if isinstance(ret, str) and ret else [] + +# result["output"] = "\n".join(ret_lines) + +# if ret is not None: +# result["subscribed"] = "True" +# result["count"] = len(ret_lines) +# result["topic"] = topic_name + +# print_tool_output(console, result["output"], self.name) + +# return result From 4ff61156f5615a40ec567432e0b7845ddac821b0 Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 23 Apr 2026 08:31:16 +0200 Subject: [PATCH 32/55] [#23897] Updated default_tools by groups, modal_screen, '/tools ' display, focused at the init of the app Signed-off-by: danipiza --- src/vulcanai/console/console.py | 76 ++- src/vulcanai/console/logger.py | 10 +- src/vulcanai/console/modal_screens.py | 87 ++- src/vulcanai/core/executor.py | 5 + src/vulcanai/core/manager.py | 53 +- src/vulcanai/core/manager_iterator.py | 6 + src/vulcanai/core/manager_plan.py | 4 + src/vulcanai/core/plan_types.py | 6 +- src/vulcanai/core/validator.py | 38 +- src/vulcanai/tools/default_tools.py | 757 ++++++++++++++++---------- src/vulcanai/tools/tool_registry.py | 96 +++- src/vulcanai/tools/tools.py | 3 + 12 files changed, 789 insertions(+), 352 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index a3f53b7..c03423b 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -388,6 +388,14 @@ async def bootstrap(self) -> None: self.logger.log_console("Clipboard: select text and press F4 to copy. Use Ctrl+V or middle-click to paste.") self.logger.log_console("Use '/exit' or press 'Ctrl+Q' to quit.") + # TODO. danip + # Anchor the main log at the bottom after all startup output has been queued, + # so the user sees the latest line (and input prompt) without manual scrolling. + if self.main_pannel is not None: + self.main_pannel.scroll_end(animate=False, immediate=True, x_axis=False) + self.call_after_refresh(self.main_pannel.scroll_end, animate=False, immediate=True, x_axis=False) + self.call_later(self.main_pannel.scroll_end, animate=False, immediate=True, x_axis=False) + # Activate the terminal input self.set_input_enabled(True) @@ -539,31 +547,32 @@ def _update_variables_panel(self) -> None: kvalue_widget.update(text) @work # Runs in a worker. waiting won't freeze the UI - async def open_checklist(self, tools_list: list[str], active_tools_num: int) -> None: + async def open_checklist(self, grouped: list, active_set: set) -> None: """ Function used to open a Checklist ModalScreen in the console. Used in the /edit_tools command. """ # Create the checklist dialog - selected = await self.push_screen_wait(CheckListModal(tools_list, active_tools_num)) + selected = await self.push_screen_wait(CheckListModal(grouped, active_set)) if selected is None: self.logger.log_msg("Selection cancelled.") else: - # Iterate over all tools and activate/deactivate accordingly - # to the selection made by the user - for tool_tmp in tools_list: - # Remove "- " prefix - tool = tool_tmp[2:] - - if tool_tmp in selected: - # Current tool checbox, activated - if self.manager.registry.activate_tool(tool): - self.logger.log_console(f"Activated tool '{tool}'") + selected_set = set(selected) + # Collect all non-help tool names + all_tool_names = ( + set(self.manager.registry.tools.keys()) + | set(self.manager.registry.deactivated_tools.keys()) + ) + all_tool_names.discard("help") + + for tool_name in all_tool_names: + if tool_name in selected_set: + if self.manager.registry.activate_tool(tool_name): + self.logger.log_console(f"Activated tool '{tool_name}'") else: - # Current tool checbox, deactivated - if self.manager.registry.deactivate_tool(tool): - self.logger.log_console(f"Deactivated tool '{tool}'") + if self.manager.registry.deactivate_tool(tool_name): + self.logger.log_console(f"Deactivated tool '{tool_name}'") @work async def open_radiolist( @@ -639,21 +648,36 @@ def cmd_tools(self, _) -> None: tool_msg += "Available tools:\n" tool_msg += tmp_msg + "\n" + ("‾" * len(tmp_msg)) + "\n" - for tool in self.manager.registry.tools.values(): - tool_msg += f"- {tool.name}: {tool.description}\n" - self.logger.log_console(tool_msg, "console") + tool_names = [name for name in self.manager.registry.tools.keys() if name != "help"] + grouped = self.manager.registry.group_tool_names(tool_names) - def cmd_edit_tools(self, _) -> None: - tools_list = [] - for tool in self.manager.registry.tools.values(): - tools_list.append(f"- {tool.name}") + for entry_name, subtools in grouped: + if subtools is None: + tool = self.manager.registry.tools[entry_name] + tool_msg += f"- {tool.name}: {tool.tool_description}\n" + else: + tool_msg += f"{entry_name}:\n" + for subtool in subtools: + full_name = f"{entry_name}_{subtool}" + tool = self.manager.registry.tools[full_name] + tool_msg += f" - {subtool}: {tool.tool_description}\n" - active_tools_num = len(tools_list) + if "help" in self.manager.registry.tools: + help_tool = self.manager.registry.tools["help"] + tool_msg += f"- {help_tool.name}: {help_tool.tool_description}\n" - for deactivated_tool in self.manager.registry.deactivated_tools.values(): - tools_list.append(f"- {deactivated_tool.name}") + self.logger.log_console(tool_msg, "console") - self.open_checklist(tools_list, active_tools_num) + def cmd_edit_tools(self, _) -> None: + all_names = sorted( + n + for n in list(self.manager.registry.tools.keys()) + + list(self.manager.registry.deactivated_tools.keys()) + if n != "help" + ) + active_set = set(self.manager.registry.tools.keys()) + grouped = self.manager.registry.group_tool_names(all_names) + self.open_checklist(grouped, active_set) def cmd_change_k(self, args) -> None: if len(args) == 0: diff --git a/src/vulcanai/console/logger.py b/src/vulcanai/console/logger.py index d3ce661..5e08dd4 100644 --- a/src/vulcanai/console/logger.py +++ b/src/vulcanai/console/logger.py @@ -176,11 +176,15 @@ def log_tool(self, msg: str, tool_name: str = "", error: bool = False, color: st return processed_msg - def log_registry(self, msg: str, error: bool = False, color: str = ""): + def log_registry(self, msg: str, error: bool = False, color: str = "", indent: int = 0): + indent_str = " " * indent if error: - prefix = "[error][REGISTRY] [ERROR][/error] " + prefix = f"[error][REGISTRY] [ERROR][/error]{indent_str} " + elif indent > 0: + prefix = f"{indent_str} " + msg = f"{msg}" else: - prefix = "[registry][REGISTRY][/registry] " + prefix = f"[registry][REGISTRY][/registry]{indent_str} " processed_msg = self.process_msg(msg, prefix=prefix, color=color) self.sink.write(processed_msg) diff --git a/src/vulcanai/console/modal_screens.py b/src/vulcanai/console/modal_screens.py index 81bcb3b..f53cb41 100644 --- a/src/vulcanai/console/modal_screens.py +++ b/src/vulcanai/console/modal_screens.py @@ -170,6 +170,12 @@ class CheckListModal(ModalScreen[list[str] | None]): /* no max-height, no overflow-y here */ } + .group-child { + margin-left: 4; + margin-right: 2; + padding-right: 2; + } + .btns { height: auto; width: 100%; @@ -181,32 +187,95 @@ class CheckListModal(ModalScreen[list[str] | None]): .btns Button { padding: 0 3; + margin: 0 2; } """ - def __init__(self, lines: list[str], active_tools_num: int = 0) -> None: + def __init__(self, grouped: list, active_tools: set) -> None: super().__init__() - self.lines = list(lines) - self.active_tools_num = active_tools_num + self.grouped = grouped # [(prefix, [subtools]) | (name, None), ...] + self.active_tools = active_tools + self._parent_to_children: dict[str, list[str]] = {} + self._child_to_parent: dict[str, str] = {} + self._id_to_tool: dict[str, str] = {} # cb_id -> full tool name (children & standalone only) def compose(self) -> ComposeResult: with Vertical(classes="dialog"): yield Label("Pick tools you want to enable", classes="title") - # SCROLLABLE CHECKBOX LIST with VerticalScroll(classes="checkbox-list"): - for i, line in enumerate(self.lines, start=1): - yield Checkbox(line, value=i <= self.active_tools_num, id=f"cb{i}") + idx = 0 + for group_name, subtools in self.grouped: + if subtools is None: + # Standalone tool + cb_id = f"cb{idx}" + self._id_to_tool[cb_id] = group_name + yield Checkbox( + group_name, + value=group_name in self.active_tools, + id=cb_id, + ) + idx += 1 + else: + # Group parent + parent_id = f"cb{idx}" + idx += 1 + child_ids = [] + for subtool in subtools: + child_id = f"cb{idx}" + full_name = f"{group_name}_{subtool}" + self._id_to_tool[child_id] = full_name + child_ids.append(child_id) + idx += 1 + + self._parent_to_children[parent_id] = child_ids + for cid in child_ids: + self._child_to_parent[cid] = parent_id + + all_active = all( + f"{group_name}_{s}" in self.active_tools for s in subtools + ) + yield Checkbox(group_name, value=all_active, id=parent_id) + + for subtool, child_id in zip(subtools, child_ids): + full_name = f"{group_name}_{subtool}" + yield Checkbox( + subtool, + value=full_name in self.active_tools, + id=child_id, + classes="group-child", + ) - # Buttons with Horizontal(classes="btns"): yield Button("Cancel", variant="default", id="cancel") yield Button("Submit", variant="primary", id="submit") + def on_checkbox_changed(self, event: Checkbox.Changed) -> None: + cb_id = event.checkbox.id + + if cb_id in self._parent_to_children: + # Parent toggled -> set all children to same value + with self.prevent(Checkbox.Changed): + for child_id in self._parent_to_children[cb_id]: + self.query_one(f"#{child_id}", Checkbox).value = event.value + + elif cb_id in self._child_to_parent: + # Child toggled -> update parent (checked only when ALL children checked) + parent_id = self._child_to_parent[cb_id] + all_checked = all( + self.query_one(f"#{cid}", Checkbox).value + for cid in self._parent_to_children[parent_id] + ) + with self.prevent(Checkbox.Changed): + self.query_one(f"#{parent_id}", Checkbox).value = all_checked + def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "submit": - boxes = list(self.query(Checkbox)) - selected = [self.lines[i] for i, cb in enumerate(boxes) if cb.value] + selected = [ + tool_name + for cb_id, tool_name in self._id_to_tool.items() + if self.query_one(f"#{cb_id}", Checkbox).value + ] self.dismiss(selected) elif event.button.id == "cancel": self.dismiss(None) diff --git a/src/vulcanai/core/executor.py b/src/vulcanai/core/executor.py index 61d13f7..e9000ab 100644 --- a/src/vulcanai/core/executor.py +++ b/src/vulcanai/core/executor.py @@ -25,8 +25,11 @@ TYPE_CAST = { "float": float, "int": int, + "integer": int, "bool": lambda v: v if isinstance(v, bool) else str(v).strip().lower() in ("1", "true", "yes", "on"), + "boolean": lambda v: v if isinstance(v, bool) else str(v).strip().lower() in ("1", "true", "yes", "on"), "str": str, + "string": str, } @@ -246,6 +249,8 @@ def _get_from_bb(self, path: str, bb: Blackboard) -> Any: def _coerce_to_schema(self, schema: List[Tuple[str, str]], key: str, arg: str) -> Any: spec = dict(schema) t = spec.get(key, "str") + if isinstance(t, str) and t.endswith("?"): + t = t[:-1] try: out = TYPE_CAST[t](arg) except Exception: diff --git a/src/vulcanai/core/manager.py b/src/vulcanai/core/manager.py index 43988e3..a87131b 100644 --- a/src/vulcanai/core/manager.py +++ b/src/vulcanai/core/manager.py @@ -12,12 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. +import shlex from typing import Any, Dict, Optional, Tuple from vulcanai.console.logger import VulcanAILogger from vulcanai.core.agent import Agent from vulcanai.core.executor import Blackboard, PlanExecutor -from vulcanai.core.plan_types import GlobalPlan +from vulcanai.core.plan_types import ArgValue, GlobalPlan, PlanNode, Step from vulcanai.core.validator import PlanValidator from vulcanai.tools.tool_registry import ToolRegistry @@ -94,6 +95,7 @@ def handle_user_request(self, user_text: str, context: Dict[str, Any]) -> Dict[s # Get plan from LLM plan = self.get_plan_from_user_request(user_text, context) if not plan: + self.logger.log_manager("No executable plan generated by the planner.", error=True) return {"error": "No plan generated by LLM."} # Validate plan if validator is available if self.validator: @@ -177,6 +179,8 @@ def _build_prompt(self, user_text: str, ctx: Dict[str, Any]) -> Tuple[str, str]: f" Outputs: {tool.output_schema}\n" ) tools_text = "\n".join(tool_descriptions) + # TODO. danip, better performance? + #tools_text = self.render_tool_descriptions(tools) user_context = self._parse_user_context() user_prompt = "User request:\n" + user_text @@ -215,11 +219,58 @@ def update_k_index(self, new_k: int): self.k = max(1, int(new_k)) self.logger.log_console(f"Updated k index to {new_k}") + @staticmethod + def _schema_type_parts(type_name: str) -> Tuple[str, bool]: + if isinstance(type_name, str) and type_name.endswith("?"): + return type_name[:-1], True + return type_name, False + + @staticmethod + def _format_schema_value(value: Any) -> str: + if isinstance(value, str): + return value + return repr(value) + + def _format_tool_inputs(self, tool) -> str: + if not tool.input_schema: + return "none" + + input_defaults = getattr(tool, "input_defaults", {}) or {} + formatted_inputs = [] + for key, type_name in tool.input_schema: + base_type, optional = self._schema_type_parts(type_name) + role = "optional" if optional else "required" + suffix = "" + if key in input_defaults: + suffix = f", default={self._format_schema_value(input_defaults[key])}" + formatted_inputs.append(f"{key} ({base_type}, {role}{suffix})") + return "; ".join(formatted_inputs) + + @staticmethod + def _format_tool_outputs(tool) -> str: + if not tool.output_schema: + return "none" + return ", ".join(f"{key} ({type_name})" for key, type_name in tool.output_schema.items()) + + def render_tool_descriptions(self, tools) -> str: + tool_descriptions = [] + for tool in tools: + tool_descriptions.append( + f"- *{tool.name}*: {tool.description}\n" + f" Inputs: {self._format_tool_inputs(tool)}\n" + f" Outputs: {self._format_tool_outputs(tool)}\n" + ) + return "\n".join(tool_descriptions) + def _get_prompt_template(self) -> str: template = """ You are a planner assistant controlling a robotic system. Your job is to take a user request and generate a valid execution plan, containing only ONE step. Be sure to understand the text received and select the best action command from the available options. +Never return only a summary: always produce at least one PlanNode with at least one Step. +Inputs whose type ends with `?` are optional and may be omitted when the tool default behavior is appropriate. +If an input is marked optional and the user did not ask for a specific value, omit that argument instead of inventing one. +Do not guess placeholder values such as `1`, `1.0`, `10`, or empty strings for optional inputs. {user_context} ## Available tools: {tools_text} diff --git a/src/vulcanai/core/manager_iterator.py b/src/vulcanai/core/manager_iterator.py index e940b6d..f5d8871 100644 --- a/src/vulcanai/core/manager_iterator.py +++ b/src/vulcanai/core/manager_iterator.py @@ -219,6 +219,8 @@ def _build_prompt(self, user_text: str, ctx: Dict[str, Any]) -> Tuple[str, str]: f" Outputs: {tool.output_schema}\n" ) tools_text = "\n".join(tool_descriptions) + # TODO. danip, better performance? + tools_text = self.render_tool_descriptions(tools) bb_snapshot = self.bb.text_snapshot() @@ -252,6 +254,8 @@ def _build_goal_prompt(self, user_text: str, ctx: Dict[str, Any]) -> Tuple[str, f" Outputs: {tool.output_schema}\n" ) tools_text = "\n".join(tool_descriptions) + # TODO. danip, better performance? + #tools_text = self.render_tool_descriptions(tools) else: tools_text = "No available tools. Use blackboard" @@ -349,6 +353,8 @@ def _get_prompt_template(self) -> str: - If the last step failed, propose a different approach or tool/arguments and include a brief rationale of what will be done differently (as a comment-like line in the Summary). - Add only optional execution control parameters if strictly necessary or requested by the user. +- If an input is marked optional and the user did not ask for a specific value, omit that argument instead of + inventing one. - Use "{{{{bb.tool_name.key}}}}" to pass outputs from previous steps if relevant. For example, if tool 'detect_object' outputs {{"pose": [1.0, 2.0]}}, you can pass it to navigate as: diff --git a/src/vulcanai/core/manager_plan.py b/src/vulcanai/core/manager_plan.py index 32bfbdc..2bb7d5f 100644 --- a/src/vulcanai/core/manager_plan.py +++ b/src/vulcanai/core/manager_plan.py @@ -52,6 +52,10 @@ def _get_prompt_template(self) -> str: containing one or more steps grouped into one or more PlanNodes. Use PlanNodes to group steps that need to be executed together, either in sequence or in parallel, to achieve sub-goals. +Never return only a summary: always produce at least one PlanNode with at least one Step. +Inputs whose type ends with `?` are optional and may be omitted when the tool default behavior is appropriate. +If an input is marked optional and the user did not ask for a specific value, omit that argument instead of inventing one. +Do not guess placeholder values such as `1`, `1.0`, `10`, or empty strings for optional inputs. {user_context} ## Available tools: {tools_text} diff --git a/src/vulcanai/core/plan_types.py b/src/vulcanai/core/plan_types.py index 72d4fd7..75095ef 100644 --- a/src/vulcanai/core/plan_types.py +++ b/src/vulcanai/core/plan_types.py @@ -32,7 +32,7 @@ class StepBase(BaseModel): """Atomic execution unit bound to a ITool.""" # Associated tool - tool: str = None + tool: str = Field(..., description="Registered tool name") # Tool arguments args: List[ArgValue] = Field( default_factory=list, @@ -59,7 +59,7 @@ class PlanBase(BaseModel): # SEQUENCE or PARALLEL execution of children kind: Kind # Child nodes - steps: List[Step] = Field(default_factory=list) + steps: List[Step] = Field(..., min_length=1) # Execution control condition: Optional[str] = None success_criteria: Optional[str] = None @@ -79,7 +79,7 @@ class GlobalPlan(BaseModel): """GlobalPlan returned by the LLM with each step to be executed.""" # Top-level plan structure. Always executed sequentially. - plan: List[PlanNode] = Field(default_factory=list) + plan: List[PlanNode] = Field(..., min_length=1) # Brief summary of the plan summary: Optional[str] = None diff --git a/src/vulcanai/core/validator.py b/src/vulcanai/core/validator.py index 9cf5122..ead47d4 100644 --- a/src/vulcanai/core/validator.py +++ b/src/vulcanai/core/validator.py @@ -19,6 +19,14 @@ TYPE_ALIAS = {"int": int, "integer": int, "float": float, "bool": bool, "boolean": bool, "str": str, "string": str} +def _is_optional_schema_type(type_name: str) -> bool: + return isinstance(type_name, str) and type_name.endswith("?") + + +def _base_schema_type(type_name: str) -> str: + return type_name[:-1] if _is_optional_schema_type(type_name) else type_name + + class PlanValidator: """Validates and optionally augments a plan before execution.""" @@ -40,7 +48,7 @@ def validate(self, plan: GlobalPlan): for node in plan.plan: if isinstance(node, PlanNode): if not node.steps: - raise ValueError(f"PlanNode '{node.node_id}' has no steps defined.") + raise ValueError(f"PlanNode '{node.kind}' has no steps defined.") for step in node.steps: self._validate_step(step) @@ -54,11 +62,28 @@ def _validate_step(self, step: Step): # Validate args against tool input schema tool = self.registry.tools.get(step.tool) if tool.input_schema: - if len(step.args) != len(tool.input_schema): + required_keys = {key for key, type_name in tool.input_schema if not _is_optional_schema_type(type_name)} + provided_keys = {arg.key for arg in step.args} + schema_len = len(tool.input_schema) + required_len = len(required_keys) + + if len(step.args) < required_len or len(step.args) > schema_len: + if required_len == schema_len: + raise ValueError( + f"Tool '{tool.name}' expects {schema_len} arguments," + f" but {len(step.args)} were provided." + ) raise ValueError( - f"Tool '{tool.name}' expects {len(tool.input_schema)} arguments," + f"Tool '{tool.name}' expects between {required_len} and {schema_len} arguments," f" but {len(step.args)} were provided." ) + + missing_required = sorted(required_keys - provided_keys) + if missing_required: + raise ValueError( + f"Tool '{tool.name}' is missing required arguments: {', '.join(missing_required)}." + ) + for arg in step.args: if arg.key not in {k for d in tool.input_schema for k in d}: raise ValueError(f"Argument '{arg.key}' not defined in tool '{tool.name}' input schema.") @@ -78,11 +103,12 @@ def _validate_step(self, step: Step): # adhere to the schema for schema in tool.input_schema: if arg.key in schema: - is_string_type = schema[1] in ["str", "string"] + is_string_type = _base_schema_type(schema[1]) in ["str", "string"] if not is_string_type: raise ValueError( f"Argument '{arg.key}' of tool '{tool.name}' expects type" - f" '{TYPE_ALIAS.get(schema[1])}', but got '{type(arg.val).__name__}'." + f" '{TYPE_ALIAS.get(_base_schema_type(schema[1]))}'," + f" but got '{type(arg.val).__name__}'." ) # Check type if static value for non-string types else: @@ -91,7 +117,7 @@ def _validate_step(self, step: Step): if arg.key in schema: print(f"Schema for arg '{arg.key}' in tool '{tool.name}': {schema}") # Debug print # Use TYPE_ALIAS to map string type names to actual types - expected_type = TYPE_ALIAS.get(schema[1]) + expected_type = TYPE_ALIAS.get(_base_schema_type(schema[1])) print( f"Expected type for arg '{arg.key}' in tool '{tool.name}': {expected_type}" ) # Debug print diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index b7defe8..aaf09f2 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -507,6 +507,7 @@ def _run_ros2_interface_command(console, tool_name: str, command: str, interface @vulcanai_tool class Ros2NodeListTool(AtomicTool): name = "ros2_node_list" + tool_description = "List all currently available ROS 2 nodes. " description = ( "List all currently available ROS 2 nodes. " "Equivalent to `ros2 node list`. " @@ -535,9 +536,10 @@ def run(self, **kwargs): @vulcanai_tool class Ros2NodeInfoTool(AtomicTool): name = "ros2_node_info" + tool_description = "Show details for a specific ROS 2 node. " description = ( "Show details for a specific ROS 2 node. " - "Equivalent to `ros2 node info `." + "Equivalent to `ros2 node info [name]`." "Use when the user wants to list, show, display, or print the information of a ROS 2 node." ) tags = [ @@ -560,6 +562,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2TopicListTool(AtomicTool): name = "ros2_topic_list" + tool_description = "List all currently available ROS 2 topics. " description = ( "List all currently available ROS 2 topics. " "Equivalent to `ros2 topic list`. " @@ -588,6 +591,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2TopicInfoTool(AtomicTool): name = "ros2_topic_info" + tool_description = "Show details for a specific ROS 2 topic. " description = ( "Show details for a specific ROS 2 topic. " "Equivalent to `ros2 topic info`. " @@ -613,9 +617,10 @@ def run(self, **kwargs): @vulcanai_tool class Ros2TopicFindTool(AtomicTool): name = "ros2_topic_find" + tool_description = "Find ROS 2 topics by message type. " description = ( "Find ROS 2 topics by message type. " - "Equivalent to `ros2 topic find `." + "Equivalent to `ros2 topic find [msg_type]`." "Use when the user wants to find, show, display, or print the topic with a given ROS 2 topic type." ) tags = [ @@ -640,9 +645,10 @@ def run(self, **kwargs): @vulcanai_tool class Ros2TopicTypeTool(AtomicTool): name = "ros2_topic_type" + tool_description = "Show the message type used by a ROS 2 topic. " description = ( "Show the message type used by a ROS 2 topic. " - "Equivalent to `ros2 topic type `." + "Equivalent to `ros2 topic type [topic_name]`." "Use when the user wants to get, show, display, or print the type of a ROS 2 topic." ) tags = [ @@ -667,6 +673,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2TopicBwTool(AtomicTool): name = "ros2_topic_bw" + tool_description = "Stream and observe ROS 2 topic bandwidth. " description = ( "Stream and observe ROS 2 topic bandwidth. " "Equivalent to `ros2 topic bw`. " @@ -696,13 +703,20 @@ def run(self, **kwargs): console = _require_console(self.bb) # Streaming commands variables - max_duration = kwargs.get("max_duration") - if max_duration is None: - max_duration = 60 + # max_duration = kwargs.get("max_duration") + # if max_duration is None: + # max_duration = 60 - max_lines = kwargs.get("max_lines") - if max_lines is None: - max_lines = 100 + # max_lines = kwargs.get("max_lines") + # if max_lines is None: + # max_lines = 100 + max_duration = kwargs.get("max_duration", None) + if max_duration is None or not isinstance(max_duration, (int, float)): + max_duration = self.input_defaults["max_duration"] + + max_lines = kwargs.get("max_lines", None) + if max_lines is None or not isinstance(max_lines, (int, float)): + max_lines = self.input_defaults["max_lines"] return _run_ros2_topic_command( console, @@ -717,6 +731,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2TopicDelayTool(AtomicTool): name = "ros2_topic_delay" + tool_description = "Stream and observe ROS 2 topic delay. " description = ( "Stream and observe ROS 2 topic delay. " "Equivalent to `ros2 topic delay`. " @@ -731,24 +746,40 @@ class Ros2TopicDelayTool(AtomicTool): "inspect topic delay", "show topic delay" ] - input_schema = [("topic_name", "string"), ("max_duration", "float"), ("max_lines", "int")] + input_schema = [ + ("topic_name", "string"), + ("max_duration", "float?"), + ("max_lines", "int?") + ] + input_defaults = {"max_duration": 60, "max_lines": 100} output_schema = {"output": "string"} def run(self, **kwargs): console = _require_console(self.bb) + + # Streaming commands variables + max_duration = kwargs.get("max_duration") + if max_duration is None: + max_duration = 60 + + max_lines = kwargs.get("max_lines") + if max_lines is None: + max_lines = 100 + return _run_ros2_topic_command( console, self.name, "delay", topic_name=kwargs.get("topic_name"), - max_duration=kwargs.get("max_duration"), - max_lines=kwargs.get("max_lines"), + max_duration=max_duration, + max_lines=max_lines, ) @vulcanai_tool class Ros2TopicHzTool(AtomicTool): name = "ros2_topic_hz" + tool_description = "Stream and observe ROS 2 topic average receiving rate. " description = ( "Stream and observe ROS 2 topic average receiving rate. " "Equivalent to `ros2 topic hz`. " @@ -766,24 +797,40 @@ class Ros2TopicHzTool(AtomicTool): "inspect topic hz", "show topic hz" ] - input_schema = [("topic_name", "string"), ("max_duration", "float"), ("max_lines", "int")] + input_schema = [ + ("topic_name", "string"), + ("max_duration", "float?"), + ("max_lines", "int?") + ] + input_defaults = {"max_duration": 60, "max_lines": 100} output_schema = {"output": "string"} def run(self, **kwargs): console = _require_console(self.bb) + + # Streaming commands variables + max_duration = kwargs.get("max_duration") + if max_duration is None: + max_duration = 60 + + max_lines = kwargs.get("max_lines") + if max_lines is None: + max_lines = 100 + return _run_ros2_topic_command( console, self.name, "hz", topic_name=kwargs.get("topic_name"), - max_duration=kwargs.get("max_duration"), - max_lines=kwargs.get("max_lines"), + max_duration=max_duration, + max_lines=max_lines, ) @vulcanai_tool class Ros2ServiceListTool(AtomicTool): name = "ros2_service_list" + tool_description = "List all currently available ROS 2 services. " description = ( "List all currently available ROS 2 services. " "Equivalent to `ros2 service list`. " @@ -812,6 +859,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ServiceInfoTool(AtomicTool): name = "ros2_service_info" + tool_description = "Show details for a specific ROS 2 service. " description = ( "Show details for a specific ROS 2 service. " "Equivalent to `ros2 service info`. " @@ -838,9 +886,10 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ServiceTypeTool(AtomicTool): name = "ros2_service_type" + tool_description = "Show the message type used by a ROS 2 service. " description = ( "Show the message type used by a ROS 2 service. " - "Equivalent to `ros2 service type `." + "Equivalent to `ros2 service type [service_name]`." "Use when the user wants to get, show, display, or print the type of a ROS 2 service." ) tags = [ @@ -865,9 +914,10 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ServiceFindTool(AtomicTool): name = "ros2_service_find" + tool_description = "Find ROS 2 services by message type. " description = ( "Find ROS 2 services by message type. " - "Equivalent to `ros2 service find `." + "Equivalent to `ros2 service find [msg_type]`." "Use when the user wants to find, show, display, or print the service with a given ROS 2 sevice type." ) tags = [ @@ -891,9 +941,10 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ServiceCallTool(AtomicTool): name = "ros2_service_call" + tool_description = "Call a ROS 2 service. " description = ( "Call a ROS 2 service. " - "Equivalent to `ros2 service call `." + "Equivalent to `ros2 service call [args]`." "Use when the user wants to call a ROS 2 service ." ) tags = [ @@ -904,7 +955,11 @@ class Ros2ServiceCallTool(AtomicTool): "call service", "call ros2 service", ] - input_schema = [("service_name", "string"), ("service_type", "string"), ("args", "string")] + input_schema = [ + ("service_name", "string"), + ("service_type", "string"), + ("args", "string") + ] output_schema = {"output": "string"} def run(self, **kwargs): @@ -922,9 +977,10 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ServiceEchoTool(AtomicTool): name = "ros2_service_echo" + tool_description = "Stream and observe ROS 2 service traffic over time. " description = ( "Stream and observe ROS 2 service traffic over time. " - "Equivalent to `ros2 service echo `." + "Equivalent to `ros2 service echo [service_name]`." "Use when the user wants to show, display, or print the information a ROS 2 service is publishing." ) tags = [ @@ -937,24 +993,39 @@ class Ros2ServiceEchoTool(AtomicTool): "observe service", "echo service", ] - input_schema = [("service_name", "string"), ("max_duration", "float"), ("max_lines", "int")] + input_schema = [ + ("service_name", "string"), + ("max_duration", "float?"), + ("max_lines", "int?") + ] output_schema = {"output": "string"} def run(self, **kwargs): console = _require_console(self.bb) + + # Streaming commands variables + max_duration = kwargs.get("max_duration") + if max_duration is None: + max_duration = 60 + + max_lines = kwargs.get("max_lines") + if max_lines is None: + max_lines = 100 + return _run_ros2_service_command( console, self.name, "echo", service_name=kwargs.get("service_name"), - max_duration=kwargs.get("max_duration"), - max_lines=kwargs.get("max_lines"), + max_duration=max_duration, + max_lines=max_lines, ) @vulcanai_tool class Ros2ActionListTool(AtomicTool): name = "ros2_action_list" + tool_description = "List all currently available ROS 2 actions. " description = ( "List all currently available ROS 2 actions. " "Equivalent to `ros2 action list`. " @@ -982,6 +1053,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ActionInfoTool(AtomicTool): name = "ros2_action_info" + tool_description = "Show details for a specific ROS 2 action. " description = ( "Show details for a specific ROS 2 action. " "Equivalent to `ros2 action info`. " @@ -1006,9 +1078,10 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ActionTypeTool(AtomicTool): name = "ros2_action_type" + tool_description = "Show the message type used by a ROS 2 action. " description = ( "Show the message type used by a ROS 2 action. " - "Equivalent to `ros2 action type `." + "Equivalent to `ros2 action type [action_name]`." "Use when the user wants to get, show, display, or print the type of a ROS 2 action." ) tags = [ @@ -1032,9 +1105,10 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ActionSendGoalTool(AtomicTool): name = "ros2_action_send_goal" + tool_description = "Send a goal to a ROS 2 action server. " description = ( "Send a goal to a ROS 2 action server. " - "Equivalent to `ros2 action send_goal `." + "Equivalent to `ros2 action send_goal [action_name] [action_type]`." "Use when the user wants to send a ROS 2 action goal" ) tags = [ @@ -1048,7 +1122,11 @@ class Ros2ActionSendGoalTool(AtomicTool): "call action", "trigger action" ] - input_schema = [("action_name", "string"), ("action_type", "string"), ("goal_args", "string")] + input_schema = [ + ("action_name", "string"), + ("action_type", "string"), + ("goal_args", "string") + ] output_schema = {"output": "string"} def run(self, **kwargs): @@ -1066,6 +1144,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ParamListTool(AtomicTool): name = "ros2_param_list" + tool_description = "List all currently available ROS 2 params. " description = ( "List all currently available ROS 2 params. " "Equivalent to `ros2 param list`. " @@ -1093,9 +1172,10 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ParamGetTool(AtomicTool): name = "ros2_param_get" + tool_description = "Get the value of a parameter from a ROS 2 node. " description = ( "Get the value of a parameter from a ROS 2 node. " - "Equivalent to `ros2 param get `." + "Equivalent to `ros2 param get [node_name] [param_name]`." "Use when the user want to get a Ros 2 parameter" ) tags = [ @@ -1108,7 +1188,10 @@ class Ros2ParamGetTool(AtomicTool): "read parameter", "show parameter value" ] - input_schema = [("node_name", "string"), ("param_name", "string")] + input_schema = [ + ("node_name", "string"), + ("param_name", "string") + ] output_schema = {"output": "string"} def run(self, **kwargs): @@ -1125,9 +1208,10 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ParamDescribeTool(AtomicTool): name = "ros2_param_describe" + tool_description = "Show the definition and metadata of a ROS 2 parameter. " description = ( "Show the definition and metadata of a ROS 2 parameter. " - "Equivalent to `ros2 param describe `." + "Equivalent to `ros2 param describe [node_name] [param_name]`." "Use when the user want to show, display or print a desciptive information about a ROS 2 parameter" ) tags = [ @@ -1140,7 +1224,10 @@ class Ros2ParamDescribeTool(AtomicTool): "parameter metadata", "parameter details", ] - input_schema = [("node_name", "string"), ("param_name", "string")] + input_schema = [ + ("node_name", "string"), + ("param_name", "string") + ] output_schema = {"output": "string"} def run(self, **kwargs): @@ -1157,9 +1244,10 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ParamSetTool(AtomicTool): name = "ros2_param_set" + tool_description = "Set the value of a parameter on a ROS 2 node. " description = ( "Set the value of a parameter on a ROS 2 node. " - "Equivalent to `ros2 param set `." + "Equivalent to `ros2 param set [node_name] [param_name] [value]`." "Use when the user want to set a ROS 2 parameter" ) tags = [ @@ -1172,7 +1260,11 @@ class Ros2ParamSetTool(AtomicTool): "update parameter", "change parameter value" ] - input_schema = [("node_name", "string"), ("param_name", "string"), ("set_value", "string")] + input_schema = [ + ("node_name", "string"), + ("param_name", "string"), + ("set_value", "string") + ] output_schema = {"output": "string"} def run(self, **kwargs): @@ -1190,9 +1282,10 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ParamDeleteTool(AtomicTool): name = "ros2_param_delete" + tool_description = "Delete a parameter from a ROS 2 node. " description = ( "Delete a parameter from a ROS 2 node. " - "Equivalent to `ros2 param delete `." + "Equivalent to `ros2 param delete [node_name] [param_name]`." "Used when the user want to delete a ROS 2 parameter" ) tags = [ @@ -1206,7 +1299,10 @@ class Ros2ParamDeleteTool(AtomicTool): "remove parameter", "unset parameter" ] - input_schema = [("node_name", "string"), ("param_name", "string")] + input_schema = [ + ("node_name", "string"), + ("param_name", "string") + ] output_schema = {"output": "string"} def run(self, **kwargs): @@ -1223,9 +1319,10 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ParamDumpTool(AtomicTool): name = "ros2_param_dump" + tool_description = "Export all parameters from a ROS 2 node. " description = ( "Export all parameters from a ROS 2 node. " - "Equivalent to `ros2 param dump `." + "Equivalent to `ros2 param dump [node_name]`." "Used when the user want to show, display or print all the parameters of a ROS 2 node" ) tags = [ @@ -1250,9 +1347,10 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ParamLoadTool(AtomicTool): name = "ros2_param_load" + tool_description = "Load parameters into a ROS 2 node from a file." description = ( "Load parameters into a ROS 2 node from a file." - "Equivalent to `ros2 param load `." + "Equivalent to `ros2 param load [node_name] [file_path]`." "Used when the user want to load a parameter filer for a ROS 2 node" ) tags = [ @@ -1266,7 +1364,10 @@ class Ros2ParamLoadTool(AtomicTool): "import parameters", "load params from file" ] - input_schema = [("node_name", "string"), ("file_path", "string")] + input_schema = [ + ("node_name", "string"), + ("file_path", "string") + ] output_schema = {"output": "string"} def run(self, **kwargs): @@ -1283,6 +1384,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2PkgListTool(AtomicTool): name = "ros2_pkg_list" + tool_description = "List all currently available ROS 2 pkgs. " description = ( "List all currently available ROS 2 pkgs. " "Equivalent to `ros2 pkg list`. " @@ -1310,6 +1412,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2PkgExecutablesTool(AtomicTool): name = "ros2_pkg_executables" + tool_description = "List executable entry points provided by ROS 2 packages. " description = ( "List executable entry points provided by ROS 2 packages. " "Equivalent to `ros2 pkg executables`." @@ -1337,6 +1440,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2InterfaceListTool(AtomicTool): name = "ros2_interface_list" + tool_description = "List all currently available ROS 2 interfaces. " description = ( "List all currently available ROS 2 interfaces. " "Equivalent to `ros2 interface list`. " @@ -1364,6 +1468,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2InterfacePackagesTool(AtomicTool): name = "ros2_interface_packages" + tool_description = "List ROS 2 packages that provide interfaces." description = ( "List ROS 2 packages that provide interfaces." "Equivalent to `ros2 interface packages`." @@ -1391,9 +1496,10 @@ def run(self, **kwargs): @vulcanai_tool class Ros2InterfacePackageTool(AtomicTool): name = "ros2_interface_package" + tool_description = "List the interfaces provided by a ROS 2 package. " description = ( "List the interfaces provided by a ROS 2 package. " - "Equivalent to `ros2 interface package `." + "Equivalent to `ros2 interface package [package_name]`." "Used when the user wants to list, show, display or print the ROS 2 interfaces of a package" ) tags = [ @@ -1417,9 +1523,10 @@ def run(self, **kwargs): @vulcanai_tool class Ros2InterfaceShowTool(AtomicTool): name = "ros2_interface_show" + tool_description = "Show the definition of a ROS 2 interface. " description = ( "Show the definition of a ROS 2 interface. " - "Equivalent to `ros2 interface show `." + "Equivalent to `ros2 interface show [interface_name]`." "Used when the user wants show, display or print the ROS 2 interface information" ) tags = [ @@ -1657,275 +1764,327 @@ def import_msg_type(type_str: str, node): return getattr(module, msg_name) -# @vulcanai_tool -# class Ros2PublishTool(AtomicTool): -# name = "ros_publish" -# description = ( -# "Publish one or more messages to a given ROS 2 topic [topic_name]. " -# "Or execute 'ros2 topic pub [topic_name]'. " -# "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 it keeps publishing messages until Ctrl+C is pressed. " -# "with type 'std_msgs/msg/String' in topic '/chatter' " -# "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"] +@vulcanai_tool +class Ros2PublishTool(AtomicTool): + name = "ros_publish" + #ros2 topic pub chatter std_msgs/msg/String 'data: Hello World' + # description = ( + # "Publish one or more messages to a given ROS 2 topic [topic_name]. " + # "Or execute 'ros2 topic pub [topic_name]'. " + # "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 it keeps publishing messages until Ctrl+C is pressed. " + # "with type 'std_msgs/msg/String' in topic '/chatter' " + # "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"] + tool_description = "Stream and publish ROS 2 topic. " + description = ( + "Stream and publish ROS 2 topic. " + "Equivalent to `ros2 topic pub`. " + "Use when the user wants to write or publish a message in a ROS 2 topic. " + "If optional limits are omitted, it defaults to 60 seconds and 100 lines." + ) + tags = [ + "ros2", + "ros 2", + "write", + "writer", + "pub", + "publish", + "publisher", + "topic pub", + "ros2 topic pub", + "pub topic", + "write topic", + ] -# 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" -# ("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 -# ] + # 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" + # ("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": "string", -# } + 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" + ("max_lines", "int?"), # (optional) number of messages to publish + ("max_duration", "int?"), # (optional) stop after this seconds + ] -# def msg_from_dict(self, msg, values: dict): -# """ -# Populate a ROS 2 message instance from a Python dictionary. + output_schema = { + "published": "bool", + "count": "int", + "topic": "string", + "output": "string", + } -# This function recursively assigns values from a dictionary to the -# corresponding fields of a ROS 2 message instance. + def msg_from_dict(self, msg, values: dict): + """ + Populate a ROS 2 message instance from a Python dictionary. -# Supports: -# - Primitive fields (int, float, bool, string) -# - Nested ROS 2 messages + This function recursively assigns values from a dictionary to the + corresponding fields of a ROS 2 message instance. -# """ -# 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) + Supports: + - Primitive fields (int, float, bool, string) + - Nested ROS 2 messages -# def run(self, **kwargs): -# # Ros2 node to create the Publisher and print the log information -# node = self.bb.get("main_node", None) -# if node is None: -# raise Exception("Could not find shared node, aborting...") -# # Optional console handle to route logs to the subprocess panel. -# console = self.bb.get("console", None) - -# result = { -# "published": "False", -# "count": "0", -# "topic": "", -# "output": "", -# } - -# panel_enabled = console is not None and hasattr(console, "show_subprocess_panel") -# if panel_enabled: -# console.call_from_thread(console.show_subprocess_panel) -# if hasattr(console, "change_route_logs"): -# console.call_from_thread(console.change_route_logs, True) - -# topic_name = kwargs.get("topic", "/chatter") -# # Support both 'message_data' (new) and 'message' (deprecated) -# message_data = kwargs.get("message_data", kwargs.get("message", "Hello from VulcanAI PublishTool!")) -# msg_type_str = kwargs.get("msg_type", "std_msgs/msg/String") - -# max_duration = kwargs.get("max_duration", None) -# if not isinstance(max_duration, (int, float)) or max_duration <= 0: -# max_duration = None - -# max_lines = kwargs.get("max_lines", None) -# if max_lines is not None and not isinstance(max_lines, int): -# max_lines = None - -# period_sec = kwargs.get("period_sec", 0.1) - -# qos_depth = 10 - -# if console is None: -# print("[ERROR] Console not is None") - -# return result - -# published_msgs = [] -# output_lines = [] -# publisher = None -# cancel_token = None - -# try: -# if not topic_name: -# console.call_from_thread(console.logger.log_msg, "[ROS] [ERROR] No topic provided.") -# return result - -# result["topic"] = topic_name - -# if max_lines is not None and max_lines <= 0: -# # No messages to publish -# console.call_from_thread( -# console.logger.log_msg, "[ROS] [WARN] max_lines <= 0, nothing to publish." -# ) -# return result - -# MsgType = import_msg_type(msg_type_str, node) -# publisher = node.create_publisher(MsgType, topic_name, qos_depth) -# cancel_token = Future() -# console.set_stream_task(cancel_token) -# log_tool_in_stream_and_main( -# console, -# "[tool]Publisher created![tool]", -# tool_name=self.name, -# ) - -# start_time = time.monotonic() -# published_count = 0 - -# while True: -# if cancel_token.cancelled(): -# console.logger.log_tool("[tool]Ctrl+C received:[/tool] stopping publish...", tool_name=self.name) -# break -# if max_lines is not None and published_count >= max_lines: -# console.logger.log_tool(f"[tool]Stopping:[/tool] Reached max_lines = {max_lines}", tool_name=self.name) -# break -# if max_duration is not None and (time.monotonic() - start_time) >= max_duration: -# console.logger.log_tool( -# f"[tool]Stopping:[/tool] Exceeded max_duration = {max_duration}s", -# tool_name=self.name, -# ) -# break - -# msg = MsgType() - -# # Try to populate message based on message type -# if hasattr(msg, "data"): -# # Standard message type with a 'data' field (e.g., std_msgs/msg/String) -# msg.data = message_data -# else: -# # Custom message type - parse message_data as JSON -# try: -# payload = json.loads(message_data) -# self.msg_from_dict(msg, payload) -# except json.JSONDecodeError as e: -# console.call_from_thread( -# console.logger.log_msg, -# "[ROS] [ERROR] Failed to parse message_data as JSON for custom type" -# + f"'{msg_type_str}': {e}", -# ) -# return result - -# if hasattr(msg, "data"): -# publish_line = f"[ROS] [INFO] Publishing: '{msg.data}'" -# console.call_from_thread(console.logger.log_msg, f"{publish_line}") -# else: -# publish_line = f"[ROS] [INFO] Publishing custom message to '{topic_name}'" -# console.call_from_thread( -# console.logger.log_msg, -# f"{publish_line}", -# ) -# output_lines.append(publish_line) -# publisher.publish(msg) -# published_msgs.append(msg.data if hasattr(msg, "data") else str(msg)) -# published_count += 1 - -# rclpy.spin_once(node, timeout_sec=0.05) - -# if period_sec and period_sec > 0.0: -# 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) -# if publisher is not None: -# try: -# node.destroy_publisher(publisher) -# except Exception: -# pass - -# result["output"] = "\n".join(output_lines) - -# if published_msgs is not None: -# result["published"] = "True" -# result["count"] = len(published_msgs) - -# print_tool_output(console, result["output"], self.name) - -# # if panel_enabled: -# # console.logger.log_msg(result["output"], color="gray") -# # console.logger.log_msg("\n") -# # else: -# # print_tool_output(console, result["output"], self.name) -# return result + """ + for field, value in values.items(): + attr = getattr(msg, field) + if hasattr(attr, "__slots__"): + self.msg_from_dict(attr, value) + else: + setattr(msg, field, value) + def run(self, **kwargs): + # Ros2 node to create the Publisher and print the log information + node = self.bb.get("main_node", None) + if node is None: + raise Exception("Could not find shared node, aborting...") + # Optional console handle to route logs to the subprocess panel. + console = self.bb.get("console", None) + + result = { + "published": "False", + "count": "0", + "topic": "", + "output": "", + } + + panel_enabled = console is not None and hasattr(console, "show_subprocess_panel") + if panel_enabled: + console.call_from_thread(console.show_subprocess_panel) + if hasattr(console, "change_route_logs"): + console.call_from_thread(console.change_route_logs, True) + + topic_name = kwargs.get("topic", "/chatter") + # Support both 'message_data' (new) and 'message' (deprecated) + message_data = kwargs.get("message_data", kwargs.get("message", "Hello from VulcanAI PublishTool!")) + msg_type_str = kwargs.get("msg_type", "std_msgs/msg/String") + + max_duration = kwargs.get("max_duration", None) + if not isinstance(max_duration, (int, float)) or max_duration <= 0: + max_duration = None + + max_lines = kwargs.get("max_lines", None) + if max_lines is not None and not isinstance(max_lines, int): + max_lines = None + + period_sec = kwargs.get("period_sec", 0.1) + + qos_depth = 10 + + if console is None: + print("[ERROR] Console not is None") + + return result + + published_msgs = [] + output_lines = [] + publisher = None + cancel_token = None + + try: + if not topic_name: + console.call_from_thread(console.logger.log_msg, "[ROS] [ERROR] No topic provided.") + return result + + result["topic"] = topic_name + + if max_lines is not None and max_lines <= 0: + # No messages to publish + console.call_from_thread( + console.logger.log_msg, "[ROS] [WARN] max_lines <= 0, nothing to publish." + ) + return result + + MsgType = import_msg_type(msg_type_str, node) + publisher = node.create_publisher(MsgType, topic_name, qos_depth) + cancel_token = Future() + console.set_stream_task(cancel_token) + log_tool_in_stream_and_main( + console, + "[tool]Publisher created![tool]", + tool_name=self.name, + ) + + start_time = time.monotonic() + published_count = 0 + + while True: + if cancel_token.cancelled(): + console.logger.log_tool("[tool]Ctrl+C received:[/tool] stopping publish...", tool_name=self.name) + break + if max_lines is not None and published_count >= max_lines: + console.logger.log_tool(f"[tool]Stopping:[/tool] Reached max_lines = {max_lines}", tool_name=self.name) + break + if max_duration is not None and (time.monotonic() - start_time) >= max_duration: + console.logger.log_tool( + f"[tool]Stopping:[/tool] Exceeded max_duration = {max_duration}s", + tool_name=self.name, + ) + break + + msg = MsgType() + + # Try to populate message based on message type + if hasattr(msg, "data"): + # Standard message type with a 'data' field (e.g., std_msgs/msg/String) + msg.data = message_data + else: + # Custom message type - parse message_data as JSON + try: + payload = json.loads(message_data) + self.msg_from_dict(msg, payload) + except json.JSONDecodeError as e: + console.call_from_thread( + console.logger.log_msg, + "[ROS] [ERROR] Failed to parse message_data as JSON for custom type" + + f"'{msg_type_str}': {e}", + ) + return result + + if hasattr(msg, "data"): + publish_line = f"[ROS] [INFO] Publishing: '{msg.data}'" + console.call_from_thread(console.logger.log_msg, f"{publish_line}") + else: + publish_line = f"[ROS] [INFO] Publishing custom message to '{topic_name}'" + console.call_from_thread( + console.logger.log_msg, + f"{publish_line}", + ) + output_lines.append(publish_line) + publisher.publish(msg) + published_msgs.append(msg.data if hasattr(msg, "data") else str(msg)) + published_count += 1 + + rclpy.spin_once(node, timeout_sec=0.05) + + if period_sec and period_sec > 0.0: + 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) + if publisher is not None: + try: + node.destroy_publisher(publisher) + except Exception: + pass + + result["output"] = "\n".join(output_lines) + + if published_msgs is not None: + result["published"] = "True" + result["count"] = len(published_msgs) + + print_tool_output(console, result["output"], self.name) + + # if panel_enabled: + # console.logger.log_msg(result["output"], color="gray") + # console.logger.log_msg("\n") + # else: + # print_tool_output(console, result["output"], self.name) + return result -# @vulcanai_tool -# class Ros2SubscribeTool(AtomicTool): -# name = "ros_subscribe" -# description = ( -# "Subscribe to a topic [topic] or execute 'ros2 topic echo [topic]' " -# "and stop after receiving N messages or max duration." -# ) -# tags = ["ros2", "subscribe", "topic", "std_msgs"] -# input_schema = [ -# ("topic", "string"), # topic name -# ("max_lines", "int?"), # (optional) stop after this number of messages -# ("max_duration", "int?"), # (optional) stop after this seconds -# ] +@vulcanai_tool +class Ros2SubscribeTool(AtomicTool): + name = "ros_subscribe" + tool_description = "Stream and observe ROS 2 topic. " + description = ( + "Stream and observe ROS 2 topic. " + "Equivalent to `ros2 topic echo`. " + "Use when the user wants to show, display, or print what is being published in a ROS 2 topic. " + "If optional limits are omitted, it defaults to 60 seconds and 100 lines." + ) + tags = [ + "ros2", + "ros 2", + "echo", + "subscribe", + "subs", + "topic echo", + "ros2 topic echo", + "inspect topic", + "show topic", + "display topic", + "print topic" + ] -# output_schema = { -# "subscribed": "bool", -# "count": "int", -# "topic": "string", -# "output": "string", -# } + input_schema = [ + ("topic", "string"), # topic name + ("max_duration", "int?"), # (optional) stop after this seconds + ("max_lines", "int?"), # (optional) stop after this number of messages + ] + input_defaults = {"max_duration": 60, "max_lines": 100} + output_schema = { + "subscribed": "bool", + "count": "int", + "topic": "string", + "output": "string", + } -# def run(self, **kwargs): -# # Ros2 node to create the Publisher and print the log information -# node = self.bb.get("main_node", None) -# if node is None: -# raise Exception("Could not find shared node, aborting...") -# # Optional console handle to support Ctrl+C cancellation. -# console = self.bb.get("console", None) - -# result = { -# "subscribed": "False", -# "count": "0", -# "topic": "", -# "output": "", -# } - -# topic_name = kwargs.get("topic", None) -# max_duration = kwargs.get("max_duration", None) -# if max_duration is not None and not isinstance(max_duration, (int, float)): -# max_duration = None - -# max_lines = kwargs.get("max_lines", None) -# if max_lines is not None and not isinstance(max_lines, int): -# max_lines = None - -# # Start line (stream panel/main panel with tool color) -# console.logger.log_tool("[tool]Subscriber created![/tool]", tool_name=self.name) - -# # "--field data" prints only the data field from each message -# # instead of the full YAML message -# # "--no-arr" do not print array fields of messages -# base_args = ["ros2", "topic", "echo", topic_name, "--field", "data", "--no-arr"] -# ret = execute_subprocess(console, self.name, base_args, max_duration, max_lines, log_created=False) - -# ret_lines = ret.splitlines() if isinstance(ret, str) and ret else [] - -# result["output"] = "\n".join(ret_lines) - -# if ret is not None: -# result["subscribed"] = "True" -# result["count"] = len(ret_lines) -# result["topic"] = topic_name - -# print_tool_output(console, result["output"], self.name) - -# return result + def run(self, **kwargs): + # Ros2 node to create the Publisher and print the log information + node = self.bb.get("main_node", None) + if node is None: + raise Exception("Could not find shared node, aborting...") + # Optional console handle to support Ctrl+C cancellation. + console = self.bb.get("console", None) + + result = { + "subscribed": "False", + "count": "0", + "topic": "", + "output": "", + } + + topic_name = kwargs.get("topic", None) + max_duration = kwargs.get("max_duration") + try: + max_duration = float(max_duration) if max_duration is not None else None + except (TypeError, ValueError): + max_duration = None + if max_duration is None or max_duration <= 0: + max_duration = self.input_defaults["max_duration"] + + max_lines = kwargs.get("max_lines") + try: + max_lines = int(max_lines) if max_lines is not None else None + except (TypeError, ValueError): + max_lines = None + if max_lines is None or max_lines <= 0: + max_lines = self.input_defaults["max_lines"] + + # Start line (stream panel/main panel with tool color) + console.logger.log_tool("[tool]Subscriber created![/tool]", tool_name=self.name) + + # "--field data" prints only the data field from each message + # instead of the full YAML message + # "--no-arr" do not print array fields of messages + base_args = ["ros2", "topic", "echo", topic_name, "--field", "data", "--no-arr"] + ret = execute_subprocess(console, self.name, base_args, max_duration, max_lines, log_created=False) + + ret_lines = ret.splitlines() if isinstance(ret, str) and ret else [] + + result["output"] = "\n".join(ret_lines) + + if ret is not None: + result["subscribed"] = "True" + result["count"] = len(ret_lines) + result["topic"] = topic_name + + print_tool_output(console, result["output"], self.name) + + return result diff --git a/src/vulcanai/tools/tool_registry.py b/src/vulcanai/tools/tool_registry.py index d2fa2fc..72fb34e 100644 --- a/src/vulcanai/tools/tool_registry.py +++ b/src/vulcanai/tools/tool_registry.py @@ -38,6 +38,10 @@ class HelpTool(ITool): """A tool that provides help information.""" name = "help" + tool_description = ( + "Provides help information for using the library. It can list all available tools or" + " give info about the usage of a specific tool if 'tool_name' is provided as an argument." + ) description = ( "Provides help information for using the library. It can list all available tools or" " give info about the usage of a specific tool if 'tool_name' is provided as an argument." @@ -105,7 +109,7 @@ def __init__(self, embedder=None, logger=None, default_tools=True): self.logger.log_msg(f"[error]{e}[/error]") raise - def register_tool(self, tool: ITool, solve_deps: bool = True): + def register_tool(self, tool: ITool, solve_deps: bool = True, log: bool = True): """Register a single tool instance.""" # Avoid duplicates if tool.name in self.tools: @@ -116,7 +120,8 @@ def register_tool(self, tool: ITool, solve_deps: bool = True): self.validation_tools.append(tool.name) emb = self.embedder.embed(self._doc(tool)) self._index.append((tool.name, emb)) - self.logger.log_registry(f"Registered tool: [registry]{tool.name}[/registry]") + if log: + self.logger.log_registry(f"Registered tool: [registry]{tool.name}[/registry]") self.help_tool.available_tools = self.tools if solve_deps: # Get class of tool @@ -165,6 +170,7 @@ def deactivate_tool(self, tool_name) -> bool: def register(self): """Register all loaded classes marked with @vulcanai_tool.""" + before = set(self.tools.keys()) composite_classes = [] for module in self._loaded_modules: for name in dir(module): @@ -174,11 +180,14 @@ def register(self): if issubclass(tool, CompositeTool): composite_classes.append(tool) else: - self.register_tool(tool(), solve_deps=False) + self.register_tool(tool(), solve_deps=False, log=False) # Register composite tools after atomic ones to resolve dependencies for tool_cls in composite_classes: tool = tool_cls() - self.register_tool(tool, solve_deps=True) + self.register_tool(tool, solve_deps=True, log=False) + + newly_registered = [name for name in self.tools if name not in before] + self._log_tools_grouped(newly_registered) def _resolve_dependencies(self, tool: CompositeTool): """Resolve and attach dependencies for a CompositeTool.""" @@ -280,4 +289,81 @@ def top_k(self, query: str, k: int = 5, validation: bool = False) -> list[ITool] @staticmethod def _doc(tool: ITool) -> str: # Text used for embeddings - return f"{tool.name}\n{tool.description}\n{tool.tags}\n{tool.input_schema}\n" + # TODO. danip + #return f"{tool.name}\n{tool.description}\n{tool.tags}\n{tool.input_schema}\n" + input_defaults = getattr(tool, "input_defaults", {}) or {} + inputs = [] + for key, type_name in tool.input_schema: + optional = isinstance(type_name, str) and type_name.endswith("?") + base_type = type_name[:-1] if optional else type_name + role = "optional" if optional else "required" + default = f", default={input_defaults[key]}" if key in input_defaults else "" + inputs.append(f"{key}:{base_type}:{role}{default}") + return f"{tool.name}\n{tool.description}\n{tool.tags}\n{inputs}\n" + + def group_tool_names(self, tool_names: list) -> list: + """Group tool names by common prefix, preserving registration order. + + Returns a list of (name, subtools) tuples: + - For groups: (prefix, [sorted subtool suffixes]) + - For standalone tools: (tool_name, None) + """ + # Count how many tools share each prefix (require >= 2 underscore parts) + prefix_count: dict = {} + for name in tool_names: + parts = name.split("_") + for i in range(2, len(parts)): + prefix = "_".join(parts[:i]) + prefix_count[prefix] = prefix_count.get(prefix, 0) + 1 + + # Assign each tool to its longest valid group prefix + tool_group: dict = {} + group_tools: dict = {} + + for name in tool_names: + parts = name.split("_") + best_prefix = None + for i in range(len(parts) - 1, 1, -1): + prefix = "_".join(parts[:i]) + if prefix_count.get(prefix, 0) >= 2: + best_prefix = prefix + break + + if best_prefix: + suffix = name[len(best_prefix) + 1:] + tool_group[name] = best_prefix + group_tools.setdefault(best_prefix, []).append(suffix) + else: + tool_group[name] = None + + # Build result preserving registration order, groups emitted on first occurrence + result = [] + emitted_groups: set = set() + + for name in tool_names: + prefix = tool_group[name] + if prefix is None: + result.append((name, None)) + elif prefix not in emitted_groups: + emitted_groups.add(prefix) + result.append((prefix, sorted(group_tools[prefix]))) + + return result + + def _log_tools_grouped(self, tool_names: list): + """Log a batch of newly registered tools, grouped by common prefix.""" + grouped = self.group_tool_names(tool_names) + for entry_name, subtools in grouped: + if subtools is None: + self.logger.log_registry( + f"Registered tool: [registry]{entry_name}[/registry]" + ) + else: + self.logger.log_registry( + f"Registered group of tools: [registry]{entry_name}[/registry]" + ) + for subtool in subtools: + self.logger.log_registry( + f"Registered tool: [registry]{subtool}[/registry]", + indent=1, + ) diff --git a/src/vulcanai/tools/tools.py b/src/vulcanai/tools/tools.py index dc1b325..53fe8c7 100644 --- a/src/vulcanai/tools/tools.py +++ b/src/vulcanai/tools/tools.py @@ -31,6 +31,9 @@ class ITool(ABC): # JSON schemas for input validation and output formatting. # Only used for documentation and LLM prompt generation. input_schema: List[Tuple[str, str]] = [] # List of (key, type) pairs, simulating a ArgValue list + # Optional default values for documented inputs. + # These are metadata for prompt generation and tool discovery only. + input_defaults: Dict[str, Any] = {} output_schema: Dict[str, str] = {} # Validation tool is_validation_tool: bool = False From af735fd9e724b6a683fec40bbf47d050c5df9922 Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 23 Apr 2026 11:32:15 +0200 Subject: [PATCH 33/55] [#23897] Added 'group_name' variable to group subtools Signed-off-by: danipiza --- src/vulcanai/console/console.py | 4 +- src/vulcanai/console/logger.py | 3 +- src/vulcanai/tools/default_tools.py | 35 ++++++++++++++++- src/vulcanai/tools/tool_registry.py | 59 +++++++++++------------------ 4 files changed, 61 insertions(+), 40 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index c03423b..f128e17 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -654,13 +654,13 @@ def cmd_tools(self, _) -> None: for entry_name, subtools in grouped: if subtools is None: tool = self.manager.registry.tools[entry_name] - tool_msg += f"- {tool.name}: {tool.tool_description}\n" + tool_msg += f"{tool.name}: {tool.tool_description}\n" else: tool_msg += f"{entry_name}:\n" for subtool in subtools: full_name = f"{entry_name}_{subtool}" tool = self.manager.registry.tools[full_name] - tool_msg += f" - {subtool}: {tool.tool_description}\n" + tool_msg += f" - {subtool}: {tool.tool_description}\n" if "help" in self.manager.registry.tools: help_tool = self.manager.registry.tools["help"] diff --git a/src/vulcanai/console/logger.py b/src/vulcanai/console/logger.py index 5e08dd4..4ec0e33 100644 --- a/src/vulcanai/console/logger.py +++ b/src/vulcanai/console/logger.py @@ -177,7 +177,8 @@ def log_tool(self, msg: str, tool_name: str = "", error: bool = False, color: st return processed_msg def log_registry(self, msg: str, error: bool = False, color: str = "", indent: int = 0): - indent_str = " " * indent + # indent_str = " " * indent + indent_str = " " * indent if error: prefix = f"[error][REGISTRY] [ERROR][/error]{indent_str} " elif indent > 0: diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index aaf09f2..ffadcd9 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -507,6 +507,7 @@ def _run_ros2_interface_command(console, tool_name: str, command: str, interface @vulcanai_tool class Ros2NodeListTool(AtomicTool): name = "ros2_node_list" + group_name = "ros2_node" tool_description = "List all currently available ROS 2 nodes. " description = ( "List all currently available ROS 2 nodes. " @@ -536,6 +537,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2NodeInfoTool(AtomicTool): name = "ros2_node_info" + group_name = "ros2_node" tool_description = "Show details for a specific ROS 2 node. " description = ( "Show details for a specific ROS 2 node. " @@ -562,6 +564,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2TopicListTool(AtomicTool): name = "ros2_topic_list" + group_name = "ros2_topic" tool_description = "List all currently available ROS 2 topics. " description = ( "List all currently available ROS 2 topics. " @@ -591,6 +594,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2TopicInfoTool(AtomicTool): name = "ros2_topic_info" + group_name = "ros2_topic" tool_description = "Show details for a specific ROS 2 topic. " description = ( "Show details for a specific ROS 2 topic. " @@ -617,6 +621,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2TopicFindTool(AtomicTool): name = "ros2_topic_find" + group_name = "ros2_topic" tool_description = "Find ROS 2 topics by message type. " description = ( "Find ROS 2 topics by message type. " @@ -645,6 +650,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2TopicTypeTool(AtomicTool): name = "ros2_topic_type" + group_name = "ros2_topic" tool_description = "Show the message type used by a ROS 2 topic. " description = ( "Show the message type used by a ROS 2 topic. " @@ -673,6 +679,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2TopicBwTool(AtomicTool): name = "ros2_topic_bw" + group_name = "ros2_topic" tool_description = "Stream and observe ROS 2 topic bandwidth. " description = ( "Stream and observe ROS 2 topic bandwidth. " @@ -731,6 +738,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2TopicDelayTool(AtomicTool): name = "ros2_topic_delay" + group_name = "ros2_topic" tool_description = "Stream and observe ROS 2 topic delay. " description = ( "Stream and observe ROS 2 topic delay. " @@ -779,6 +787,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2TopicHzTool(AtomicTool): name = "ros2_topic_hz" + group_name = "ros2_topic" tool_description = "Stream and observe ROS 2 topic average receiving rate. " description = ( "Stream and observe ROS 2 topic average receiving rate. " @@ -830,6 +839,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ServiceListTool(AtomicTool): name = "ros2_service_list" + group_name = "ros2_service" tool_description = "List all currently available ROS 2 services. " description = ( "List all currently available ROS 2 services. " @@ -859,6 +869,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ServiceInfoTool(AtomicTool): name = "ros2_service_info" + group_name = "ros2_service" tool_description = "Show details for a specific ROS 2 service. " description = ( "Show details for a specific ROS 2 service. " @@ -886,6 +897,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ServiceTypeTool(AtomicTool): name = "ros2_service_type" + group_name = "ros2_service" tool_description = "Show the message type used by a ROS 2 service. " description = ( "Show the message type used by a ROS 2 service. " @@ -914,6 +926,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ServiceFindTool(AtomicTool): name = "ros2_service_find" + group_name = "ros2_service" tool_description = "Find ROS 2 services by message type. " description = ( "Find ROS 2 services by message type. " @@ -941,6 +954,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ServiceCallTool(AtomicTool): name = "ros2_service_call" + group_name = "ros2_service" tool_description = "Call a ROS 2 service. " description = ( "Call a ROS 2 service. " @@ -977,6 +991,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ServiceEchoTool(AtomicTool): name = "ros2_service_echo" + group_name = "ros2_service" tool_description = "Stream and observe ROS 2 service traffic over time. " description = ( "Stream and observe ROS 2 service traffic over time. " @@ -1025,6 +1040,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ActionListTool(AtomicTool): name = "ros2_action_list" + group_name = "ros2_action" tool_description = "List all currently available ROS 2 actions. " description = ( "List all currently available ROS 2 actions. " @@ -1053,6 +1069,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ActionInfoTool(AtomicTool): name = "ros2_action_info" + group_name = "ros2_action" tool_description = "Show details for a specific ROS 2 action. " description = ( "Show details for a specific ROS 2 action. " @@ -1078,6 +1095,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ActionTypeTool(AtomicTool): name = "ros2_action_type" + group_name = "ros2_action" tool_description = "Show the message type used by a ROS 2 action. " description = ( "Show the message type used by a ROS 2 action. " @@ -1105,6 +1123,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ActionSendGoalTool(AtomicTool): name = "ros2_action_send_goal" + group_name = "ros2_action" tool_description = "Send a goal to a ROS 2 action server. " description = ( "Send a goal to a ROS 2 action server. " @@ -1144,6 +1163,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ParamListTool(AtomicTool): name = "ros2_param_list" + group_name = "ros2_param" tool_description = "List all currently available ROS 2 params. " description = ( "List all currently available ROS 2 params. " @@ -1172,6 +1192,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ParamGetTool(AtomicTool): name = "ros2_param_get" + group_name = "ros2_param" tool_description = "Get the value of a parameter from a ROS 2 node. " description = ( "Get the value of a parameter from a ROS 2 node. " @@ -1208,6 +1229,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ParamDescribeTool(AtomicTool): name = "ros2_param_describe" + group_name = "ros2_param" tool_description = "Show the definition and metadata of a ROS 2 parameter. " description = ( "Show the definition and metadata of a ROS 2 parameter. " @@ -1244,6 +1266,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ParamSetTool(AtomicTool): name = "ros2_param_set" + group_name = "ros2_param" tool_description = "Set the value of a parameter on a ROS 2 node. " description = ( "Set the value of a parameter on a ROS 2 node. " @@ -1282,6 +1305,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ParamDeleteTool(AtomicTool): name = "ros2_param_delete" + group_name = "ros2_param" tool_description = "Delete a parameter from a ROS 2 node. " description = ( "Delete a parameter from a ROS 2 node. " @@ -1319,6 +1343,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ParamDumpTool(AtomicTool): name = "ros2_param_dump" + group_name = "ros2_param" tool_description = "Export all parameters from a ROS 2 node. " description = ( "Export all parameters from a ROS 2 node. " @@ -1347,6 +1372,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2ParamLoadTool(AtomicTool): name = "ros2_param_load" + group_name = "ros2_param" tool_description = "Load parameters into a ROS 2 node from a file." description = ( "Load parameters into a ROS 2 node from a file." @@ -1384,6 +1410,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2PkgListTool(AtomicTool): name = "ros2_pkg_list" + group_name = "ros2_pkg" tool_description = "List all currently available ROS 2 pkgs. " description = ( "List all currently available ROS 2 pkgs. " @@ -1412,6 +1439,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2PkgExecutablesTool(AtomicTool): name = "ros2_pkg_executables" + group_name = "ros2_pkg" tool_description = "List executable entry points provided by ROS 2 packages. " description = ( "List executable entry points provided by ROS 2 packages. " @@ -1440,6 +1468,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2InterfaceListTool(AtomicTool): name = "ros2_interface_list" + group_name = "ros2_interface" tool_description = "List all currently available ROS 2 interfaces. " description = ( "List all currently available ROS 2 interfaces. " @@ -1468,6 +1497,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2InterfacePackagesTool(AtomicTool): name = "ros2_interface_packages" + group_name = "ros2_interface" tool_description = "List ROS 2 packages that provide interfaces." description = ( "List ROS 2 packages that provide interfaces." @@ -1496,6 +1526,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2InterfacePackageTool(AtomicTool): name = "ros2_interface_package" + group_name = "ros2_interface" tool_description = "List the interfaces provided by a ROS 2 package. " description = ( "List the interfaces provided by a ROS 2 package. " @@ -1523,6 +1554,7 @@ def run(self, **kwargs): @vulcanai_tool class Ros2InterfaceShowTool(AtomicTool): name = "ros2_interface_show" + group_name = "ros2_interface" tool_description = "Show the definition of a ROS 2 interface. " description = ( "Show the definition of a ROS 2 interface. " @@ -2005,7 +2037,7 @@ class Ros2SubscribeTool(AtomicTool): description = ( "Stream and observe ROS 2 topic. " "Equivalent to `ros2 topic echo`. " - "Use when the user wants to show, display, or print what is being published in a ROS 2 topic. " + "Use when the user wants to show, display, print or listen what is being published in a ROS 2 topic. " "If optional limits are omitted, it defaults to 60 seconds and 100 lines." ) tags = [ @@ -2014,6 +2046,7 @@ class Ros2SubscribeTool(AtomicTool): "echo", "subscribe", "subs", + "listen", "topic echo", "ros2 topic echo", "inspect topic", diff --git a/src/vulcanai/tools/tool_registry.py b/src/vulcanai/tools/tool_registry.py index 72fb34e..4929f2d 100644 --- a/src/vulcanai/tools/tool_registry.py +++ b/src/vulcanai/tools/tool_registry.py @@ -302,51 +302,38 @@ def _doc(tool: ITool) -> str: return f"{tool.name}\n{tool.description}\n{tool.tags}\n{inputs}\n" def group_tool_names(self, tool_names: list) -> list: - """Group tool names by common prefix, preserving registration order. + """Group tool names by each tools `group_name` attribute. + + Tools that declare a `group_name` are collected under that group; tools + without the attribute are emitted standalone. Registration order is + preserved, with each group emitted on the first occurrence of one of + its members. Returns a list of (name, subtools) tuples: - - For groups: (prefix, [sorted subtool suffixes]) + - For groups: (group_name, [sorted subtool display names]) - For standalone tools: (tool_name, None) """ - # Count how many tools share each prefix (require >= 2 underscore parts) - prefix_count: dict = {} - for name in tool_names: - parts = name.split("_") - for i in range(2, len(parts)): - prefix = "_".join(parts[:i]) - prefix_count[prefix] = prefix_count.get(prefix, 0) + 1 - - # Assign each tool to its longest valid group prefix - tool_group: dict = {} + result = [] group_tools: dict = {} + order = [] for name in tool_names: - parts = name.split("_") - best_prefix = None - for i in range(len(parts) - 1, 1, -1): - prefix = "_".join(parts[:i]) - if prefix_count.get(prefix, 0) >= 2: - best_prefix = prefix - break - - if best_prefix: - suffix = name[len(best_prefix) + 1:] - tool_group[name] = best_prefix - group_tools.setdefault(best_prefix, []).append(suffix) + tool = self.tools.get(name) + group = getattr(tool, "group_name", None) if tool is not None else None + if group: + if group not in group_tools: + group_tools[group] = [] + order.append(("group", group)) + display = name[len(group) + 1:] if name.startswith(group + "_") else name + group_tools[group].append(display) else: - tool_group[name] = None + order.append(("tool", name)) - # Build result preserving registration order, groups emitted on first occurrence - result = [] - emitted_groups: set = set() - - for name in tool_names: - prefix = tool_group[name] - if prefix is None: - result.append((name, None)) - elif prefix not in emitted_groups: - emitted_groups.add(prefix) - result.append((prefix, sorted(group_tools[prefix]))) + for kind, item in order: + if kind == "tool": + result.append((item, None)) + else: + result.append((item, sorted(group_tools[item]))) return result From 1495b8c022029d05b9874141dc267a9d13aac5d1 Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 23 Apr 2026 12:17:46 +0200 Subject: [PATCH 34/55] [#23897] Linters + solved '<>' user queries by removing from the query and first execution warning text Signed-off-by: danipiza --- src/vulcanai/console/console.py | 24 ++-- src/vulcanai/console/modal_screens.py | 13 +- src/vulcanai/core/manager.py | 8 +- src/vulcanai/core/manager_iterator.py | 2 +- src/vulcanai/core/manager_plan.py | 3 +- src/vulcanai/core/validator.py | 7 +- src/vulcanai/tools/default_tools.py | 168 ++++++++------------------ src/vulcanai/tools/tool_registry.py | 12 +- 8 files changed, 79 insertions(+), 158 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index f128e17..491a82d 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -560,9 +560,8 @@ async def open_checklist(self, grouped: list, active_set: set) -> None: else: selected_set = set(selected) # Collect all non-help tool names - all_tool_names = ( - set(self.manager.registry.tools.keys()) - | set(self.manager.registry.deactivated_tools.keys()) + all_tool_names = set(self.manager.registry.tools.keys()) | set( + self.manager.registry.deactivated_tools.keys() ) all_tool_names.discard("help") @@ -671,8 +670,7 @@ def cmd_tools(self, _) -> None: def cmd_edit_tools(self, _) -> None: all_names = sorted( n - for n in list(self.manager.registry.tools.keys()) - + list(self.manager.registry.deactivated_tools.keys()) + for n in list(self.manager.registry.tools.keys()) + list(self.manager.registry.deactivated_tools.keys()) if n != "help" ) active_set = set(self.manager.registry.tools.keys()) @@ -1029,7 +1027,8 @@ def add_line(self, input: str, color: str = "", subprocess_flag: bool = False) - # Keep tool/executor output hidden in main panel while stream panel remains # visible after stream completion. Flushes on Ctrl+C panel close - if (target_panel is self.main_pannel + if ( + target_panel is self.main_pannel and self._stream_panel_visible and not self._is_stream_task_active() and self._route_logs_to_stream_panel == 0 @@ -1069,7 +1068,11 @@ async def on_input_submitted(self, event: Input.Submitted) -> None: # Console not ready yet return - cmd = event.value.strip() + # Strip '<' and '>' so inputs like '' (ROS topic names) are not + # consumed by the Textual markup parser, which would drop the token from + # the echoed line and raise "'' is not a valid color" errors when + # the same text flows through manager/plan logs. + cmd = event.value.strip().replace("<", "").replace(">", "") if not cmd: # Empty command return @@ -1081,8 +1084,8 @@ async def on_input_submitted(self, event: Input.Submitted) -> None: # Not the command input box return - # Get the user input and strip leading/trailing spaces - user_input = (event.value or "").strip() + # Already sanitized above; reuse to keep echo, history, and query aligned + user_input = cmd # Defensive recovery: if no stream is active but routing stayed enabled, # clear only routing (keep panel visible until Ctrl+C) @@ -1386,8 +1389,7 @@ def action_stop_streaming_task(self) -> None: pass self.reset_stream_state() - elif self.stream_pannel is not None and ( - self._stream_panel_visible or self._route_logs_to_stream_panel > 0): + elif self.stream_pannel is not None and (self._stream_panel_visible or self._route_logs_to_stream_panel > 0): # No active stream, but stale stream UI/routing is visible self.reset_stream_state() diff --git a/src/vulcanai/console/modal_screens.py b/src/vulcanai/console/modal_screens.py index f53cb41..7dbd14d 100644 --- a/src/vulcanai/console/modal_screens.py +++ b/src/vulcanai/console/modal_screens.py @@ -193,11 +193,11 @@ class CheckListModal(ModalScreen[list[str] | None]): def __init__(self, grouped: list, active_tools: set) -> None: super().__init__() - self.grouped = grouped # [(prefix, [subtools]) | (name, None), ...] + self.grouped = grouped # [(prefix, [subtools]) | (name, None), ...] self.active_tools = active_tools self._parent_to_children: dict[str, list[str]] = {} self._child_to_parent: dict[str, str] = {} - self._id_to_tool: dict[str, str] = {} # cb_id -> full tool name (children & standalone only) + self._id_to_tool: dict[str, str] = {} # cb_id -> full tool name (children & standalone only) def compose(self) -> ComposeResult: with Vertical(classes="dialog"): @@ -232,9 +232,7 @@ def compose(self) -> ComposeResult: for cid in child_ids: self._child_to_parent[cid] = parent_id - all_active = all( - f"{group_name}_{s}" in self.active_tools for s in subtools - ) + all_active = all(f"{group_name}_{s}" in self.active_tools for s in subtools) yield Checkbox(group_name, value=all_active, id=parent_id) for subtool, child_id in zip(subtools, child_ids): @@ -262,10 +260,7 @@ def on_checkbox_changed(self, event: Checkbox.Changed) -> None: elif cb_id in self._child_to_parent: # Child toggled -> update parent (checked only when ALL children checked) parent_id = self._child_to_parent[cb_id] - all_checked = all( - self.query_one(f"#{cid}", Checkbox).value - for cid in self._parent_to_children[parent_id] - ) + all_checked = all(self.query_one(f"#{cid}", Checkbox).value for cid in self._parent_to_children[parent_id]) with self.prevent(Checkbox.Changed): self.query_one(f"#{parent_id}", Checkbox).value = all_checked diff --git a/src/vulcanai/core/manager.py b/src/vulcanai/core/manager.py index a87131b..5ff477a 100644 --- a/src/vulcanai/core/manager.py +++ b/src/vulcanai/core/manager.py @@ -12,13 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -import shlex from typing import Any, Dict, Optional, Tuple from vulcanai.console.logger import VulcanAILogger from vulcanai.core.agent import Agent from vulcanai.core.executor import Blackboard, PlanExecutor -from vulcanai.core.plan_types import ArgValue, GlobalPlan, PlanNode, Step +from vulcanai.core.plan_types import GlobalPlan from vulcanai.core.validator import PlanValidator from vulcanai.tools.tool_registry import ToolRegistry @@ -180,7 +179,7 @@ def _build_prompt(self, user_text: str, ctx: Dict[str, Any]) -> Tuple[str, str]: ) tools_text = "\n".join(tool_descriptions) # TODO. danip, better performance? - #tools_text = self.render_tool_descriptions(tools) + # tools_text = self.render_tool_descriptions(tools) user_context = self._parse_user_context() user_prompt = "User request:\n" + user_text @@ -269,7 +268,8 @@ def _get_prompt_template(self) -> str: Be sure to understand the text received and select the best action command from the available options. Never return only a summary: always produce at least one PlanNode with at least one Step. Inputs whose type ends with `?` are optional and may be omitted when the tool default behavior is appropriate. -If an input is marked optional and the user did not ask for a specific value, omit that argument instead of inventing one. +If an input is marked optional and the user did not ask for a specific value, +omit that argument instead of inventing one. Do not guess placeholder values such as `1`, `1.0`, `10`, or empty strings for optional inputs. {user_context} ## Available tools: diff --git a/src/vulcanai/core/manager_iterator.py b/src/vulcanai/core/manager_iterator.py index f5d8871..1bba3ae 100644 --- a/src/vulcanai/core/manager_iterator.py +++ b/src/vulcanai/core/manager_iterator.py @@ -255,7 +255,7 @@ def _build_goal_prompt(self, user_text: str, ctx: Dict[str, Any]) -> Tuple[str, ) tools_text = "\n".join(tool_descriptions) # TODO. danip, better performance? - #tools_text = self.render_tool_descriptions(tools) + # tools_text = self.render_tool_descriptions(tools) else: tools_text = "No available tools. Use blackboard" diff --git a/src/vulcanai/core/manager_plan.py b/src/vulcanai/core/manager_plan.py index 2bb7d5f..a0d986c 100644 --- a/src/vulcanai/core/manager_plan.py +++ b/src/vulcanai/core/manager_plan.py @@ -54,7 +54,8 @@ def _get_prompt_template(self) -> str: achieve sub-goals. Never return only a summary: always produce at least one PlanNode with at least one Step. Inputs whose type ends with `?` are optional and may be omitted when the tool default behavior is appropriate. -If an input is marked optional and the user did not ask for a specific value, omit that argument instead of inventing one. +If an input is marked optional and the user did not ask for a specific value, +omit that argument instead of inventing one. Do not guess placeholder values such as `1`, `1.0`, `10`, or empty strings for optional inputs. {user_context} ## Available tools: diff --git a/src/vulcanai/core/validator.py b/src/vulcanai/core/validator.py index ead47d4..f0bd127 100644 --- a/src/vulcanai/core/validator.py +++ b/src/vulcanai/core/validator.py @@ -70,8 +70,7 @@ def _validate_step(self, step: Step): if len(step.args) < required_len or len(step.args) > schema_len: if required_len == schema_len: raise ValueError( - f"Tool '{tool.name}' expects {schema_len} arguments," - f" but {len(step.args)} were provided." + f"Tool '{tool.name}' expects {schema_len} arguments, but {len(step.args)} were provided." ) raise ValueError( f"Tool '{tool.name}' expects between {required_len} and {schema_len} arguments," @@ -80,9 +79,7 @@ def _validate_step(self, step: Step): missing_required = sorted(required_keys - provided_keys) if missing_required: - raise ValueError( - f"Tool '{tool.name}' is missing required arguments: {', '.join(missing_required)}." - ) + raise ValueError(f"Tool '{tool.name}' is missing required arguments: {', '.join(missing_required)}.") for arg in step.args: if arg.key not in {k for d in tool.input_schema for k in d}: diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index ffadcd9..11b7c5e 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -438,7 +438,9 @@ def _run_ros2_param_command( raise ValueError("`command='load'` `file_path`.") result["output"] = run_oneshot_cmd(["ros2", "param", "load", node_name, file_path]) else: - raise ValueError(f"Unknown command '{command}'. Expected one of: list, get, describe, set, delete, dump, load.") + raise ValueError( + f"Unknown command '{command}'. Expected one of: list, get, describe, set, delete, dump, load." + ) print_tool_output(console, result["output"], tool_name) return result @@ -543,16 +545,8 @@ class Ros2NodeInfoTool(AtomicTool): "Show details for a specific ROS 2 node. " "Equivalent to `ros2 node info [name]`." "Use when the user wants to list, show, display, or print the information of a ROS 2 node." - ) - tags = [ - "ros2", - "ros 2", - "info", - "node info", - "ros2 node info", - "inspect node", - "show node details" - ] + ) + tags = ["ros2", "ros 2", "info", "node info", "ros2 node info", "inspect node", "show node details"] input_schema = [("node_name", "string")] output_schema = {"output": "string"} @@ -601,15 +595,7 @@ class Ros2TopicInfoTool(AtomicTool): "Equivalent to `ros2 topic info`. " "Use when the user wants to list, show, display, or print the information of a ROS 2 topic." ) - tags = [ - "ros2", - "ros 2", - "info", - "topic info", - "ros2 topic info", - "inspect topic", - "show topic details" - ] + tags = ["ros2", "ros 2", "info", "topic info", "ros2 topic info", "inspect topic", "show topic details"] input_schema = [("topic_name", "string")] output_schema = {"output": "string"} @@ -695,14 +681,10 @@ class Ros2TopicBwTool(AtomicTool): "topic bandwidth", "ros2 topic bandwidth", "inspect topic bandwidth", - "show topic bandwidth" + "show topic bandwidth", ] - input_schema = [ - ("topic_name", "string"), - ("max_duration", "float?"), - ("max_lines", "int?") - ] + input_schema = [("topic_name", "string"), ("max_duration", "float?"), ("max_lines", "int?")] input_defaults = {"max_duration": 60, "max_lines": 100} output_schema = {"output": "string"} @@ -745,20 +727,8 @@ class Ros2TopicDelayTool(AtomicTool): "Equivalent to `ros2 topic delay`. " "Use when the user wants to show, display, or print the delay of a ROS 2 topic that is publishing." ) - tags = [ - "ros2", - "ros 2", - "delay", - "topic delay", - "ros2 topic delay", - "inspect topic delay", - "show topic delay" - ] - input_schema = [ - ("topic_name", "string"), - ("max_duration", "float?"), - ("max_lines", "int?") - ] + tags = ["ros2", "ros 2", "delay", "topic delay", "ros2 topic delay", "inspect topic delay", "show topic delay"] + input_schema = [("topic_name", "string"), ("max_duration", "float?"), ("max_lines", "int?")] input_defaults = {"max_duration": 60, "max_lines": 100} output_schema = {"output": "string"} @@ -792,25 +762,18 @@ class Ros2TopicHzTool(AtomicTool): description = ( "Stream and observe ROS 2 topic average receiving rate. " "Equivalent to `ros2 topic hz`. " - "Use when the user wants to show, display, or print the average receiving rate of a ROS 2 topic that is publishing." + "Use when the user wants to show, display, or print " + "the average receiving rate of a ROS 2 topic that is publishing." ) tags = [ "ros2", "ros 2", - "hz" - "rate" - "receiving rate" - "average receiving rate" - "topic hz", + "hzratereceiving rateaverage receiving ratetopic hz", "ros2 topic hz", "inspect topic hz", - "show topic hz" - ] - input_schema = [ - ("topic_name", "string"), - ("max_duration", "float?"), - ("max_lines", "int?") + "show topic hz", ] + input_schema = [("topic_name", "string"), ("max_duration", "float?"), ("max_lines", "int?")] input_defaults = {"max_duration": 60, "max_lines": 100} output_schema = {"output": "string"} @@ -884,7 +847,7 @@ class Ros2ServiceInfoTool(AtomicTool): "service info", "ros2 service info", "inspect service", - "show service details" + "show service details", ] input_schema = [("service_name", "string")] output_schema = {"output": "string"} @@ -969,11 +932,7 @@ class Ros2ServiceCallTool(AtomicTool): "call service", "call ros2 service", ] - input_schema = [ - ("service_name", "string"), - ("service_type", "string"), - ("args", "string") - ] + input_schema = [("service_name", "string"), ("service_type", "string"), ("args", "string")] output_schema = {"output": "string"} def run(self, **kwargs): @@ -1008,11 +967,7 @@ class Ros2ServiceEchoTool(AtomicTool): "observe service", "echo service", ] - input_schema = [ - ("service_name", "string"), - ("max_duration", "float?"), - ("max_lines", "int?") - ] + input_schema = [("service_name", "string"), ("max_duration", "float?"), ("max_lines", "int?")] output_schema = {"output": "string"} def run(self, **kwargs): @@ -1076,14 +1031,7 @@ class Ros2ActionInfoTool(AtomicTool): "Equivalent to `ros2 action info`. " "Use when the user wants to list, show, display, or print the information of a ROS 2 action." ) - tags = [ - "ros2", - "ros 2", - "action info", - "ros2 action info", - "inspect action", - "show action details" - ] + tags = ["ros2", "ros 2", "action info", "ros2 action info", "inspect action", "show action details"] input_schema = [("action_name", "string")] output_schema = {"output": "string"} @@ -1139,13 +1087,9 @@ class Ros2ActionSendGoalTool(AtomicTool): "ros2 action send_goal", "send action goal", "call action", - "trigger action" - ] - input_schema = [ - ("action_name", "string"), - ("action_type", "string"), - ("goal_args", "string") + "trigger action", ] + input_schema = [("action_name", "string"), ("action_type", "string"), ("goal_args", "string")] output_schema = {"output": "string"} def run(self, **kwargs): @@ -1207,12 +1151,9 @@ class Ros2ParamGetTool(AtomicTool): "ros2 param get", "get parameter", "read parameter", - "show parameter value" - ] - input_schema = [ - ("node_name", "string"), - ("param_name", "string") + "show parameter value", ] + input_schema = [("node_name", "string"), ("param_name", "string")] output_schema = {"output": "string"} def run(self, **kwargs): @@ -1246,10 +1187,7 @@ class Ros2ParamDescribeTool(AtomicTool): "parameter metadata", "parameter details", ] - input_schema = [ - ("node_name", "string"), - ("param_name", "string") - ] + input_schema = [("node_name", "string"), ("param_name", "string")] output_schema = {"output": "string"} def run(self, **kwargs): @@ -1281,13 +1219,9 @@ class Ros2ParamSetTool(AtomicTool): "ros2 param set", "set parameter", "update parameter", - "change parameter value" - ] - input_schema = [ - ("node_name", "string"), - ("param_name", "string"), - ("set_value", "string") + "change parameter value", ] + input_schema = [("node_name", "string"), ("param_name", "string"), ("set_value", "string")] output_schema = {"output": "string"} def run(self, **kwargs): @@ -1321,12 +1255,9 @@ class Ros2ParamDeleteTool(AtomicTool): "ros2 param delete", "delete parameter", "remove parameter", - "unset parameter" - ] - input_schema = [ - ("node_name", "string"), - ("param_name", "string") + "unset parameter", ] + input_schema = [("node_name", "string"), ("param_name", "string")] output_schema = {"output": "string"} def run(self, **kwargs): @@ -1359,7 +1290,7 @@ class Ros2ParamDumpTool(AtomicTool): "ros2 param dump", "export parameters", "dump parameters", - "save node parameters" + "save node parameters", ] input_schema = [("node_name", "string")] output_schema = {"output": "string"} @@ -1388,12 +1319,9 @@ class Ros2ParamLoadTool(AtomicTool): "ros2 param load", "load parameters", "import parameters", - "load params from file" - ] - input_schema = [ - ("node_name", "string"), - ("file_path", "string") + "load params from file", ] + input_schema = [("node_name", "string"), ("file_path", "string")] output_schema = {"output": "string"} def run(self, **kwargs): @@ -1455,7 +1383,7 @@ class Ros2PkgExecutablesTool(AtomicTool): "ros2 pkg executables", "list package executables", "show executables", - "package binaries" + "package binaries", ] input_schema = [] output_schema = {"output": "string"} @@ -1513,7 +1441,7 @@ class Ros2InterfacePackagesTool(AtomicTool): "interface packages", "ros2 interface packages", "list interface packages", - "packages with interfaces" + "packages with interfaces", ] input_schema = [] output_schema = {"output": "string"} @@ -1536,12 +1464,11 @@ class Ros2InterfacePackageTool(AtomicTool): tags = [ "ros2", "ros 2", - "interfaces" - "interface package", + "interfacesinterface package", "ros2 interface package", "list package interfaces", "show package interfaces", - "package interfaces" + "package interfaces", ] input_schema = [("interface_name", "string")] output_schema = {"output": "string"} @@ -1570,7 +1497,7 @@ class Ros2InterfaceShowTool(AtomicTool): "ros2 interface show", "show interface", "interface definition", - "display interface" + "display interface", ] input_schema = [("interface_name", "string")] output_schema = {"output": "string"} @@ -1799,7 +1726,7 @@ def import_msg_type(type_str: str, node): @vulcanai_tool class Ros2PublishTool(AtomicTool): name = "ros_publish" - #ros2 topic pub chatter std_msgs/msg/String 'data: Hello World' + # ros2 topic pub chatter std_msgs/msg/String 'data: Hello World' # description = ( # "Publish one or more messages to a given ROS 2 topic [topic_name]. " # "Or execute 'ros2 topic pub [topic_name]'. " @@ -1808,7 +1735,8 @@ class Ros2PublishTool(AtomicTool): # "By default it keeps publishing messages until Ctrl+C is pressed. " # "with type 'std_msgs/msg/String' in topic '/chatter' " # "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"}\'' + # 'Example for custom type: msg_type=\'my_pkg/msg/MyMessage\', + # message_data=\'{"index": 1, "message": "Hello"}\'' # ) # tags = ["ros2", "publish", "message", "std_msgs"] tool_description = "Stream and publish ROS 2 topic. " @@ -1823,13 +1751,13 @@ class Ros2PublishTool(AtomicTool): "ros 2", "write", "writer", - "pub", + "pub", "publish", "publisher", "topic pub", "ros2 topic pub", "pub topic", - "write topic", + "write topic", ] # input_schema = [ @@ -1847,7 +1775,7 @@ class Ros2PublishTool(AtomicTool): ("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" ("max_lines", "int?"), # (optional) number of messages to publish - ("max_duration", "int?"), # (optional) stop after this seconds + ("max_duration", "int?"), # (optional) stop after this seconds ] output_schema = { @@ -1956,7 +1884,9 @@ def run(self, **kwargs): console.logger.log_tool("[tool]Ctrl+C received:[/tool] stopping publish...", tool_name=self.name) break if max_lines is not None and published_count >= max_lines: - console.logger.log_tool(f"[tool]Stopping:[/tool] Reached max_lines = {max_lines}", tool_name=self.name) + console.logger.log_tool( + f"[tool]Stopping:[/tool] Reached max_lines = {max_lines}", tool_name=self.name + ) break if max_duration is not None and (time.monotonic() - start_time) >= max_duration: console.logger.log_tool( @@ -2052,13 +1982,13 @@ class Ros2SubscribeTool(AtomicTool): "inspect topic", "show topic", "display topic", - "print topic" + "print topic", ] input_schema = [ - ("topic", "string"), # topic name - ("max_duration", "int?"), # (optional) stop after this seconds - ("max_lines", "int?"), # (optional) stop after this number of messages + ("topic", "string"), # topic name + ("max_duration", "int?"), # (optional) stop after this seconds + ("max_lines", "int?"), # (optional) stop after this number of messages ] input_defaults = {"max_duration": 60, "max_lines": 100} output_schema = { diff --git a/src/vulcanai/tools/tool_registry.py b/src/vulcanai/tools/tool_registry.py index 4929f2d..b2349e5 100644 --- a/src/vulcanai/tools/tool_registry.py +++ b/src/vulcanai/tools/tool_registry.py @@ -290,7 +290,7 @@ def top_k(self, query: str, k: int = 5, validation: bool = False) -> list[ITool] def _doc(tool: ITool) -> str: # Text used for embeddings # TODO. danip - #return f"{tool.name}\n{tool.description}\n{tool.tags}\n{tool.input_schema}\n" + # return f"{tool.name}\n{tool.description}\n{tool.tags}\n{tool.input_schema}\n" input_defaults = getattr(tool, "input_defaults", {}) or {} inputs = [] for key, type_name in tool.input_schema: @@ -324,7 +324,7 @@ def group_tool_names(self, tool_names: list) -> list: if group not in group_tools: group_tools[group] = [] order.append(("group", group)) - display = name[len(group) + 1:] if name.startswith(group + "_") else name + display = name[len(group) + 1 :] if name.startswith(group + "_") else name group_tools[group].append(display) else: order.append(("tool", name)) @@ -342,13 +342,9 @@ def _log_tools_grouped(self, tool_names: list): grouped = self.group_tool_names(tool_names) for entry_name, subtools in grouped: if subtools is None: - self.logger.log_registry( - f"Registered tool: [registry]{entry_name}[/registry]" - ) + self.logger.log_registry(f"Registered tool: [registry]{entry_name}[/registry]") else: - self.logger.log_registry( - f"Registered group of tools: [registry]{entry_name}[/registry]" - ) + self.logger.log_registry(f"Registered group of tools: [registry]{entry_name}[/registry]") for subtool in subtools: self.logger.log_registry( f"Registered tool: [registry]{subtool}[/registry]", From 710061b9294c972a077d9d5974ec3e94fec863a7 Mon Sep 17 00:00:00 2001 From: danipiza Date: Tue, 28 Apr 2026 12:56:41 +0200 Subject: [PATCH 35/55] [#23897] Upgraded ROS CLI publish tool Signed-off-by: danipiza --- src/vulcanai/console/console.py | 9 +- src/vulcanai/core/manager.py | 4 + src/vulcanai/core/manager_iterator.py | 4 + src/vulcanai/core/manager_plan.py | 4 + src/vulcanai/tools/default_tools.py | 281 ++++---------------------- src/vulcanai/tools/tools.py | 6 +- 6 files changed, 66 insertions(+), 242 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index 491a82d..fdb71f4 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -642,6 +642,9 @@ def cmd_help(self, _) -> None: self.logger.log_console(table, "console") def cmd_tools(self, _) -> None: + def get_tool_summary(tool) -> str: + return getattr(tool, "tool_description", None) or tool.description + tmp_msg = f"(current index k={self.manager.k})" tool_msg = ("_" * len(tmp_msg)) + "\n" tool_msg += "Available tools:\n" @@ -653,17 +656,17 @@ def cmd_tools(self, _) -> None: for entry_name, subtools in grouped: if subtools is None: tool = self.manager.registry.tools[entry_name] - tool_msg += f"{tool.name}: {tool.tool_description}\n" + tool_msg += f"{tool.name}: {get_tool_summary(tool)}\n" else: tool_msg += f"{entry_name}:\n" for subtool in subtools: full_name = f"{entry_name}_{subtool}" tool = self.manager.registry.tools[full_name] - tool_msg += f" - {subtool}: {tool.tool_description}\n" + tool_msg += f" - {subtool}: {get_tool_summary(tool)}\n" if "help" in self.manager.registry.tools: help_tool = self.manager.registry.tools["help"] - tool_msg += f"- {help_tool.name}: {help_tool.tool_description}\n" + tool_msg += f"- {help_tool.name}: {get_tool_summary(help_tool)}\n" self.logger.log_console(tool_msg, "console") diff --git a/src/vulcanai/core/manager.py b/src/vulcanai/core/manager.py index 5ff477a..562a60a 100644 --- a/src/vulcanai/core/manager.py +++ b/src/vulcanai/core/manager.py @@ -271,6 +271,10 @@ def _get_prompt_template(self) -> str: If an input is marked optional and the user did not ask for a specific value, omit that argument instead of inventing one. Do not guess placeholder values such as `1`, `1.0`, `10`, or empty strings for optional inputs. +When the user explicitly provides identifiers or counts (for example topic names, service names, node names, +file paths, or the number of repetitions), copy those values exactly into the tool arguments. +If the user asks to repeat the same action N times and a tool exposes an internal count or limit argument +(for example `max_lines`), prefer a single Step using that argument instead of duplicating the same tool call. {user_context} ## Available tools: {tools_text} diff --git a/src/vulcanai/core/manager_iterator.py b/src/vulcanai/core/manager_iterator.py index 1bba3ae..2f72adb 100644 --- a/src/vulcanai/core/manager_iterator.py +++ b/src/vulcanai/core/manager_iterator.py @@ -355,6 +355,10 @@ def _get_prompt_template(self) -> str: - Add only optional execution control parameters if strictly necessary or requested by the user. - If an input is marked optional and the user did not ask for a specific value, omit that argument instead of inventing one. +- When the user explicitly provides identifiers or counts (for example topic names, service names, node names, + file paths, or the number of repetitions), copy those values exactly into the tool arguments. +- If the user asks to repeat the same action N times and a tool exposes an internal count or limit argument + (for example `max_lines`), prefer a single Step using that argument instead of duplicating the same tool call. - Use "{{{{bb.tool_name.key}}}}" to pass outputs from previous steps if relevant. For example, if tool 'detect_object' outputs {{"pose": [1.0, 2.0]}}, you can pass it to navigate as: diff --git a/src/vulcanai/core/manager_plan.py b/src/vulcanai/core/manager_plan.py index a0d986c..a32a374 100644 --- a/src/vulcanai/core/manager_plan.py +++ b/src/vulcanai/core/manager_plan.py @@ -57,6 +57,10 @@ def _get_prompt_template(self) -> str: If an input is marked optional and the user did not ask for a specific value, omit that argument instead of inventing one. Do not guess placeholder values such as `1`, `1.0`, `10`, or empty strings for optional inputs. +When the user explicitly provides identifiers or counts (for example topic names, service names, node names, +file paths, or the number of repetitions), copy those values exactly into the tool arguments. +If the user asks to repeat the same action N times and a tool exposes an internal count or limit argument +(for example `max_lines`), prefer a single Step using that argument instead of duplicating the same tool call. {user_context} ## Available tools: {tools_text} diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index 11b7c5e..b1aab12 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -1506,199 +1506,6 @@ def run(self, **kwargs): console = _require_console(self.bb) return _run_ros2_interface_command(console, self.name, "show", interface_name=kwargs.get("interface_name")) - -# --------------------------------------------------------------------------- -# Backward-compatible legacy wrappers (hidden from planner) -# --------------------------------------------------------------------------- - - -# @vulcanai_tool -# class Ros2NodeTool(AtomicTool): -# name = "ros2_node" -# planner_visible = False -# description = ( -# "[DEPRECATED] Backward-compatible wrapper for ROS2 node commands. " -# "Prefer strict tools: ros2_node_list and ros2_node_info." -# ) -# tags = ["ros2", "nodes", "cli", "info", "diagnostics"] -# input_schema = [("command", "string"), ("node_name", "string?")] -# output_schema = {"output": "string"} - -# def run(self, **kwargs): -# console = _require_console(self.bb) -# return _run_ros2_node_command( -# console, -# self.name, -# kwargs.get("command"), -# node_name=kwargs.get("node_name", ""), -# ) - - -# @vulcanai_tool -# class Ros2TopicTool(AtomicTool): -# name = "ros2_topic" -# planner_visible = False -# description = ( -# "[DEPRECATED] Backward-compatible wrapper for ROS2 topic commands. " -# "Prefer strict tools: ros2_topic_*." -# ) -# tags = ["ros2", "topics", "cli", "info"] -# input_schema = [ -# ("command", "string"), -# ("topic_name", "string?"), -# ("msg_type", "string?"), -# ("max_duration", "number?"), -# ("max_lines", "int?"), -# ] -# output_schema = {"output": "string"} - -# def run(self, **kwargs): -# console = _require_console(self.bb) -# return _run_ros2_topic_command( -# console, -# self.name, -# kwargs.get("command"), -# topic_name=kwargs.get("topic_name"), -# msg_type=kwargs.get("msg_type"), -# max_duration=kwargs.get("max_duration"), -# max_lines=kwargs.get("max_lines"), -# ) - - -# @vulcanai_tool -# class Ros2ServiceTool(AtomicTool): -# name = "ros2_service" -# planner_visible = False -# description = ( -# "[DEPRECATED] Backward-compatible wrapper for ROS2 service commands. " -# "Prefer strict tools: ros2_service_*." -# ) -# tags = ["ros2", "services", "cli", "info", "call"] -# input_schema = [ -# ("command", "string"), -# ("service_name", "string?"), -# ("service_type", "string?"), -# ("call", "bool?"), -# ("args", "string?"), -# ("max_duration", "number?"), -# ("max_lines", "int?"), -# ] -# output_schema = {"output": "string"} - -# def run(self, **kwargs): -# console = _require_console(self.bb) -# return _run_ros2_service_command( -# console, -# self.name, -# kwargs.get("command"), -# service_name=kwargs.get("service_name"), -# service_type=kwargs.get("service_type"), -# call_args=kwargs.get("args"), -# max_duration=kwargs.get("max_duration"), -# max_lines=kwargs.get("max_lines"), -# ) - - -# @vulcanai_tool -# class Ros2ActionTool(AtomicTool): -# name = "ros2_action" -# planner_visible = False -# description = ( -# "[DEPRECATED] Backward-compatible wrapper for ROS2 action commands. " -# "Prefer strict tools: ros2_action_*." -# ) -# tags = ["ros2", "actions", "cli", "info", "goal"] -# input_schema = [ -# ("command", "string"), -# ("action_name", "string?"), -# ("action_type", "string?"), -# ("send_goal", "bool?"), -# ("goal_args", "string?"), -# ] -# output_schema = {"output": "string"} - -# def run(self, **kwargs): -# console = _require_console(self.bb) -# return _run_ros2_action_command( -# console, -# self.name, -# kwargs.get("command"), -# action_name=kwargs.get("action_name"), -# action_type=kwargs.get("action_type"), -# goal_args=kwargs.get("goal_args"), -# ) - - -# @vulcanai_tool -# class Ros2ParamTool(AtomicTool): -# name = "ros2_param" -# planner_visible = False -# description = ( -# "[DEPRECATED] Backward-compatible wrapper for ROS2 param commands. " -# "Prefer strict tools: ros2_param_*." -# ) -# tags = ["ros2", "param", "parameters", "cli"] -# input_schema = [ -# ("command", "string"), -# ("param_name", "string?"), -# ("node_name", "string?"), -# ("set_value", "string?"), -# ("file_path", "string?"), -# ] -# output_schema = {"output": "string"} - -# def run(self, **kwargs): -# console = _require_console(self.bb) -# return _run_ros2_param_command( -# console, -# self.name, -# kwargs.get("command"), -# node_name=kwargs.get("node_name"), -# param_name=kwargs.get("param_name"), -# set_value=kwargs.get("set_value"), -# file_path=kwargs.get("file_path"), -# ) - - -# @vulcanai_tool -# class Ros2PkgTool(AtomicTool): -# name = "ros2_pkg" -# planner_visible = False -# description = ( -# "[DEPRECATED] Backward-compatible wrapper for ROS2 pkg commands. " -# "Prefer strict tools: ros2_pkg_list and ros2_pkg_executables." -# ) -# tags = ["ros2", "pkg", "packages", "cli", "introspection"] -# input_schema = [("command", "string")] -# output_schema = {"output": "string"} - -# def run(self, **kwargs): -# console = _require_console(self.bb) -# return _run_ros2_pkg_command(console, self.name, kwargs.get("command")) - - -# @vulcanai_tool -# class Ros2InterfaceTool(AtomicTool): -# name = "ros2_interface" -# planner_visible = False -# description = ( -# "[DEPRECATED] Backward-compatible wrapper for ROS2 interface commands. " -# "Prefer strict tools: ros2_interface_*." -# ) -# tags = ["ros2", "interface", "msg", "srv", "cli", "introspection"] -# input_schema = [("command", "string"), ("interface_name", "string?")] -# output_schema = {"output": "string"} - -# def run(self, **kwargs): -# console = _require_console(self.bb) -# return _run_ros2_interface_command( -# console, -# self.name, -# kwargs.get("command"), -# interface_name=kwargs.get("interface_name"), -# ) - - def import_msg_type(type_str: str, node): """ Dynamically import a ROS 2 message type from its string identifier. @@ -1726,25 +1533,14 @@ def import_msg_type(type_str: str, node): @vulcanai_tool class Ros2PublishTool(AtomicTool): name = "ros_publish" - # ros2 topic pub chatter std_msgs/msg/String 'data: Hello World' - # description = ( - # "Publish one or more messages to a given ROS 2 topic [topic_name]. " - # "Or execute 'ros2 topic pub [topic_name]'. " - # "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 it keeps publishing messages until Ctrl+C is pressed. " - # "with type 'std_msgs/msg/String' in topic '/chatter' " - # "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"] tool_description = "Stream and publish ROS 2 topic. " description = ( "Stream and publish ROS 2 topic. " "Equivalent to `ros2 topic pub`. " "Use when the user wants to write or publish a message in a ROS 2 topic. " - "If optional limits are omitted, it defaults to 60 seconds and 100 lines." + "To publish N messages, call this tool once with `max_lines=N` instead of repeating the tool N times. " + "If optional limits are omitted, it defaults to 60 seconds, 100 messages, " + "and `std_msgs/msg/String` with a 0.1 second period." ) tags = [ "ros2", @@ -1758,25 +1554,27 @@ class Ros2PublishTool(AtomicTool): "ros2 topic pub", "pub topic", "write topic", + "repeat", + "count", + "times", ] - # 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" - # ("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 - # ] - input_schema = [ - ("topic", "string"), # e.g. "/chatter" + # ros2 topic pub + ("topic", "string"), # e.g. "/chatter" + ("msg_type", "string?"), # (optional) e.g. "std_msgs/msg/String" or "my_pkg/msg/CustomMsg" ("message_data", "string?"), # (optional) payload - string for std_msgs/String or JSON for custom types - ("msg_type", "string?"), # (optional) e.g. "std_msgs/msg/String" or "my_pkg/msg/CustomMsg" - ("max_lines", "int?"), # (optional) number of messages to publish - ("max_duration", "int?"), # (optional) stop after this seconds + # Configuration of the publisher/execution + ("max_lines", "int?"), # (optional) number of messages to publish + ("max_duration", "float?"), # (optional) stop after this seconds + ("period_sec", "float?"), # (optional) delay between publishes (in seconds) ] + input_defaults = { + "msg_type": "std_msgs/msg/String", + "max_lines": 100, + "max_duration": 60, + "period_sec": 0.1, + } output_schema = { "published": "bool", @@ -1828,22 +1626,34 @@ def run(self, **kwargs): 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") + msg_type_str = kwargs.get("msg_type", self.input_defaults["msg_type"]) - max_duration = kwargs.get("max_duration", None) - if not isinstance(max_duration, (int, float)) or max_duration <= 0: - max_duration = None + max_duration = kwargs.get("max_duration", self.input_defaults["max_duration"]) + try: + max_duration = float(max_duration) if max_duration is not None else self.input_defaults["max_duration"] + except (TypeError, ValueError): + max_duration = self.input_defaults["max_duration"] + if max_duration <= 0: + max_duration = self.input_defaults["max_duration"] - max_lines = kwargs.get("max_lines", None) - if max_lines is not None and not isinstance(max_lines, int): - max_lines = None + max_lines = kwargs.get("max_lines", self.input_defaults["max_lines"]) + try: + max_lines = int(max_lines) if max_lines is not None else self.input_defaults["max_lines"] + except (TypeError, ValueError): + max_lines = self.input_defaults["max_lines"] - period_sec = kwargs.get("period_sec", 0.1) + period_sec = kwargs.get("period_sec", self.input_defaults["period_sec"]) + try: + period_sec = float(period_sec) if period_sec is not None else self.input_defaults["period_sec"] + except (TypeError, ValueError): + period_sec = self.input_defaults["period_sec"] + if period_sec < 0.0: + period_sec = self.input_defaults["period_sec"] qos_depth = 10 if console is None: - print("[ERROR] Console not is None") + print("[ERROR] Console node is None") return result @@ -1952,11 +1762,6 @@ def run(self, **kwargs): print_tool_output(console, result["output"], self.name) - # if panel_enabled: - # console.logger.log_msg(result["output"], color="gray") - # console.logger.log_msg("\n") - # else: - # print_tool_output(console, result["output"], self.name) return result @@ -1986,9 +1791,9 @@ class Ros2SubscribeTool(AtomicTool): ] input_schema = [ - ("topic", "string"), # topic name - ("max_duration", "int?"), # (optional) stop after this seconds - ("max_lines", "int?"), # (optional) stop after this number of messages + ("topic", "string"), # topic name + ("max_duration", "int?"), # (optional) stop after this seconds + ("max_lines", "int?"), # (optional) stop after this number of messages ] input_defaults = {"max_duration": 60, "max_lines": 100} output_schema = { diff --git a/src/vulcanai/tools/tools.py b/src/vulcanai/tools/tools.py index 53fe8c7..eb31fe2 100644 --- a/src/vulcanai/tools/tools.py +++ b/src/vulcanai/tools/tools.py @@ -24,7 +24,11 @@ class ITool(ABC): # Name given to the tool name: str - # Brief description of the tool's purpose + # Optional brief description of the tools. + # Used in the '/tools' command to display the info for the user. If this variable is not present in the tool + # the command fallback to the 'description' variable used by the LLM. + tool_description: Optional[str] = "" + # Detailed tool description used by the LLM during plan generation. description: str # List of keywords associated with the tool tags: list[str] = [] From 03e8c1a65e49f4d8ab40b6e57687ffcbdaf4be60 Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 7 May 2026 12:14:32 +0200 Subject: [PATCH 36/55] [#23897] Updated ROS2 Publish tool logs Signed-off-by: danipiza --- src/vulcanai/core/executor.py | 34 +++++++++++++++++++++++++++++----- src/vulcanai/tools/tools.py | 3 ++- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/vulcanai/core/executor.py b/src/vulcanai/core/executor.py index e9000ab..884239a 100644 --- a/src/vulcanai/core/executor.py +++ b/src/vulcanai/core/executor.py @@ -153,7 +153,8 @@ def _run_step(self, step: Step, bb: Blackboard, parallel: bool = False) -> bool: # Bind args with blackboard placeholders tool = self.registry.tools.get(step.tool) input_schema = tool.input_schema if tool else [] - args = self._bind_args(step.args, input_schema, bb) + input_defaults = getattr(tool, "input_defaults", {}) if tool else {} + args = self._bind_args(step.args, input_schema, bb, input_defaults) attempts = step.retry + 1 if step.retry else 1 for i in range(attempts): @@ -216,13 +217,36 @@ def _make_bb_subs(self, expr: str, bb: Blackboard) -> str: self.logger.log_executor(f"Blackboard substitution failed: {expr} ({e})", error=True) return expr - def _bind_args(self, args: List[ArgValue], schema: List[Tuple[str, str]], bb: Blackboard) -> List[ArgValue]: - """Replace {{bb.key}} placeholders with actual values.""" - bound = [] + def _bind_args( + self, + args: List[ArgValue], + schema: List[Tuple[str, str]], + bb: Blackboard, + input_defaults: Optional[Dict[str, Any]] = None, + ) -> List[ArgValue]: + """Replace {{bb.key}} placeholders with actual values and fill omitted defaults in schema order""" + + bound_by_key = {} for arg in args: bound_arg = self._make_bb_subs(arg.val, bb) arg.val = self._coerce_to_schema(schema, arg.key, bound_arg) - bound.append(arg) + bound_by_key[arg.key] = arg + + bound = [] + for key, _ in schema: + if key in bound_by_key: + bound.append(bound_by_key.pop(key)) + continue + if not input_defaults or key not in input_defaults: + continue + default_value = input_defaults[key] + if isinstance(default_value, str): + default_value = self._make_bb_subs(default_value, bb) + bound.append(ArgValue(key=key, val=self._coerce_to_schema(schema, key, default_value))) + + # Preserve any unexpected args after the schema-ordered ones + bound.extend(bound_by_key.values()) + return bound def _get_from_bb(self, path: str, bb: Blackboard) -> Any: diff --git a/src/vulcanai/tools/tools.py b/src/vulcanai/tools/tools.py index eb31fe2..4e982bf 100644 --- a/src/vulcanai/tools/tools.py +++ b/src/vulcanai/tools/tools.py @@ -36,7 +36,8 @@ class ITool(ABC): # Only used for documentation and LLM prompt generation. input_schema: List[Tuple[str, str]] = [] # List of (key, type) pairs, simulating a ArgValue list # Optional default values for documented inputs. - # These are metadata for prompt generation and tool discovery only. + # Used by prompt generation, tool discovery, and execution when optional + # arguments are omitted from a plan input_defaults: Dict[str, Any] = {} output_schema: Dict[str, str] = {} # Validation tool From c3c205961f10d3ad7788c92d625b4bf6d4a321ff Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 7 May 2026 12:51:43 +0200 Subject: [PATCH 37/55] [#23897] Cleaned PR Signed-off-by: danipiza --- src/vulcanai/console/console.py | 1 - src/vulcanai/core/manager.py | 12 ++---------- src/vulcanai/core/manager_iterator.py | 24 ++---------------------- src/vulcanai/tools/tool_registry.py | 2 -- 4 files changed, 4 insertions(+), 35 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index fdb71f4..0ad24b7 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -388,7 +388,6 @@ async def bootstrap(self) -> None: self.logger.log_console("Clipboard: select text and press F4 to copy. Use Ctrl+V or middle-click to paste.") self.logger.log_console("Use '/exit' or press 'Ctrl+Q' to quit.") - # TODO. danip # Anchor the main log at the bottom after all startup output has been queued, # so the user sees the latest line (and input prompt) without manual scrolling. if self.main_pannel is not None: diff --git a/src/vulcanai/core/manager.py b/src/vulcanai/core/manager.py index 562a60a..5bf5dda 100644 --- a/src/vulcanai/core/manager.py +++ b/src/vulcanai/core/manager.py @@ -170,16 +170,8 @@ def _build_prompt(self, user_text: str, ctx: Dict[str, Any]) -> Tuple[str, str]: if not tools: self.logger.log_manager("No tools available in the registry.", error=True) return "", "" - tool_descriptions = [] - for tool in tools: - tool_descriptions.append( - f"- *{tool.name}*: {tool.description}\n" - f" Inputs: {tool.input_schema}\n" - f" Outputs: {tool.output_schema}\n" - ) - tools_text = "\n".join(tool_descriptions) - # TODO. danip, better performance? - # tools_text = self.render_tool_descriptions(tools) + + tools_text = self.render_tool_descriptions(tools) user_context = self._parse_user_context() user_prompt = "User request:\n" + user_text diff --git a/src/vulcanai/core/manager_iterator.py b/src/vulcanai/core/manager_iterator.py index 2f72adb..3297ef7 100644 --- a/src/vulcanai/core/manager_iterator.py +++ b/src/vulcanai/core/manager_iterator.py @@ -211,19 +211,9 @@ def _build_prompt(self, user_text: str, ctx: Dict[str, Any]) -> Tuple[str, str]: if not tools: self.logger.log_manager("No tools available in the registry.", error=True) return "", "" - tool_descriptions = [] - for tool in tools: - tool_descriptions.append( - f"- *{tool.name}*: {tool.description}\n" - f" Inputs: {tool.input_schema}\n" - f" Outputs: {tool.output_schema}\n" - ) - tools_text = "\n".join(tool_descriptions) - # TODO. danip, better performance? + tools_text = self.render_tool_descriptions(tools) - bb_snapshot = self.bb.text_snapshot() - user_context = self._parse_user_context() system_prompt = self._get_prompt_template() @@ -231,7 +221,6 @@ def _build_prompt(self, user_text: str, ctx: Dict[str, Any]) -> Tuple[str, str]: tools_text=tools_text, user_context=user_context, ) - user_prompt = ( "## User Request: " + user_text + "\nContext:\n" + self._get_iter_context().format(bb_snapshot=bb_snapshot) ) @@ -246,16 +235,7 @@ def _build_goal_prompt(self, user_text: str, ctx: Dict[str, Any]) -> Tuple[str, """ tools = self.registry.top_k(user_text, self.k, validation=True) if tools: - tool_descriptions = [] - for tool in tools: - tool_descriptions.append( - f"- *{tool.name}*: {tool.description}\n" - f" Inputs: {tool.input_schema}\n" - f" Outputs: {tool.output_schema}\n" - ) - tools_text = "\n".join(tool_descriptions) - # TODO. danip, better performance? - # tools_text = self.render_tool_descriptions(tools) + tools_text = self.render_tool_descriptions(tools) else: tools_text = "No available tools. Use blackboard" diff --git a/src/vulcanai/tools/tool_registry.py b/src/vulcanai/tools/tool_registry.py index b2349e5..9d83846 100644 --- a/src/vulcanai/tools/tool_registry.py +++ b/src/vulcanai/tools/tool_registry.py @@ -289,8 +289,6 @@ def top_k(self, query: str, k: int = 5, validation: bool = False) -> list[ITool] @staticmethod def _doc(tool: ITool) -> str: # Text used for embeddings - # TODO. danip - # return f"{tool.name}\n{tool.description}\n{tool.tags}\n{tool.input_schema}\n" input_defaults = getattr(tool, "input_defaults", {}) or {} inputs = [] for key, type_name in tool.input_schema: From c6eab85cac4c6690c62f4ab06a9eecc6ec02fba1 Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 7 May 2026 12:53:22 +0200 Subject: [PATCH 38/55] [#23897] Fixed Ruff Signed-off-by: danipiza --- src/vulcanai/core/manager_iterator.py | 2 +- src/vulcanai/tools/default_tools.py | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/vulcanai/core/manager_iterator.py b/src/vulcanai/core/manager_iterator.py index 3297ef7..ea23b60 100644 --- a/src/vulcanai/core/manager_iterator.py +++ b/src/vulcanai/core/manager_iterator.py @@ -211,7 +211,7 @@ def _build_prompt(self, user_text: str, ctx: Dict[str, Any]) -> Tuple[str, str]: if not tools: self.logger.log_manager("No tools available in the registry.", error=True) return "", "" - + tools_text = self.render_tool_descriptions(tools) bb_snapshot = self.bb.text_snapshot() user_context = self._parse_user_context() diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index b1aab12..5158c74 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -1506,6 +1506,7 @@ def run(self, **kwargs): console = _require_console(self.bb) return _run_ros2_interface_command(console, self.name, "show", interface_name=kwargs.get("interface_name")) + def import_msg_type(type_str: str, node): """ Dynamically import a ROS 2 message type from its string identifier. @@ -1561,13 +1562,13 @@ class Ros2PublishTool(AtomicTool): input_schema = [ # ros2 topic pub - ("topic", "string"), # e.g. "/chatter" - ("msg_type", "string?"), # (optional) e.g. "std_msgs/msg/String" or "my_pkg/msg/CustomMsg" + ("topic", "string"), # e.g. "/chatter" + ("msg_type", "string?"), # (optional) e.g. "std_msgs/msg/String" or "my_pkg/msg/CustomMsg" ("message_data", "string?"), # (optional) payload - string for std_msgs/String or JSON for custom types # Configuration of the publisher/execution - ("max_lines", "int?"), # (optional) number of messages to publish - ("max_duration", "float?"), # (optional) stop after this seconds - ("period_sec", "float?"), # (optional) delay between publishes (in seconds) + ("max_lines", "int?"), # (optional) number of messages to publish + ("max_duration", "float?"), # (optional) stop after this seconds + ("period_sec", "float?"), # (optional) delay between publishes (in seconds) ] input_defaults = { "msg_type": "std_msgs/msg/String", @@ -1791,9 +1792,9 @@ class Ros2SubscribeTool(AtomicTool): ] input_schema = [ - ("topic", "string"), # topic name - ("max_duration", "int?"), # (optional) stop after this seconds - ("max_lines", "int?"), # (optional) stop after this number of messages + ("topic", "string"), # topic name + ("max_duration", "int?"), # (optional) stop after this seconds + ("max_lines", "int?"), # (optional) stop after this number of messages ] input_defaults = {"max_duration": 60, "max_lines": 100} output_schema = { From cb96b6d7c6457b0f66cd86d5ec35e46663f0c442 Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 7 May 2026 12:57:21 +0200 Subject: [PATCH 39/55] [#23897] Fixed Ruff Signed-off-by: danipiza --- tests/unittest/test_default_tools.py | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/tests/unittest/test_default_tools.py b/tests/unittest/test_default_tools.py index a8fdbdc..116f992 100644 --- a/tests/unittest/test_default_tools.py +++ b/tests/unittest/test_default_tools.py @@ -1045,6 +1045,7 @@ def test_param_set(self): set_value="123", ) self.assertIn("Set parameter", result["output"]) + # ------------------------------------------------------------------------- # Tests — ros2 param delete # ------------------------------------------------------------------------- @@ -1071,6 +1072,7 @@ def test_param_delete(self): node_name="/parameter_blackboard", param_name=param_name, ) + # ------------------------------------------------------------------------- # Tests — ros2 param dump [file_path] # ------------------------------------------------------------------------- @@ -1473,22 +1475,6 @@ def test_subscribe_with_max_lines(self): proc = start_background_publisher(topic, message="hello_subscribe", rate=10) self._bg_publishers.append(proc) - result = self._run_subscribe_threaded(topic=topic, max_duration=5.0, max_lines=5) - - self.assertEqual("True", result["subscribed"]) - self.assertEqual(5, result["count"]) - self.assertEqual(topic, result["topic"]) - self.assertIn("I heard", result["output"]) - - # ------------------------------------------------------------------------- - # Tests — Subscribe max_duration - # ------------------------------------------------------------------------- - def test_subscribe_with_max_lines(self): - """`run` should receive live topic data and count lines.""" - topic = "/vulcan_test_subscribe" - proc = start_background_publisher(topic, message="hello_subscribe", rate=1) - self._bg_publishers.append(proc) - result = self._run_subscribe_threaded(topic=topic, max_duration=5.0, max_lines=50) self.assertEqual("True", result["subscribed"]) From 237a622b60ee7c87db8e9cbf1314dedc6c8da348 Mon Sep 17 00:00:00 2001 From: danipiza Date: Tue, 26 May 2026 08:04:21 +0200 Subject: [PATCH 40/55] [#23897] Applied revision Signed-off-by: danipiza --- src/vulcanai/console/console.py | 88 ++++++++++++++++++++++-- src/vulcanai/core/manager.py | 97 +++++++++++++++++++++++++-- src/vulcanai/core/manager_iterator.py | 6 +- src/vulcanai/core/manager_plan.py | 3 +- src/vulcanai/tools/default_tools.py | 66 ++++++++++-------- src/vulcanai/tools/tool_registry.py | 10 ++- src/vulcanai/tools/tools.py | 3 +- src/vulcanai/tools/utils.py | 26 ++++++- 8 files changed, 249 insertions(+), 50 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index 0ad24b7..798b970 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -60,6 +60,43 @@ def write(self, msg: str, color: str = "") -> None: self.console.add_line(msg, color) +def ensure_ros_parameter_services(node): + """Attach ROS parameter services to a node when they were disabled at construction.""" + if node is None: + return None + + parameter_service = getattr(node, "_parameter_service", None) + if parameter_service is not None: + return parameter_service + + from rclpy.parameter_service import ParameterService + + parameter_service = ParameterService(node) + setattr(node, "_parameter_service", parameter_service) + return parameter_service + + +def get_ros_spin_lock(node) -> threading.Lock | None: + """Return a shared spin lock stored on the node.""" + if node is None: + return None + + spin_lock = getattr(node, "_vulcanai_spin_lock", None) + if spin_lock is None: + spin_lock = threading.Lock() + setattr(node, "_vulcanai_spin_lock", spin_lock) + return spin_lock + + +def has_ros2_runtime() -> bool: + """Return True when the ROS 2 Python runtime is available.""" + try: + import rclpy # noqa: F401 + except ImportError: + return False + return True + + class VulcanConsole(App): DEFAULT_CMD_PLACEHOLDER = "> " # Extra info when a streaming process is active @@ -242,6 +279,7 @@ def __init__( self._gnome_profile_schema: str | None = None self._gnome_scrollbar_policy_backup: str | None = None + self._main_node_spin_task: asyncio.Task | None = None async def on_mouse_down(self, event: MouseEvent) -> None: """ @@ -365,6 +403,9 @@ async def bootstrap(self) -> None: except ImportError: self.logger.log_console("Unable to load ROS 2 default node for default tools.") + if has_ros2_runtime(): + self._enable_main_node_ros_support() + # -- Register tools (file I/O - run in executor) -- # File paths tools for tool_file_path in self.register_from_file: @@ -485,6 +526,45 @@ def _format_blackboard_subset(self, bb, keys: list[str]) -> str: # Keep output on a single line while avoiding Textual tag parsing issues. return repr(subset).replace("<", "'").replace(">", "'") + def _enable_main_node_ros_support(self) -> None: + """Ensure the shared ROS node is responsive to external ROS CLI calls.""" + if self.main_node is None or not has_ros2_runtime(): + return + + get_ros_spin_lock(self.main_node) + + try: + ensure_ros_parameter_services(self.main_node) + except Exception as e: + self.logger.log_console( + f"Unable to attach ROS parameter services to the shared node: {e}", + ) + + if self._main_node_spin_task is None or self._main_node_spin_task.done(): + self._main_node_spin_task = asyncio.create_task(self._spin_main_node_loop()) + + async def _spin_main_node_loop(self) -> None: + """Keep the shared ROS node serviced so CLI tools do not time out on it.""" + try: + import rclpy + except ImportError: + return + + while self.main_node is not None: + try: + if rclpy.ok(): + spin_lock = get_ros_spin_lock(self.main_node) + if spin_lock is not None and spin_lock.acquire(blocking=False): + try: + rclpy.spin_once(self.main_node, timeout_sec=0.0) + finally: + spin_lock.release() + except Exception: + # The node may be shutting down while the console exits. + break + + await asyncio.sleep(0.05) + def _apply_history_to_input(self) -> None: """ Function used to apply the current history index to the input box. @@ -863,7 +943,7 @@ def show_subprocess_panel(self, show_notice: bool = True) -> None: self._stream_panel_visible = True cmd = self.query_one("#cmd", Input) cmd.placeholder = self.STREAM_CMD_PLACEHOLDER - if follow_main_output and self.main_pannel is not None: + if follow_main_output: self.main_pannel.scroll_end(animate=False, immediate=True, x_axis=False) self.call_after_refresh(self.main_pannel.scroll_end, animate=False, immediate=True, x_axis=False) self.call_later(self.main_pannel.scroll_end, animate=False, immediate=True, x_axis=False) @@ -958,7 +1038,7 @@ def hide_subprocess_panel(self) -> None: self.write_deferred_main_output() cmd = self.query_one("#cmd", Input) cmd.placeholder = self.DEFAULT_CMD_PLACEHOLDER - if follow_main_output and self.main_pannel is not None: + if follow_main_output: self.main_pannel.scroll_end(animate=False, immediate=True, x_axis=False) self.call_after_refresh(self.main_pannel.scroll_end, animate=False, immediate=True, x_axis=False) @@ -1010,8 +1090,8 @@ def add_line(self, input: str, color: str = "", subprocess_flag: bool = False) - target_panel = self.main_pannel if self._route_logs_to_stream_panel > 0 and self.stream_pannel is not None: if not self._stream_panel_visible: - # If somehow the streaming panel is closed without the user - # reopen it before routing output to the stream area + # If the streaming panel did not showed up + # reopen it before routing the output to the stream area self.show_subprocess_panel(show_notice=False) if self._stream_panel_visible: target_panel = self.stream_pannel diff --git a/src/vulcanai/core/manager.py b/src/vulcanai/core/manager.py index 5bf5dda..41a8cde 100644 --- a/src/vulcanai/core/manager.py +++ b/src/vulcanai/core/manager.py @@ -25,6 +25,21 @@ class ToolManager: """Manages the LLM Agent and calls the executor with the LLM output.""" + _literal_identifier_input_keys = { + "action_name", + "file_path", + "frame_id", + "interface_name", + "msg_type", + "node_name", + "package_name", + "param_name", + "service_name", + "topic_name", + "type_name", + } + _literal_identifier_input_suffixes = ("_name", "_path", "_type") + def __init__( self, model: str, @@ -131,11 +146,30 @@ def get_plan_from_user_request(self, user_text: str, context: Dict[str, Any] = N # Images should be a list of paths images = context["images"] + is_direct_ros2_cli_request = self._is_direct_ros2_cli_request(user_text) + history = [] if is_direct_ros2_cli_request else self.history + # Query LLM - plan = self.llm.inference_plan(system_prompt, user_prompt, images, self.history) + plan = self.llm.inference_plan(system_prompt, user_prompt, images, history) + + if is_direct_ros2_cli_request and not self._is_single_step_plan(plan): + self.logger.log_manager( + "Planner returned multiple steps for a direct ROS 2 command. Requesting a single-step plan.", + error=True, + ) + retry_prompt = ( + user_prompt + + "\n\nImportant: the current user request is already an exact `ros2 ...` command." + + " Return exactly ONE PlanNode with exactly ONE Step using the single best matching tool." + + " Do not include alternatives, helper steps, previous commands, or multiple guesses." + ) + plan = self.llm.inference_plan(system_prompt, retry_prompt, images, []) + if not self._is_single_step_plan(plan): + raise ValueError("Planner returned multiple steps for a direct ROS 2 command.") + self.logger.log_manager(f"Plan received:\n{plan}") # Save to history - if plan: + if plan and not is_direct_ros2_cli_request: self._add_to_history(user_prompt, plan.summary) return plan @@ -175,7 +209,14 @@ def _build_prompt(self, user_text: str, ctx: Dict[str, Any]) -> Tuple[str, str]: user_context = self._parse_user_context() user_prompt = "User request:\n" + user_text - return self._get_prompt_template().format(tools_text=tools_text, user_context=user_context), user_prompt + return ( + self._get_prompt_template().format( + tools_text=tools_text, + user_context=user_context, + identifier_rules=self._get_identifier_prompt_rules(), + ), + user_prompt, + ) def _add_to_history(self, user_text: str, plan_summary: str): """Add a new interaction to the history and trim if necessary.""" @@ -243,14 +284,59 @@ def _format_tool_outputs(tool) -> str: return "none" return ", ".join(f"{key} ({type_name})" for key, type_name in tool.output_schema.items()) + @staticmethod + def _is_direct_ros2_cli_request(user_text: str) -> bool: + return isinstance(user_text, str) and user_text.strip().startswith("ros2 ") + + @staticmethod + def _is_single_step_plan(plan: GlobalPlan | None) -> bool: + if not isinstance(plan, GlobalPlan): + return False + return len(plan.plan) == 1 and len(plan.plan[0].steps) == 1 + + @classmethod + def _is_literal_identifier_input(cls, key: str) -> bool: + return key in cls._literal_identifier_input_keys or key.endswith(cls._literal_identifier_input_suffixes) + + def _format_literal_input_guidance(self, tool) -> str: + literal_inputs = [key for key, _ in tool.input_schema if self._is_literal_identifier_input(key)] + if not literal_inputs: + return "" + + input_list = ", ".join(literal_inputs) + return ( + f"Literal inputs: {input_list}. Copy these values exactly from the user request. " + "Do not correct, expand, or autocomplete them; if they are partial or misspelled, " + "the tool may open an interactive suggestion modal." + ) + + @staticmethod + def _get_identifier_prompt_rules() -> str: + return ( + "When the user provides identifier-like values such as topic, service, action, node, " + "parameter, package, or file names, copy them exactly as written into tool arguments.\n" + "Do not correct, expand, normalize, or autocomplete those values, even if they look partial or " + "misspelled.\n" + "If the user provides a partial or mistyped identifier such as `use_s`, keep it exactly as written " + "so the tool can open its suggestion modal.\n" + "If the user request is already an exact command such as a `ros2 ...` command, map it to exactly " + "ONE matching tool call.\n" + "For an exact command request, return exactly ONE PlanNode with exactly ONE Step and do not add " + "alternatives, helper steps, or previous command variants.\n" + ) + def render_tool_descriptions(self, tools) -> str: tool_descriptions = [] for tool in tools: - tool_descriptions.append( + tool_description = ( f"- *{tool.name}*: {tool.description}\n" f" Inputs: {self._format_tool_inputs(tool)}\n" f" Outputs: {self._format_tool_outputs(tool)}\n" ) + literal_guidance = self._format_literal_input_guidance(tool) + if literal_guidance: + tool_description += f" {literal_guidance}\n" + tool_descriptions.append(tool_description) return "\n".join(tool_descriptions) def _get_prompt_template(self) -> str: @@ -263,10 +349,9 @@ def _get_prompt_template(self) -> str: If an input is marked optional and the user did not ask for a specific value, omit that argument instead of inventing one. Do not guess placeholder values such as `1`, `1.0`, `10`, or empty strings for optional inputs. -When the user explicitly provides identifiers or counts (for example topic names, service names, node names, -file paths, or the number of repetitions), copy those values exactly into the tool arguments. If the user asks to repeat the same action N times and a tool exposes an internal count or limit argument (for example `max_lines`), prefer a single Step using that argument instead of duplicating the same tool call. +{identifier_rules} {user_context} ## Available tools: {tools_text} diff --git a/src/vulcanai/core/manager_iterator.py b/src/vulcanai/core/manager_iterator.py index ea23b60..bede5cb 100644 --- a/src/vulcanai/core/manager_iterator.py +++ b/src/vulcanai/core/manager_iterator.py @@ -45,7 +45,7 @@ def __init__( step_timeout_ms: Optional[int] = None, default_tools: bool = True, ): - super().__init__(model, registry, validator, k, max(3, hist_depth), logger, default_tools) + super().__init__(model=model, registry=registry, validator=validator, k=k, hist_depth=max(3, hist_depth), logger=logger, default_tools=default_tools) self.iter: int = 0 self.max_iters: int = int(max_iters) @@ -220,6 +220,7 @@ def _build_prompt(self, user_text: str, ctx: Dict[str, Any]) -> Tuple[str, str]: system_prompt = system_prompt.format( tools_text=tools_text, user_context=user_context, + identifier_rules=self._get_identifier_prompt_rules(), ) user_prompt = ( "## User Request: " + user_text + "\nContext:\n" + self._get_iter_context().format(bb_snapshot=bb_snapshot) @@ -335,8 +336,6 @@ def _get_prompt_template(self) -> str: - Add only optional execution control parameters if strictly necessary or requested by the user. - If an input is marked optional and the user did not ask for a specific value, omit that argument instead of inventing one. -- When the user explicitly provides identifiers or counts (for example topic names, service names, node names, - file paths, or the number of repetitions), copy those values exactly into the tool arguments. - If the user asks to repeat the same action N times and a tool exposes an internal count or limit argument (for example `max_lines`), prefer a single Step using that argument instead of duplicating the same tool call. - Use "{{{{bb.tool_name.key}}}}" to pass outputs from previous steps if relevant. @@ -344,6 +343,7 @@ def _get_prompt_template(self) -> str: you can pass it to navigate as: args=[ArgValue(key="target", val="{{{{bb.detect_object.pose}}}}")] +{identifier_rules} {user_context} ## Available tools: {tools_text} diff --git a/src/vulcanai/core/manager_plan.py b/src/vulcanai/core/manager_plan.py index a32a374..2b89f4e 100644 --- a/src/vulcanai/core/manager_plan.py +++ b/src/vulcanai/core/manager_plan.py @@ -57,10 +57,9 @@ def _get_prompt_template(self) -> str: If an input is marked optional and the user did not ask for a specific value, omit that argument instead of inventing one. Do not guess placeholder values such as `1`, `1.0`, `10`, or empty strings for optional inputs. -When the user explicitly provides identifiers or counts (for example topic names, service names, node names, -file paths, or the number of repetitions), copy those values exactly into the tool arguments. If the user asks to repeat the same action N times and a tool exposes an internal count or limit argument (for example `max_lines`), prefer a single Step using that argument instead of duplicating the same tool call. +{identifier_rules} {user_context} ## Available tools: {tools_text} diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index 5158c74..08c5f24 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -179,6 +179,17 @@ def _require_console(bb): return console +def _get_node_spin_lock(node): + if node is None: + return None + + spin_lock = getattr(node, "_vulcanai_spin_lock", None) + if spin_lock is None: + spin_lock = threading.Lock() + setattr(node, "_vulcanai_spin_lock", spin_lock) + return spin_lock + + def _normalize_command(command): if command is None: raise ValueError("`command` is required.") @@ -424,7 +435,7 @@ def _run_ros2_param_command( elif command == "set": if set_value is None: raise ValueError("`command='set'` requires `set_value`.") - result["output"] = run_oneshot_cmd(["ros2", "param", "set", node_name, param_name, set_value]) + result["output"] = run_oneshot_cmd(["ros2", "param", "set", node_name, param_name, str(set_value)]) elif command == "delete": result["output"] = run_oneshot_cmd(["ros2", "param", "delete", node_name, param_name]) elif command == "dump": @@ -483,7 +494,7 @@ def _run_ros2_interface_command(console, tool_name: str, command: str, interface if not interface_name: raise ValueError("`command='{}'` requires `interface_name`.".format(command)) # Keep existing command behavior for compatibility. - result["output"] = run_oneshot_cmd(["ros2", "topic", "package", package_name]) + result["output"] = run_oneshot_cmd(["ros2", "interface", "package", package_name]) elif command == "show": suggested_interface_name = suggest_string(console, tool_name, "Interface", interface_name, interface_name_list) if suggested_interface_name is not None: @@ -491,7 +502,7 @@ def _run_ros2_interface_command(console, tool_name: str, command: str, interface if not interface_name: raise ValueError("`command='{}'` requires `interface_name`.".format(command)) # Keep existing command behavior for compatibility. - result["output"] = run_oneshot_cmd(["ros2", "topic", "show", interface_name]) + result["output"] = run_oneshot_cmd(["ros2", "interface", "show", interface_name]) else: raise ValueError( f"Unknown command '{command}'. Expected one of: list, info, echo, bw, delay, hz, find, pub, type." @@ -691,19 +702,11 @@ class Ros2TopicBwTool(AtomicTool): def run(self, **kwargs): console = _require_console(self.bb) - # Streaming commands variables - # max_duration = kwargs.get("max_duration") - # if max_duration is None: - # max_duration = 60 - - # max_lines = kwargs.get("max_lines") - # if max_lines is None: - # max_lines = 100 - max_duration = kwargs.get("max_duration", None) + max_duration = kwargs.get("max_duration") if max_duration is None or not isinstance(max_duration, (int, float)): max_duration = self.input_defaults["max_duration"] - max_lines = kwargs.get("max_lines", None) + max_lines = kwargs.get("max_lines") if max_lines is None or not isinstance(max_lines, (int, float)): max_lines = self.input_defaults["max_lines"] @@ -737,12 +740,12 @@ def run(self, **kwargs): # Streaming commands variables max_duration = kwargs.get("max_duration") - if max_duration is None: - max_duration = 60 + if max_duration is None or not isinstance(max_duration, (int, float)): + max_duration = self.input_defaults["max_duration"] max_lines = kwargs.get("max_lines") - if max_lines is None: - max_lines = 100 + if max_lines is None or not isinstance(max_lines, (int, float)): + max_lines = self.input_defaults["max_lines"] return _run_ros2_topic_command( console, @@ -782,12 +785,12 @@ def run(self, **kwargs): # Streaming commands variables max_duration = kwargs.get("max_duration") - if max_duration is None: - max_duration = 60 + if max_duration is None or not isinstance(max_duration, (int, float)): + max_duration = self.input_defaults["max_duration"] max_lines = kwargs.get("max_lines") - if max_lines is None: - max_lines = 100 + if max_lines is None or not isinstance(max_lines, (int, float)): + max_lines = self.input_defaults["max_lines"] return _run_ros2_topic_command( console, @@ -968,6 +971,7 @@ class Ros2ServiceEchoTool(AtomicTool): "echo service", ] input_schema = [("service_name", "string"), ("max_duration", "float?"), ("max_lines", "int?")] + input_defaults = {"max_duration": 60, "max_lines": 100} output_schema = {"output": "string"} def run(self, **kwargs): @@ -975,12 +979,12 @@ def run(self, **kwargs): # Streaming commands variables max_duration = kwargs.get("max_duration") - if max_duration is None: - max_duration = 60 + if max_duration is None or not isinstance(max_duration, (int, float)): + max_duration = self.input_defaults["max_duration"] max_lines = kwargs.get("max_lines") - if max_lines is None: - max_lines = 100 + if max_lines is None or not isinstance(max_lines, (int, float)): + max_lines = self.input_defaults["max_lines"] return _run_ros2_service_command( console, @@ -1125,12 +1129,12 @@ class Ros2ParamListTool(AtomicTool): "print ros2 params", "available params", ] - input_schema = [] + input_schema = [("node_name", "string?")] output_schema = {"output": "string"} def run(self, **kwargs): console = _require_console(self.bb) - return _run_ros2_param_command(console, self.name, "list") + return _run_ros2_param_command(console, self.name, "list", node_name=kwargs.get("node_name")) @vulcanai_tool @@ -1576,7 +1580,6 @@ class Ros2PublishTool(AtomicTool): "max_duration": 60, "period_sec": 0.1, } - output_schema = { "published": "bool", "count": "int", @@ -1739,7 +1742,12 @@ def run(self, **kwargs): published_msgs.append(msg.data if hasattr(msg, "data") else str(msg)) published_count += 1 - rclpy.spin_once(node, timeout_sec=0.05) + spin_lock = _get_node_spin_lock(node) + if spin_lock is None: + rclpy.spin_once(node, timeout_sec=0.05) + else: + with spin_lock: + rclpy.spin_once(node, timeout_sec=0.05) if period_sec and period_sec > 0.0: time.sleep(period_sec) diff --git a/src/vulcanai/tools/tool_registry.py b/src/vulcanai/tools/tool_registry.py index 9d83846..5b6e47f 100644 --- a/src/vulcanai/tools/tool_registry.py +++ b/src/vulcanai/tools/tool_registry.py @@ -189,6 +189,10 @@ def register(self): newly_registered = [name for name in self.tools if name not in before] self._log_tools_grouped(newly_registered) + def _get_tool_by_name(self, tool_name: str) -> ITool | None: + """Return a tool whether it is currently active or deactivated.""" + return self.tools.get(tool_name) or self.deactivated_tools.get(tool_name) + def _resolve_dependencies(self, tool: CompositeTool): """Resolve and attach dependencies for a CompositeTool.""" for dep_name in tool.dependencies: @@ -303,7 +307,9 @@ def group_tool_names(self, tool_names: list) -> list: """Group tool names by each tools `group_name` attribute. Tools that declare a `group_name` are collected under that group; tools - without the attribute are emitted standalone. Registration order is + without the attribute are emitted standalone. Group metadata is looked + up from both active and deactivated tools so `/edit_tools` can preserve + group structure when an entire group is disabled. Registration order is preserved, with each group emitted on the first occurrence of one of its members. @@ -316,7 +322,7 @@ def group_tool_names(self, tool_names: list) -> list: order = [] for name in tool_names: - tool = self.tools.get(name) + tool = self._get_tool_by_name(name) group = getattr(tool, "group_name", None) if tool is not None else None if group: if group not in group_tools: diff --git a/src/vulcanai/tools/tools.py b/src/vulcanai/tools/tools.py index 4e982bf..323837b 100644 --- a/src/vulcanai/tools/tools.py +++ b/src/vulcanai/tools/tools.py @@ -36,8 +36,7 @@ class ITool(ABC): # Only used for documentation and LLM prompt generation. input_schema: List[Tuple[str, str]] = [] # List of (key, type) pairs, simulating a ArgValue list # Optional default values for documented inputs. - # Used by prompt generation, tool discovery, and execution when optional - # arguments are omitted from a plan + # Used when optional arguments are omitted from a plan input_defaults: Dict[str, Any] = {} output_schema: Dict[str, str] = {} # Validation tool diff --git a/src/vulcanai/tools/utils.py b/src/vulcanai/tools/utils.py index 79306b3..d5f8677 100644 --- a/src/vulcanai/tools/utils.py +++ b/src/vulcanai/tools/utils.py @@ -214,7 +214,26 @@ def run_oneshot_cmd(args: list[str]) -> str: def suggest_string(console, tool_name, string_name, input_string, real_string_list): - ret = None + if input_string is None: + return None + + def _normalize_strings(values: list[str]) -> list[str]: + ret_normalized = [] + seen_values = set() + + for value in values: + cleaned_value = value.strip() + if not cleaned_value or cleaned_value in seen_values: + continue + + seen_values.add(cleaned_value) + ret_normalized.append(cleaned_value) + + return ret_normalized + + input_string = input_string.strip() + real_string_list = _normalize_strings(real_string_list) + ret = input_string def _similarity(a: str, b: str) -> float: """Return a similarity score between 0 and 1.""" @@ -275,6 +294,9 @@ def _get_suggestions(real_string_list_comp: list[str], string_comp: str) -> tupl if input_string not in real_string_list: console.logger.log_tool(f'{string_name}: "{input_string}" does not exists', tool_name=tool_name) + if not real_string_list: + return ret + # Get the suggestions list sorted by similitud value _, topic_sim_list = _get_suggestions(real_string_list, input_string) @@ -287,7 +309,7 @@ def _get_suggestions(real_string_list_comp: list[str], string_comp: str) -> tupl # Check if the user cancelled the suggestion if console.suggestion_index >= 0: - ret = topic_sim_list[console.suggestion_index] + ret = topic_sim_list[console.suggestion_index].strip() # Reset suggestion index console.suggestion_index = -1 From 522d665892f05ffec33316b3caec101f615c850c Mon Sep 17 00:00:00 2001 From: danipiza Date: Tue, 26 May 2026 10:01:40 +0200 Subject: [PATCH 41/55] [#23897] Added optional tests Signed-off-by: danipiza --- src/vulcanai/core/manager_iterator.py | 10 +- src/vulcanai/tools/default_tools.py | 61 ++- .../test_default_tools_optional_args.py | 398 ++++++++++++++++++ 3 files changed, 457 insertions(+), 12 deletions(-) create mode 100644 tests/unittest/test_default_tools_optional_args.py diff --git a/src/vulcanai/core/manager_iterator.py b/src/vulcanai/core/manager_iterator.py index bede5cb..20f76bf 100644 --- a/src/vulcanai/core/manager_iterator.py +++ b/src/vulcanai/core/manager_iterator.py @@ -45,7 +45,15 @@ def __init__( step_timeout_ms: Optional[int] = None, default_tools: bool = True, ): - super().__init__(model=model, registry=registry, validator=validator, k=k, hist_depth=max(3, hist_depth), logger=logger, default_tools=default_tools) + super().__init__( + model=model, + registry=registry, + validator=validator, + k=k, + hist_depth=max(3, hist_depth), + logger=logger, + default_tools=default_tools, + ) self.iter: int = 0 self.max_iters: int = int(max_iters) diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index 08c5f24..cb0e766 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -695,7 +695,11 @@ class Ros2TopicBwTool(AtomicTool): "show topic bandwidth", ] - input_schema = [("topic_name", "string"), ("max_duration", "float?"), ("max_lines", "int?")] + input_schema = [ + ("topic_name", "string"), + ("max_duration", "float?"), # optional duration + ("max_lines", "int?"), # optional number of lines + ] input_defaults = {"max_duration": 60, "max_lines": 100} output_schema = {"output": "string"} @@ -731,7 +735,11 @@ class Ros2TopicDelayTool(AtomicTool): "Use when the user wants to show, display, or print the delay of a ROS 2 topic that is publishing." ) tags = ["ros2", "ros 2", "delay", "topic delay", "ros2 topic delay", "inspect topic delay", "show topic delay"] - input_schema = [("topic_name", "string"), ("max_duration", "float?"), ("max_lines", "int?")] + input_schema = [ + ("topic_name", "string"), + ("max_duration", "float?"), # optional duration + ("max_lines", "int?"), # optional number of lines + ] input_defaults = {"max_duration": 60, "max_lines": 100} output_schema = {"output": "string"} @@ -776,7 +784,11 @@ class Ros2TopicHzTool(AtomicTool): "inspect topic hz", "show topic hz", ] - input_schema = [("topic_name", "string"), ("max_duration", "float?"), ("max_lines", "int?")] + input_schema = [ + ("topic_name", "string"), + ("max_duration", "float?"), # optional duration + ("max_lines", "int?"), # optional number of lines + ] input_defaults = {"max_duration": 60, "max_lines": 100} output_schema = {"output": "string"} @@ -935,7 +947,11 @@ class Ros2ServiceCallTool(AtomicTool): "call service", "call ros2 service", ] - input_schema = [("service_name", "string"), ("service_type", "string"), ("args", "string")] + input_schema = [ + ("service_name", "string"), # service name + ("service_type", "string"), # service type + ("args", "string"), # args + ] output_schema = {"output": "string"} def run(self, **kwargs): @@ -970,7 +986,11 @@ class Ros2ServiceEchoTool(AtomicTool): "observe service", "echo service", ] - input_schema = [("service_name", "string"), ("max_duration", "float?"), ("max_lines", "int?")] + input_schema = [ + ("service_name", "string"), + ("max_duration", "float?"), # optional time + ("max_lines", "int?"), # optional number of lines + ] input_defaults = {"max_duration": 60, "max_lines": 100} output_schema = {"output": "string"} @@ -1093,7 +1113,11 @@ class Ros2ActionSendGoalTool(AtomicTool): "call action", "trigger action", ] - input_schema = [("action_name", "string"), ("action_type", "string"), ("goal_args", "string")] + input_schema = [ + ("action_name", "string"), # action name + ("action_type", "string"), # action type + ("goal_args", "string"), # goal arguments + ] output_schema = {"output": "string"} def run(self, **kwargs): @@ -1129,7 +1153,9 @@ class Ros2ParamListTool(AtomicTool): "print ros2 params", "available params", ] - input_schema = [("node_name", "string?")] + input_schema = [ + ("node_name", "string?") # optional node name + ] output_schema = {"output": "string"} def run(self, **kwargs): @@ -1157,7 +1183,10 @@ class Ros2ParamGetTool(AtomicTool): "read parameter", "show parameter value", ] - input_schema = [("node_name", "string"), ("param_name", "string")] + input_schema = [ + ("node_name", "string"), # node name + ("param_name", "string"), # node param name + ] output_schema = {"output": "string"} def run(self, **kwargs): @@ -1191,7 +1220,10 @@ class Ros2ParamDescribeTool(AtomicTool): "parameter metadata", "parameter details", ] - input_schema = [("node_name", "string"), ("param_name", "string")] + input_schema = [ + ("node_name", "string"), # node name + ("param_name", "string"), # node param name + ] output_schema = {"output": "string"} def run(self, **kwargs): @@ -1225,7 +1257,11 @@ class Ros2ParamSetTool(AtomicTool): "update parameter", "change parameter value", ] - input_schema = [("node_name", "string"), ("param_name", "string"), ("set_value", "string")] + input_schema = [ + ("node_name", "string"), # node name + ("param_name", "string"), # node param name + ("set_value", "string"), # new param value + ] output_schema = {"output": "string"} def run(self, **kwargs): @@ -1261,7 +1297,10 @@ class Ros2ParamDeleteTool(AtomicTool): "remove parameter", "unset parameter", ] - input_schema = [("node_name", "string"), ("param_name", "string")] + input_schema = [ + ("node_name", "string"), # node name + ("param_name", "string"), # node param name + ] output_schema = {"output": "string"} def run(self, **kwargs): diff --git a/tests/unittest/test_default_tools_optional_args.py b/tests/unittest/test_default_tools_optional_args.py new file mode 100644 index 0000000..b0ca514 --- /dev/null +++ b/tests/unittest/test_default_tools_optional_args.py @@ -0,0 +1,398 @@ +# Copyright 2026 Proyectos y Sistemas de Mantenimiento SL (eProsima). +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import importlib +import os +import sys +import types +import unittest +from unittest.mock import Mock, patch + + +class _DummySentenceTransformer: + def __init__(self, *args, **kwargs): + pass + + def encode(self, text, convert_to_numpy=True): + return None + + def similarity(self, a, b): + return None + + +sys.modules.setdefault("sentence_transformers", types.SimpleNamespace(SentenceTransformer=_DummySentenceTransformer)) + + +def _install_rclpy_stubs(): + rclpy_mod = types.ModuleType("rclpy") + rclpy_mod.ok = lambda: True + rclpy_mod.init = lambda: None + rclpy_mod.spin_until_future_complete = lambda *args, **kwargs: None + rclpy_mod.spin_once = lambda *args, **kwargs: None + + node_mod = types.ModuleType("rclpy.node") + + class Node: + def __init__(self, *args, **kwargs): + pass + + node_mod.Node = Node + + task_mod = types.ModuleType("rclpy.task") + + class Future: + def __init__(self): + self._cancelled = False + + def cancelled(self): + return self._cancelled + + def cancel(self): + self._cancelled = True + + task_mod.Future = Future + + sys.modules["rclpy"] = rclpy_mod + sys.modules["rclpy.node"] = node_mod + sys.modules["rclpy.task"] = task_mod + + +CURRENT_DIR = os.path.dirname(__file__) +SRC_DIR = os.path.abspath(os.path.join(CURRENT_DIR, os.path.pardir, os.path.pardir, "src")) +if SRC_DIR not in sys.path: + sys.path.insert(0, SRC_DIR) + +_install_rclpy_stubs() + + +class _MockLogger: + def log_tool(self, *args, **kwargs): + pass + + def log_msg(self, *args, **kwargs): + pass + + def log_console(self, *args, **kwargs): + pass + + +class _MockConsole: + def __init__(self): + self.logger = _MockLogger() + self.stream_task = None + + def set_stream_task(self, task): + self.stream_task = task + + def call_from_thread(self, fn, *args, **kwargs): + fn(*args, **kwargs) + + def show_subprocess_panel(self): + pass + + def change_route_logs(self, value): + pass + + def add_line(self, text): + pass + + def add_subprocess_line(self, text): + pass + + +class _FakeNodeLogger: + def warn(self, *args, **kwargs): + pass + + +class _FakePublisher: + def __init__(self): + self.messages = [] + + def publish(self, msg): + self.messages.append(msg) + + +class _FakeNode: + def __init__(self): + self.publishers = [] + self.destroyed_publishers = [] + self.logger = _FakeNodeLogger() + + def create_publisher(self, msg_type, topic_name, qos_depth): + publisher = _FakePublisher() + publisher.msg_type = msg_type + publisher.topic_name = topic_name + publisher.qos_depth = qos_depth + self.publishers.append(publisher) + return publisher + + def destroy_publisher(self, publisher): + self.destroyed_publishers.append(publisher) + + def get_logger(self): + return self.logger + + +class _NeverCancelledFuture: + def cancelled(self): + return False + + +class _StringMessage: + def __init__(self): + self.data = "" + + +class _DefaultToolsOptionalArgsBase(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.default_tools = importlib.import_module("vulcanai.tools.default_tools") + + def setUp(self): + self.console = _MockConsole() + self.node = _FakeNode() + + def _make_tool(self, tool_cls_name: str): + tool_cls = getattr(self.default_tools, tool_cls_name) + tool = tool_cls() + tool.bb = {"console": self.console, "main_node": self.node} + return tool + +# region TOPIC + +class TestStreamingToolOptionalArgs(_DefaultToolsOptionalArgsBase): + def _assert_topic_default( + self, + tool_cls_name: str, + command: str, + omitted_arg: str, + explicit_arg: str, + explicit_value, + expected_default, + ): + tool = self._make_tool(tool_cls_name) + + with patch.object(self.default_tools, "_run_ros2_topic_command", return_value={"output": "ok"}) as runner: + result = tool.run(topic_name="/demo_topic", **{explicit_arg: explicit_value}) + + self.assertEqual(result, {"output": "ok"}) + self.assertEqual(runner.call_args.args[:3], (self.console, tool.name, command)) + self.assertEqual(runner.call_args.kwargs["topic_name"], "/demo_topic") + self.assertEqual(runner.call_args.kwargs[explicit_arg], explicit_value) + self.assertEqual(runner.call_args.kwargs[omitted_arg], expected_default) + + def _assert_service_default( + self, + omitted_arg: str, + explicit_arg: str, + explicit_value, + expected_default, + ): + tool = self._make_tool("Ros2ServiceEchoTool") + + with patch.object(self.default_tools, "_run_ros2_service_command", return_value={"output": "ok"}) as runner: + result = tool.run(service_name="/demo_service", **{explicit_arg: explicit_value}) + + self.assertEqual(result, {"output": "ok"}) + self.assertEqual(runner.call_args.args[:3], (self.console, tool.name, "echo")) + self.assertEqual(runner.call_args.kwargs["service_name"], "/demo_service") + self.assertEqual(runner.call_args.kwargs[explicit_arg], explicit_value) + self.assertEqual(runner.call_args.kwargs[omitted_arg], expected_default) + + def test_topic_bw_defaults_max_duration_when_omitted(self): + self._assert_topic_default("Ros2TopicBwTool", "bw", "max_duration", "max_lines", 7, 60) + + def test_topic_bw_defaults_max_lines_when_omitted(self): + self._assert_topic_default("Ros2TopicBwTool", "bw", "max_lines", "max_duration", 3.5, 100) + + def test_topic_delay_defaults_max_duration_when_omitted(self): + self._assert_topic_default("Ros2TopicDelayTool", "delay", "max_duration", "max_lines", 8, 60) + + def test_topic_delay_defaults_max_lines_when_omitted(self): + self._assert_topic_default("Ros2TopicDelayTool", "delay", "max_lines", "max_duration", 4.5, 100) + + def test_topic_hz_defaults_max_duration_when_omitted(self): + self._assert_topic_default("Ros2TopicHzTool", "hz", "max_duration", "max_lines", 9, 60) + + def test_topic_hz_defaults_max_lines_when_omitted(self): + self._assert_topic_default("Ros2TopicHzTool", "hz", "max_lines", "max_duration", 5.5, 100) + + def test_service_echo_defaults_max_duration_when_omitted(self): + self._assert_service_default("max_duration", "max_lines", 6, 60) + + def test_service_echo_defaults_max_lines_when_omitted(self): + self._assert_service_default("max_lines", "max_duration", 2.5, 100) + +# endregion + +# region PARAM + +class TestParamListOptionalArgs(_DefaultToolsOptionalArgsBase): + def test_param_list_passes_none_when_node_name_is_omitted(self): + tool = self._make_tool("Ros2ParamListTool") + + with patch.object(self.default_tools, "_run_ros2_param_command", return_value={"output": "ok"}) as runner: + result = tool.run() + + self.assertEqual(result, {"output": "ok"}) + self.assertEqual(runner.call_args.args[:3], (self.console, tool.name, "list")) + self.assertIsNone(runner.call_args.kwargs["node_name"]) + +# endregion + +# region PUBLISH + +class TestPublishOptionalArgs(_DefaultToolsOptionalArgsBase): + def _run_publish(self, tool, monotonic_values, **kwargs): + imported_types = [] + sleep_mock = Mock() + + def fake_import_msg_type(type_str, node): + imported_types.append(type_str) + return _StringMessage + + with ( + patch.object(self.default_tools, "import_msg_type", side_effect=fake_import_msg_type), + patch.object(self.default_tools, "log_tool_in_stream_and_main"), + patch.object(self.default_tools, "print_tool_output"), + patch.object(self.default_tools, "_get_node_spin_lock", return_value=None), + patch.object(self.default_tools.rclpy, "spin_once", create=True), + patch.object(self.default_tools, "Future", _NeverCancelledFuture), + patch.object(self.default_tools.time, "monotonic", side_effect=monotonic_values), + patch.object(self.default_tools.time, "sleep", sleep_mock), + ): + result = tool.run(**kwargs) + + publisher = self.node.publishers[-1] if self.node.publishers else None + return result, publisher, imported_types, sleep_mock + + def test_publish_defaults_msg_type_when_omitted(self): + tool = self._make_tool("Ros2PublishTool") + + result, _, imported_types, _ = self._run_publish( + tool, + monotonic_values=[0.0, 0.0], + topic="/demo_publish", + message_data="hello", + max_lines=1, + max_duration=10.0, + period_sec=0.0, + ) + + self.assertEqual(result["published"], "True") + self.assertEqual(imported_types, ["std_msgs/msg/String"]) + + def test_publish_defaults_message_data_when_omitted(self): + tool = self._make_tool("Ros2PublishTool") + + result, publisher, _, _ = self._run_publish( + tool, + monotonic_values=[0.0, 0.0], + topic="/demo_publish", + max_lines=1, + max_duration=10.0, + period_sec=0.0, + ) + + self.assertEqual(result["published"], "True") + self.assertEqual(len(publisher.messages), 1) + self.assertEqual(publisher.messages[0].data, "Hello from VulcanAI PublishTool!") + + def test_publish_defaults_max_lines_when_omitted(self): + tool = self._make_tool("Ros2PublishTool") + tool.input_defaults = {**tool.input_defaults, "max_lines": 2} + + result, publisher, _, _ = self._run_publish( + tool, + monotonic_values=[0.0, 0.0, 0.1], + topic="/demo_publish", + message_data="hello", + max_duration=10.0, + period_sec=0.0, + ) + + self.assertEqual(result["count"], 2) + self.assertEqual(len(publisher.messages), 2) + + def test_publish_defaults_max_duration_when_omitted(self): + tool = self._make_tool("Ros2PublishTool") + tool.input_defaults = {**tool.input_defaults, "max_duration": 0.001} + + result, publisher, _, _ = self._run_publish( + tool, + monotonic_values=[0.0, 0.0, 0.1], + topic="/demo_publish", + message_data="hello", + max_lines=50, + period_sec=0.0, + ) + + self.assertEqual(result["count"], 1) + self.assertEqual(len(publisher.messages), 1) + + def test_publish_defaults_period_sec_when_omitted(self): + tool = self._make_tool("Ros2PublishTool") + tool.input_defaults = {**tool.input_defaults, "period_sec": 0.25} + + result, publisher, _, sleep_mock = self._run_publish( + tool, + monotonic_values=[0.0, 0.0], + topic="/demo_publish", + message_data="hello", + max_lines=1, + max_duration=10.0, + ) + + self.assertEqual(result["count"], 1) + self.assertEqual(len(publisher.messages), 1) + sleep_mock.assert_called_once_with(0.25) + +# endregion + +# region SUBSCRIBE + +class TestSubscribeOptionalArgs(_DefaultToolsOptionalArgsBase): + def test_subscribe_defaults_max_duration_when_omitted(self): + tool = self._make_tool("Ros2SubscribeTool") + + with ( + patch.object(self.default_tools, "execute_subprocess", return_value="hello\nworld") as execute, + patch.object(self.default_tools, "print_tool_output"), + ): + result = tool.run(topic="/demo_topic", max_lines=2) + + self.assertEqual(result["subscribed"], "True") + self.assertEqual(execute.call_args.args[:3], (self.console, tool.name, ["ros2", "topic", "echo", "/demo_topic", "--field", "data", "--no-arr"])) + self.assertEqual(execute.call_args.args[3], 60) + self.assertEqual(execute.call_args.args[4], 2) + + def test_subscribe_defaults_max_lines_when_omitted(self): + tool = self._make_tool("Ros2SubscribeTool") + + with ( + patch.object(self.default_tools, "execute_subprocess", return_value="hello") as execute, + patch.object(self.default_tools, "print_tool_output"), + ): + result = tool.run(topic="/demo_topic", max_duration=2.5) + + self.assertEqual(result["subscribed"], "True") + self.assertEqual(execute.call_args.args[:3], (self.console, tool.name, ["ros2", "topic", "echo", "/demo_topic", "--field", "data", "--no-arr"])) + self.assertEqual(execute.call_args.args[3], 2.5) + self.assertEqual(execute.call_args.args[4], 100) + +# endregion + +if __name__ == "__main__": + unittest.main() From aade13978278c56a899403b010158a128e50d226 Mon Sep 17 00:00:00 2001 From: danipiza Date: Tue, 26 May 2026 10:33:50 +0200 Subject: [PATCH 42/55] [#23897] Fixed test and added atleast 1 step tests Signed-off-by: danipiza --- .github/workflows/tests.yml | 15 ++++- src/vulcanai/core/validator.py | 15 +++-- .../test_default_tools_optional_args.py | 19 ++++++- tests/unittest/test_executor.py | 14 ++--- tests/unittest/test_tool_registry.py | 57 ++++++++++++++++--- tests/unittest/test_validator.py | 43 ++++++++++++-- 6 files changed, 131 insertions(+), 32 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8356609..729ca7e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -35,7 +35,17 @@ jobs: - name: Run unit tests run: | - python -m unittest discover -s tests/unittest -t . -p "test*.py" --ignore-patterns "test_default_tools.py" -v + mapfile -t test_modules < <( + find tests/unittest -maxdepth 1 -name "test*.py" ! -name "test_default_tools.py" -printf "%f\n" | + sed 's/\.py$//' | + sed 's/^/tests.unittest./' | + sort + ) + if [ "${#test_modules[@]}" -eq 0 ]; then + echo "No unit test modules found" + exit 1 + fi + python -m unittest -v "${test_modules[@]}" ros2_unittests: name: ROS 2 unit tests (default tools) @@ -55,13 +65,12 @@ jobs: - 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 + source /opt/ros/$ROS_DISTRO/setup.bash python3 -m unittest discover -s tests/unittest -t . -p "test_default_tools.py" -v integration: diff --git a/src/vulcanai/core/validator.py b/src/vulcanai/core/validator.py index f0bd127..5753d4e 100644 --- a/src/vulcanai/core/validator.py +++ b/src/vulcanai/core/validator.py @@ -45,17 +45,22 @@ def validate(self, plan: GlobalPlan): """ if not isinstance(plan, GlobalPlan): raise ValueError("Provided plan is not a GlobalPlan instance.") + if not plan.plan: + raise ValueError("GlobalPlan has no PlanNodes defined.") for node in plan.plan: - if isinstance(node, PlanNode): - if not node.steps: - raise ValueError(f"PlanNode '{node.kind}' has no steps defined.") - for step in node.steps: - self._validate_step(step) + if not isinstance(node, PlanNode): + raise ValueError("Provided node is not a PlanNode instance.") + if not node.steps: + raise ValueError(f"PlanNode '{node.kind}' has no steps defined.") + for step in node.steps: + self._validate_step(step) def _validate_step(self, step: Step): """Validate a single step in the plan.""" if not isinstance(step, Step): raise ValueError("Provided step is not a Step instance.") + if not getattr(step, "tool", None): + raise ValueError("Provided step has no tool defined.") # Check tool exists and is registered if step.tool not in self.registry.tools: raise ValueError(f"Tool '{step.tool}' not found in registry.") diff --git a/tests/unittest/test_default_tools_optional_args.py b/tests/unittest/test_default_tools_optional_args.py index b0ca514..d8d45b7 100644 --- a/tests/unittest/test_default_tools_optional_args.py +++ b/tests/unittest/test_default_tools_optional_args.py @@ -170,8 +170,10 @@ def _make_tool(self, tool_cls_name: str): tool.bb = {"console": self.console, "main_node": self.node} return tool + # region TOPIC + class TestStreamingToolOptionalArgs(_DefaultToolsOptionalArgsBase): def _assert_topic_default( self, @@ -235,10 +237,12 @@ def test_service_echo_defaults_max_duration_when_omitted(self): def test_service_echo_defaults_max_lines_when_omitted(self): self._assert_service_default("max_lines", "max_duration", 2.5, 100) + # endregion # region PARAM + class TestParamListOptionalArgs(_DefaultToolsOptionalArgsBase): def test_param_list_passes_none_when_node_name_is_omitted(self): tool = self._make_tool("Ros2ParamListTool") @@ -250,10 +254,12 @@ def test_param_list_passes_none_when_node_name_is_omitted(self): self.assertEqual(runner.call_args.args[:3], (self.console, tool.name, "list")) self.assertIsNone(runner.call_args.kwargs["node_name"]) + # endregion # region PUBLISH + class TestPublishOptionalArgs(_DefaultToolsOptionalArgsBase): def _run_publish(self, tool, monotonic_values, **kwargs): imported_types = [] @@ -359,10 +365,12 @@ def test_publish_defaults_period_sec_when_omitted(self): self.assertEqual(len(publisher.messages), 1) sleep_mock.assert_called_once_with(0.25) + # endregion # region SUBSCRIBE + class TestSubscribeOptionalArgs(_DefaultToolsOptionalArgsBase): def test_subscribe_defaults_max_duration_when_omitted(self): tool = self._make_tool("Ros2SubscribeTool") @@ -374,7 +382,10 @@ def test_subscribe_defaults_max_duration_when_omitted(self): result = tool.run(topic="/demo_topic", max_lines=2) self.assertEqual(result["subscribed"], "True") - self.assertEqual(execute.call_args.args[:3], (self.console, tool.name, ["ros2", "topic", "echo", "/demo_topic", "--field", "data", "--no-arr"])) + self.assertEqual( + execute.call_args.args[:3], + (self.console, tool.name, ["ros2", "topic", "echo", "/demo_topic", "--field", "data", "--no-arr"]), + ) self.assertEqual(execute.call_args.args[3], 60) self.assertEqual(execute.call_args.args[4], 2) @@ -388,10 +399,14 @@ def test_subscribe_defaults_max_lines_when_omitted(self): result = tool.run(topic="/demo_topic", max_duration=2.5) self.assertEqual(result["subscribed"], "True") - self.assertEqual(execute.call_args.args[:3], (self.console, tool.name, ["ros2", "topic", "echo", "/demo_topic", "--field", "data", "--no-arr"])) + self.assertEqual( + execute.call_args.args[:3], + (self.console, tool.name, ["ros2", "topic", "echo", "/demo_topic", "--field", "data", "--no-arr"]), + ) self.assertEqual(execute.call_args.args[3], 2.5) self.assertEqual(execute.call_args.args[4], 100) + # endregion if __name__ == "__main__": diff --git a/tests/unittest/test_executor.py b/tests/unittest/test_executor.py index 54b2de3..d09b37a 100644 --- a/tests/unittest/test_executor.py +++ b/tests/unittest/test_executor.py @@ -79,7 +79,7 @@ def similarity(self, a: np.ndarray, b: np.ndarray) -> float: self.Embedder = LocalDummyEmbedder() # Build registry and executor - self.registry = self.ToolRegistry(embedder=self.Embedder) + self.registry = self.ToolRegistry(embedder=self.Embedder, default_tools=False) # Define and register tools class NavTool(self.AtomicTool): @@ -459,7 +459,7 @@ def test_step_with_retries(self): ], ) - r = self.ToolRegistry(embedder=self.Embedder) + r = self.ToolRegistry(embedder=self.Embedder, default_tools=False) flaky_tool = self.FlakyTool() r.register_tool(flaky_tool) flaky_exec = self.PlanExecutor(r) @@ -497,7 +497,7 @@ def test_step_with_criteria(self): ], ) - r = self.ToolRegistry(embedder=self.Embedder) + r = self.ToolRegistry(embedder=self.Embedder, default_tools=False) # First test with a value that does not meet criteria r.register_tool(self.CriteriaTool(return_value=5)) criteria_exec = self.PlanExecutor(r) @@ -509,7 +509,7 @@ def test_step_with_criteria(self): self.assertEqual(bb["criteria_tool"]["value"], 5) # Now test with a value that meets criteria - r = self.ToolRegistry(embedder=self.Embedder) + r = self.ToolRegistry(embedder=self.Embedder, default_tools=False) r.register_tool(self.CriteriaTool(return_value=15)) criteria_exec = self.PlanExecutor(r) @@ -593,7 +593,7 @@ def test_plan_node_with_criteria(self): ], ) - r = self.ToolRegistry(embedder=self.Embedder) + r = self.ToolRegistry(embedder=self.Embedder, default_tools=False) # First test with a value that does not meet criteria r.register_tool(self.CriteriaTool(return_value=5)) criteria_exec = self.PlanExecutor(r) @@ -605,7 +605,7 @@ def test_plan_node_with_criteria(self): self.assertEqual(bb["criteria_tool"]["value"], 5) # Now test with a value that meets criteria - r = self.ToolRegistry(embedder=self.Embedder) + r = self.ToolRegistry(embedder=self.Embedder, default_tools=False) r.register_tool(self.CriteriaTool(return_value=15)) criteria_exec = self.PlanExecutor(r) @@ -629,7 +629,7 @@ def test_plan_node_with_retries(self): ], ) - r = self.ToolRegistry(embedder=self.Embedder) + r = self.ToolRegistry(embedder=self.Embedder, default_tools=False) flaky_tool = self.FlakyTool() r.register_tool(flaky_tool) flaky_exec = self.PlanExecutor(r) diff --git a/tests/unittest/test_tool_registry.py b/tests/unittest/test_tool_registry.py index 9dc09ca..4ad770a 100644 --- a/tests/unittest/test_tool_registry.py +++ b/tests/unittest/test_tool_registry.py @@ -108,6 +108,30 @@ class SpeakTool(self.AtomicTool): def run(self, **kwargs): return {"spoken": True} + class DemoGroupListTool(self.AtomicTool): + name = "demo_group_list" + group_name = "demo_group" + description = "List entries from a grouped tool family" + tags = ["demo", "grouped"] + input_schema = [] + output_schema = {"ok": "bool"} + version = "0.1" + + def run(self, **kwargs): + return {"ok": True} + + class DemoGroupInfoTool(self.AtomicTool): + name = "demo_group_info" + group_name = "demo_group" + description = "Show info from a grouped tool family" + tags = ["demo", "grouped"] + input_schema = [] + output_schema = {"ok": "bool"} + version = "0.1" + + def run(self, **kwargs): + return {"ok": True} + class ComplexTool(self.CompositeTool): name = "complex_action" description = "A complex action using multiple tools" @@ -135,11 +159,13 @@ def run(self, **kwargs): self.NavTool = NavTool self.DetectTool = DetectTool self.SpeakTool = SpeakTool + self.DemoGroupListTool = DemoGroupListTool + self.DemoGroupInfoTool = DemoGroupInfoTool self.ComplexTool = ComplexTool self.TestValidationTool = TestValidationTool # Build registry with dummy embedder and register tools - self.registry = self.ToolRegistry(embedder=self.Embedder()) + self.registry = self.ToolRegistry(embedder=self.Embedder(), default_tools=False) self.registry.register_tool(self.NavTool()) self.registry.register_tool(self.DetectTool()) self.registry.register_tool(self.SpeakTool()) @@ -171,7 +197,7 @@ def test_top_k_returns_registered_instances_validation(self): def test_empty_registry(self): """Test that top_k on empty registry returns empty list.""" - r = self.ToolRegistry(embedder=self.Embedder()) + r = self.ToolRegistry(embedder=self.Embedder(), default_tools=False) res = r.top_k("anything", k=3) # When no tools are registered, only the help tool is present. # However, top_k will return an empty list because a tool registry with only the help tool @@ -192,7 +218,7 @@ def embed(self, text: str) -> np.ndarray: self.embed_call_count += 1 return super().embed(text) - r = self.ToolRegistry(embedder=MockEmbedder()) + r = self.ToolRegistry(embedder=MockEmbedder(), default_tools=False) r.register_tool(self.NavTool()) r.register_tool(self.DetectTool()) r.register_tool(self.SpeakTool()) @@ -208,7 +234,7 @@ def embed(self, text: str) -> np.ndarray: # Test registers def test_register_tool(self): """Test that tools can be registered and are present in the registry.""" - r = self.ToolRegistry(embedder=self.Embedder()) + r = self.ToolRegistry(embedder=self.Embedder(), default_tools=False) self.assertEqual(len(r.tools), 0 + 1) # +1 for help tool r.register_tool(self.NavTool()) self.assertEqual(len(r.tools), 1 + 1) # +1 for help tool @@ -231,7 +257,7 @@ def test_register_tool_from_file(self): self.assertEqual(len(self.registry._index), 7) # (3 existing + 4 new) def test_register_tool_from_nonexistent_file(self): - r = self.ToolRegistry(embedder=self.Embedder()) + r = self.ToolRegistry(embedder=self.Embedder(), default_tools=False) r.discover_tools_from_file("/path/does/not/exist.py") self.assertEqual(len(r.tools), 0 + 1) # +1 for help tool @@ -252,7 +278,7 @@ def test_register_composite_tool_fails_reports_error(self): buf = io.StringIO() sys.stdout = buf # Check that the composite tool has no resolved deps before registration - r = self.ToolRegistry(embedder=self.Embedder()) + r = self.ToolRegistry(embedder=self.Embedder(), default_tools=False) self.assertEqual(len(r.tools), 0 + 1) # +1 for help tool self.assertEqual(len(self.ComplexTool().dependencies), 3) self.assertEqual(len(self.ComplexTool().resolved_deps), 0) @@ -271,7 +297,7 @@ def test_register_composite_tool_fails_reports_error(self): def test_register_composite_tool_solves_deps_from_file(self): """Test that registering a composite tool from file correctly resolves its dependencies.""" - r = self.ToolRegistry(embedder=self.Embedder()) + r = self.ToolRegistry(embedder=self.Embedder(), default_tools=False) buf = io.StringIO() sys.stdout = buf r.discover_tools_from_file(os.path.join(RESOURCES_DIR, "test_tools.py")) @@ -289,7 +315,7 @@ def test_register_composite_tool_fails_from_file(self): buf = io.StringIO() sys.stdout = buf # Register tool from test_composite_tool.py and expect errors for missing deps - r = self.ToolRegistry(embedder=self.Embedder()) + r = self.ToolRegistry(embedder=self.Embedder(), default_tools=False) r.discover_tools_from_file(os.path.join(RESOURCES_DIR, "test_composite_tool.py")) sys.stdout = sys.__stdout__ # Reset stdout output = buf.getvalue() @@ -301,7 +327,7 @@ def test_register_composite_tool_fails_from_file(self): def test_register_validation_tool(self): """Test that validation tools can be registered and are present in the registry.""" - r = self.ToolRegistry(embedder=self.Embedder()) + r = self.ToolRegistry(embedder=self.Embedder(), default_tools=False) self.assertEqual(len(r.tools), 0 + 1) # +1 for help tool self.assertEqual(len(r._index), 0) r.register_tool(self.TestValidationTool()) @@ -309,6 +335,19 @@ def test_register_validation_tool(self): self.assertEqual(len(r._index), 1) self.assertIn("test_validation", r.validation_tools) + def test_group_tool_names_keeps_deactivated_grouped_tools_together(self): + """Test grouped tools still render as a group after all of them are deactivated.""" + r = self.ToolRegistry(embedder=self.Embedder(), default_tools=False) + r.register_tool(self.DemoGroupListTool()) + r.register_tool(self.DemoGroupInfoTool()) + + self.assertTrue(r.deactivate_tool("demo_group_list")) + self.assertTrue(r.deactivate_tool("demo_group_info")) + + grouped = r.group_tool_names(["demo_group_info", "demo_group_list"]) + + self.assertEqual(grouped, [("demo_group", ["info", "list"])]) + if __name__ == "__main__": unittest.main() diff --git a/tests/unittest/test_validator.py b/tests/unittest/test_validator.py index 611ac4f..a45a9c5 100644 --- a/tests/unittest/test_validator.py +++ b/tests/unittest/test_validator.py @@ -20,6 +20,7 @@ import unittest import numpy as np +from pydantic import ValidationError # Stub sentence_transformers to avoid heavy dependency during tests @@ -78,7 +79,7 @@ def similarity(self, a: np.ndarray, b: np.ndarray) -> float: self.Embedder = LocalDummyEmbedder() # Build registry and executor - self.registry = self.ToolRegistry(embedder=self.Embedder) + self.registry = self.ToolRegistry(embedder=self.Embedder, default_tools=False) # Define and register tools class EmptyTool(self.AtomicTool): @@ -141,11 +142,21 @@ class TypesTool(self.AtomicTool): def run(self, **kwargs): return {"types": True} + class OptionalTool(self.AtomicTool): + name = "optional" + description = "Tool with one required argument and one optional argument" + input_schema = [("required", "string"), ("optional_value", "string?")] + output_schema = {"ok": "bool"} + + def run(self, **kwargs): + return {"ok": True} + self.registry.register_tool(EmptyTool()) self.registry.register_tool(DetectTool()) self.registry.register_tool(NavTool()) self.registry.register_tool(SpeakTool()) self.registry.register_tool(TypesTool()) + self.registry.register_tool(OptionalTool()) self.validator = self.Validator(self.registry) @@ -195,19 +206,39 @@ def test_validator_non_existing_tool(self): self.assertIn("not found in registry", str(e)) self.assertTrue(fail, "Validator did not catch non-existing key error") + def test_global_plan_requires_at_least_one_plan_node(self): + with self.assertRaises(ValidationError) as ctx: + self.GlobalPlan(summary="Empty plan", plan=[]) + + self.assertIn("plan", str(ctx.exception)) + self.assertIn("at least 1 item", str(ctx.exception)) + + def test_plan_node_requires_at_least_one_step(self): + with self.assertRaises(ValidationError) as ctx: + self.PlanNode(kind="SEQUENCE", steps=[]) + + self.assertIn("steps", str(ctx.exception)) + self.assertIn("at least 1 item", str(ctx.exception)) + + def test_step_requires_tool_name(self): + with self.assertRaises(ValidationError) as ctx: + self.Step(args=[]) + + self.assertIn("tool", str(ctx.exception)) + self.assertIn("Field required", str(ctx.exception)) + def test_validator_non_existing_key(self): plan = self.GlobalPlan( - summary="Navigate to a location", + summary="Optional tool with an unknown key", plan=[ self.PlanNode( kind="SEQUENCE", steps=[ self.Step( - tool="go_to_pose", + tool="optional", args=[ - self.Arg(key="non_existing_key", val=1.0), - self.Arg(key="y", val=2.0), - self.Arg(key="z", val=0.0), + self.Arg(key="required", val="hello"), + self.Arg(key="non_existing_key", val="unexpected"), ], ), ], From 0c75184ccb1d5f549fd0c74c9eabe4b6d0d76dba Mon Sep 17 00:00:00 2001 From: danipiza Date: Tue, 26 May 2026 10:41:34 +0200 Subject: [PATCH 43/55] [#23897] Fixed default tools test Signed-off-by: danipiza --- .github/workflows/tests.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 729ca7e..8759807 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -60,18 +60,24 @@ jobs: - name: Install ROS 2 auxiliary test dependencies run: | apt-get update && apt-get install -y --no-install-recommends \ + python3-venv \ ros-kilted-examples-rclpy-minimal-service \ ros-kilted-examples-rclpy-minimal-action-server - name: Install VulcanAI library + shell: bash run: | - python3 -m pip install -e .[test] --break-system-packages + python3 -m venv .venv --system-site-packages + source .venv/bin/activate + python -m pip install -U pip + python -m pip install -e .[test] - name: Run ROS 2 default tools tests shell: bash run: | source /opt/ros/$ROS_DISTRO/setup.bash - python3 -m unittest discover -s tests/unittest -t . -p "test_default_tools.py" -v + source .venv/bin/activate + python -m unittest discover -s tests/unittest -t . -p "test_default_tools.py" -v integration: name: Integration tests (pytest) From 1e80926364d4325d9356bff45cfdb53a1c4533fb Mon Sep 17 00:00:00 2001 From: danipiza Date: Tue, 26 May 2026 10:58:18 +0200 Subject: [PATCH 44/55] [#23897] Fixed default tools test Signed-off-by: danipiza --- src/vulcanai/tools/default_tools.py | 93 ++++++++++++++++++++++++++++ tests/unittest/test_default_tools.py | 37 ++++++----- 2 files changed, 116 insertions(+), 14 deletions(-) diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index cb0e766..4ef7258 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -511,6 +511,99 @@ def _run_ros2_interface_command(console, tool_name: str, command: str, interface print_tool_output(console, result["output"], tool_name) return result +# ----------------------------------------------------------------------------- +# ROS 2 wrappers kept for direct tests and compatibility +# ----------------------------------------------------------------------------- + +class Ros2NodeTool(AtomicTool): + name = "ros2_node" + + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_node_command(console, self.name, kwargs.get("command"), node_name=kwargs.get("node_name")) + + +class Ros2TopicTool(AtomicTool): + name = "ros2_topic" + + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_topic_command( + console, + self.name, + kwargs.get("command"), + topic_name=kwargs.get("topic_name"), + msg_type=kwargs.get("msg_type"), + max_duration=kwargs.get("max_duration"), + max_lines=kwargs.get("max_lines"), + ) + + +class Ros2ServiceTool(AtomicTool): + name = "ros2_service" + + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_service_command( + console, + self.name, + kwargs.get("command"), + service_name=kwargs.get("service_name"), + service_type=kwargs.get("service_type"), + call_args=kwargs.get("args"), + max_duration=kwargs.get("max_duration"), + max_lines=kwargs.get("max_lines"), + ) + + +class Ros2ActionTool(AtomicTool): + name = "ros2_action" + + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_action_command( + console, + self.name, + kwargs.get("command"), + action_name=kwargs.get("action_name"), + action_type=kwargs.get("action_type"), + goal_args=kwargs.get("goal_args"), + ) + + +class Ros2ParamTool(AtomicTool): + name = "ros2_param" + + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_param_command( + console, + self.name, + kwargs.get("command"), + node_name=kwargs.get("node_name"), + param_name=kwargs.get("param_name"), + set_value=kwargs.get("set_value"), + file_path=kwargs.get("file_path"), + ) + + +class Ros2PkgTool(AtomicTool): + name = "ros2_pkg" + + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_pkg_command(console, self.name, kwargs.get("command")) + + +class Ros2InterfaceTool(AtomicTool): + name = "ros2_interface" + + def run(self, **kwargs): + console = _require_console(self.bb) + return _run_ros2_interface_command( + console, self.name, kwargs.get("command"), interface_name=kwargs.get("interface_name") + ) + # --------------------------------------------------------------------------- # Strict per-subcommand ROS 2 tools (planner-facing) diff --git a/tests/unittest/test_default_tools.py b/tests/unittest/test_default_tools.py index 116f992..ab75d27 100644 --- a/tests/unittest/test_default_tools.py +++ b/tests/unittest/test_default_tools.py @@ -1084,13 +1084,24 @@ def test_param_dump(self): self.assertIn("ros__parameters", result["output"]) def test_param_dump_with_output_file(self): - """`dump` with file path currently errors on unsupported CLI option.""" + """`dump` with file path should create the output YAML file.""" self._start_parameter_blackboard() dump_path = "/tmp/vulcan_param_dump.yaml" - if os.path.exists(dump_path): - os.remove(dump_path) - with self.assertRaises(Exception): - self._run_param(command="dump", node_name="/parameter_blackboard", file_path=dump_path) + try: + if os.path.exists(dump_path): + os.remove(dump_path) + + result = self._run_param(command="dump", node_name="/parameter_blackboard", file_path=dump_path) + + self.assertTrue(os.path.exists(dump_path)) + with open(dump_path, encoding="utf-8") as f: + dumped_yaml = f.read() + self.assertIn("/parameter_blackboard:", dumped_yaml) + self.assertIn("ros__parameters", dumped_yaml) + self.assertTrue(result["output"].strip() or dump_path in result["output"]) + finally: + if os.path.exists(dump_path): + os.remove(dump_path) # ------------------------------------------------------------------------- # Tests — ros2 param load @@ -1263,19 +1274,17 @@ def test_interface_packages_command_returns_packages(self): # Tests — ros2 interface package # ------------------------------------------------------------------------- def test_interface_package_branch_calls_current_cli(self): - """`package` branch should use the current CLI call path.""" - with self.assertRaises(Exception) as exc_info: - self._run_interface(command="package", interface_name="std_msgs") - self.assertIn("ros2 topic package", str(exc_info.exception)) + """`package` should list interfaces provided by a package.""" + result = self._run_interface(command="package", interface_name="std_msgs") + self.assertIn("std_msgs/msg/String", result["output"]) # ------------------------------------------------------------------------- # Tests — ros2 interface show # ------------------------------------------------------------------------- def test_interface_show_branch_calls_current_cli(self): - """`show` branch should use the current CLI call path.""" - with self.assertRaises(Exception) as exc_info: - self._run_interface(command="show", interface_name="std_msgs/msg/String") - self.assertIn("ros2 topic show", str(exc_info.exception)) + """`show` should return the interface definition.""" + result = self._run_interface(command="show", interface_name="std_msgs/msg/String") + self.assertIn("string data", result["output"]) # ------------------------------------------------------------------------- # Tests — Error @@ -1475,7 +1484,7 @@ def test_subscribe_with_max_lines(self): proc = start_background_publisher(topic, message="hello_subscribe", rate=10) self._bg_publishers.append(proc) - result = self._run_subscribe_threaded(topic=topic, max_duration=5.0, max_lines=50) + result = self._run_subscribe_threaded(topic=topic, max_duration=5.0, max_lines=5) self.assertEqual("True", result["subscribed"]) self.assertEqual(5, result["count"]) From 54e6f76763d55bcd9f39c857f0b6db6429fa922b Mon Sep 17 00:00:00 2001 From: danipiza Date: Tue, 26 May 2026 10:59:33 +0200 Subject: [PATCH 45/55] [#23897] Fixed Ruff Signed-off-by: danipiza --- src/vulcanai/tools/default_tools.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index 4ef7258..53f5047 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -511,10 +511,12 @@ def _run_ros2_interface_command(console, tool_name: str, command: str, interface print_tool_output(console, result["output"], tool_name) return result + # ----------------------------------------------------------------------------- # ROS 2 wrappers kept for direct tests and compatibility # ----------------------------------------------------------------------------- + class Ros2NodeTool(AtomicTool): name = "ros2_node" From 64475120cbb2c65bf29e9ce63b7e99d4610f5dae Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 4 Jun 2026 11:13:38 +0200 Subject: [PATCH 46/55] [#23897] Updated DefaultToolNode Signed-off-by: danipiza --- src/vulcanai/tools/default_tools.py | 184 +++++++++++++++++++++++----- 1 file changed, 154 insertions(+), 30 deletions(-) diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index 53f5047..1c40ffa 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -41,15 +41,20 @@ except ImportError: raise ImportError("Unable to load default tools because no ROS 2 installation was found.") +try: + from rclpy.executors import SingleThreadedExecutor +except ImportError: + SingleThreadedExecutor = None + # This class contains a ROS 2 node that will be loaded if none is provided to launch ROS 2 default tools class ROS2DefaultToolNode(Node): def __init__(self, name: str = "vulcanai_ros2_default_tools_node"): - if not rclpy.ok(): - rclpy.init() - # This helper node is not spun continuously, so exposing parameter services - # would make `ros2 param list` hang while waiting on unanswered requests. - super().__init__(name, start_parameter_services=False) + self._vulcan_context = rclpy.context.Context() + self._vulcan_context.init() + # Parameter services are attached manually below so the helper node can + # answer `ros2 param *` requests while still keeping spin ownership local. + super().__init__(name, context=self._vulcan_context, start_parameter_services=False) # Dictionary to store created clients self._vulcan_clients = {} # Dictionary to store created publishers @@ -57,6 +62,64 @@ def __init__(self, name: str = "vulcanai_ros2_default_tools_node"): # Ensure entities creation is thread-safe. self.node_lock = threading.Lock() + self._vulcanai_spin_stop = threading.Event() + self._vulcan_executor = None + if SingleThreadedExecutor is not None: + self._vulcan_executor = SingleThreadedExecutor(context=self.context) + self._vulcan_executor.add_node(self) + + self._ensure_parameter_services() + + # Runs in a background thread and keeps the node serviced + # That means subscriptions, service responses, + # and especially parameter-service callbacks can actually be processed + # Without something spinning the node, external ros2 CLI calls can hang waiting for a reply + self._vulcanai_spin_thread = threading.Thread( + target=self._spin_forever, + name=f"{name}_spin", + daemon=True, + ) + self._vulcanai_spin_thread.start() + + # region PRIVATE + + def _ensure_parameter_services(self): + parameter_service = getattr(self, "_parameter_service", None) + if parameter_service is not None: + return parameter_service + + from rclpy.parameter_service import ParameterService + + parameter_service = ParameterService(self) + setattr(self, "_parameter_service", parameter_service) + return parameter_service + + def _spin_forever(self): + while not self._vulcanai_spin_stop.is_set() and self.context.ok(): + try: + self.spin_once(timeout_sec=0.1) + except Exception: + if self._vulcanai_spin_stop.is_set() or not self.context.ok(): + break + time.sleep(0.05) + + # endregion + + # region PUBLIC + + def spin_once(self, timeout_sec: float = 0.1): + spin_lock = _get_node_spin_lock(self) + if spin_lock is None: + if self._vulcan_executor is not None: + self._vulcan_executor.spin_once(timeout_sec=timeout_sec) + else: + rclpy.spin_once(self, timeout_sec=timeout_sec) + else: + with spin_lock: + if self._vulcan_executor is not None: + self._vulcan_executor.spin_once(timeout_sec=timeout_sec) + else: + rclpy.spin_once(self, timeout_sec=timeout_sec) def get_client(self, srv_type, srv_name): """ @@ -98,13 +161,41 @@ def callback(msg): sub = self.create_subscription(msg_type, topic, callback, 10) - rclpy.spin_until_future_complete(self, future, timeout_sec=timeout_sec) + deadline = None if timeout_sec is None else time.monotonic() + timeout_sec + while not future.done(): + if deadline is None: + spin_timeout = 0.1 + else: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + spin_timeout = min(0.1, remaining) + self.spin_once(timeout_sec=spin_timeout) self.destroy_subscription(sub) if future.done(): return future.result() return None + def destroy_node(self): + self._vulcanai_spin_stop.set() + spin_thread = getattr(self, "_vulcanai_spin_thread", None) + if spin_thread is not None and spin_thread.is_alive(): + spin_thread.join(timeout=1.0) + try: + return super().destroy_node() + finally: + if self._vulcan_executor is not None: + try: + self._vulcan_executor.remove_node(self) + except Exception: + pass + self._vulcan_executor.shutdown(timeout_sec=1.0) + if self.context.ok(): + self.context.try_shutdown() + + # endregion + """ Available ROS 2 CLI commands that can be run with the tools in this file: @@ -388,6 +479,33 @@ def _run_ros2_action_command( return result +def _parse_ros2_param_list_output(output: str, node_name: str = None): + """ + Parse `ros2 param list` output into plain parameter names. + + The ROS 2 CLI can emit either a node-scoped block format or a flat list. + When `node_name` is provided, only parameters for that node are returned. + """ + parsed_params = [] + current_node = None + saw_node_header = False + + for raw_line in output.splitlines(): + stripped_line = raw_line.strip() + if not stripped_line: + continue + + if stripped_line.endswith(":") and not raw_line[:1].isspace(): + current_node = stripped_line[:-1] + saw_node_header = True + continue + + if not saw_node_header or node_name is None or current_node == node_name: + parsed_params.append(stripped_line) + + return parsed_params + + def _run_ros2_param_command( console, tool_name: str, @@ -400,31 +518,30 @@ def _run_ros2_param_command( command = _normalize_command(command) result = {"output": ""} - param_name_list_str = run_oneshot_cmd(["ros2", "param", "list"]) - node_name_list_str = run_oneshot_cmd(["ros2", "node", "list"]) - - param_name_list = param_name_list_str.splitlines() - node_name_list = node_name_list_str.splitlines() - - if command != "list": - if command not in ["dump", "load"]: - suggested_param_name = suggest_string(console, tool_name, "Param", param_name, param_name_list) - if suggested_param_name is not None: - param_name = suggested_param_name - if not param_name: - raise ValueError("`command='{}'` requires `param_name`.".format(command)) - + requires_node_name = command != "list" or node_name is not None + if requires_node_name: + node_name_list_str = run_oneshot_cmd(["ros2", "node", "list"]) + node_name_list = node_name_list_str.splitlines() suggested_node_name = suggest_string(console, tool_name, "Node", node_name, node_name_list) if suggested_node_name is not None: node_name = suggested_node_name if not node_name: raise ValueError("`command='{}'` requires `node_name`.".format(command)) + if command in ["get", "describe", "set", "delete"]: + param_name_list_str = run_oneshot_cmd(["ros2", "param", "list", node_name]) + param_name_list = _parse_ros2_param_list_output(param_name_list_str, node_name=node_name) + suggested_param_name = suggest_string(console, tool_name, "Param", param_name, param_name_list) + if suggested_param_name is not None: + param_name = suggested_param_name + if not param_name: + raise ValueError("`command='{}'` requires `param_name`.".format(command)) + if command == "list": if node_name: - result["output"] = node_name_list_str + result["output"] = run_oneshot_cmd(["ros2", "param", "list", node_name]) else: - result["output"] = param_name_list_str + result["output"] = run_oneshot_cmd(["ros2", "param", "list"]) elif command == "get": get_output = run_oneshot_cmd(["ros2", "param", "get", node_name, param_name]) if "parameter not set" in get_output.lower(): @@ -439,14 +556,16 @@ def _run_ros2_param_command( elif command == "delete": result["output"] = run_oneshot_cmd(["ros2", "param", "delete", node_name, param_name]) elif command == "dump": + dump_output = run_oneshot_cmd(["ros2", "param", "dump", node_name]) if file_path: - dump_output = run_oneshot_cmd(["ros2", "param", "dump", node_name, "--output-file", file_path]) + with open(file_path, "w", encoding="utf-8") as output_file: + output_file.write(dump_output) result["output"] = dump_output or f"Dumped parameters to {file_path}" else: - result["output"] = run_oneshot_cmd(["ros2", "param", "dump", node_name]) + result["output"] = dump_output elif command == "load": if not file_path: - raise ValueError("`command='load'` `file_path`.") + raise ValueError("`command='load'` requires `file_path`.") result["output"] = run_oneshot_cmd(["ros2", "param", "load", node_name, file_path]) else: raise ValueError( @@ -1876,12 +1995,15 @@ def run(self, **kwargs): published_msgs.append(msg.data if hasattr(msg, "data") else str(msg)) published_count += 1 - spin_lock = _get_node_spin_lock(node) - if spin_lock is None: - rclpy.spin_once(node, timeout_sec=0.05) + if hasattr(node, "spin_once"): + node.spin_once(timeout_sec=0.05) else: - with spin_lock: + spin_lock = _get_node_spin_lock(node) + if spin_lock is None: rclpy.spin_once(node, timeout_sec=0.05) + else: + with spin_lock: + rclpy.spin_once(node, timeout_sec=0.05) if period_sec and period_sec > 0.0: time.sleep(period_sec) @@ -1987,7 +2109,9 @@ def run(self, **kwargs): base_args = ["ros2", "topic", "echo", topic_name, "--field", "data", "--no-arr"] ret = execute_subprocess(console, self.name, base_args, max_duration, max_lines, log_created=False) - ret_lines = ret.splitlines() if isinstance(ret, str) and ret else [] + ret_lines = [line for line in ret.splitlines() if line.strip()] if isinstance(ret, str) and ret else [] + if max_lines is not None: + ret_lines = ret_lines[:max_lines] result["output"] = "\n".join(ret_lines) From 03abcc091df7d3226459c338e22bd9d59a405ea9 Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 4 Jun 2026 11:55:32 +0200 Subject: [PATCH 47/55] [#23897] Fixed ROS 2 unit tests + 'test_publish_with_max_duration' flaky test Signed-off-by: danipiza --- src/vulcanai/tools/default_tools.py | 41 ++++++++++++++++++++++++++-- tests/unittest/test_default_tools.py | 15 +++++++--- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index 1c40ffa..a45ae77 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -506,6 +506,30 @@ def _parse_ros2_param_list_output(output: str, node_name: str = None): return parsed_params +def _normalize_ros2_param_list_output(output: str, node_name: str = None): + """ + Normalize `ros2 param list` output to the node-scoped block format. + + Some ROS 2 distributions emit only plain parameter names for + `ros2 param list `, while others include a `:` header. + Returning a stable format keeps higher-level tooling and tests + consistent across environments. + """ + if node_name is None or node_name in output: + return output + + parsed_params = _parse_ros2_param_list_output(output, node_name=node_name) + if not parsed_params: + return output + + normalized_lines = [f"{node_name}:"] + normalized_lines.extend(f" {param_name}" for param_name in parsed_params) + normalized_output = "\n".join(normalized_lines) + if output.endswith("\n"): + normalized_output += "\n" + return normalized_output + + def _run_ros2_param_command( console, tool_name: str, @@ -539,7 +563,8 @@ def _run_ros2_param_command( if command == "list": if node_name: - result["output"] = run_oneshot_cmd(["ros2", "param", "list", node_name]) + raw_output = run_oneshot_cmd(["ros2", "param", "list", node_name]) + result["output"] = _normalize_ros2_param_list_output(raw_output, node_name=node_name) else: result["output"] = run_oneshot_cmd(["ros2", "param", "list"]) elif command == "get": @@ -1944,6 +1969,7 @@ def run(self, **kwargs): ) start_time = time.monotonic() + next_publish_time = start_time published_count = 0 while True: @@ -1962,6 +1988,17 @@ def run(self, **kwargs): ) break + if period_sec and period_sec > 0.0: + remaining_wait = next_publish_time - time.monotonic() + if remaining_wait > 0.0: + time.sleep(remaining_wait) + if max_duration is not None and (time.monotonic() - start_time) >= max_duration: + console.logger.log_tool( + f"[tool]Stopping:[/tool] Exceeded max_duration = {max_duration}s", + tool_name=self.name, + ) + break + msg = MsgType() # Try to populate message based on message type @@ -2006,7 +2043,7 @@ def run(self, **kwargs): rclpy.spin_once(node, timeout_sec=0.05) if period_sec and period_sec > 0.0: - time.sleep(period_sec) + next_publish_time += period_sec finally: console.set_stream_task(None) diff --git a/tests/unittest/test_default_tools.py b/tests/unittest/test_default_tools.py index ab75d27..1a5186a 100644 --- a/tests/unittest/test_default_tools.py +++ b/tests/unittest/test_default_tools.py @@ -1358,19 +1358,26 @@ def test_publish_with_max_lines(self): # Tests — Publish max_duration # ------------------------------------------------------------------------- def test_publish_with_max_duration(self): - """Publisher should stop after max_lines and report count/output.""" + """Publisher should stop due to max_duration and report progress/output.""" + max_lines = 100 + max_duration = 3.0 + start_time = time.monotonic() result = self._run_publish( topic="/vulcan_publish_tool_test", message_data="hello_publish", - max_lines=10, + max_lines=max_lines, period_sec=1.0, - max_duration=10.0, + max_duration=max_duration, ) + elapsed = time.monotonic() - start_time self.assertEqual("True", result["published"]) - self.assertEqual(10, result["count"]) + self.assertGreaterEqual(result["count"], 1) + self.assertLess(result["count"], max_lines) self.assertEqual("/vulcan_publish_tool_test", result["topic"]) self.assertIn("Publishing: 'hello_publish'", result["output"]) + self.assertGreaterEqual(elapsed, max_duration * 0.8) + self.assertLess(elapsed, max_duration + 5.0) # ------------------------------------------------------------------------- # Tests — Errors From 1c5193c4f4b7b81bb157ce729e716eff5cb660f6 Mon Sep 17 00:00:00 2001 From: danipiza Date: Mon, 8 Jun 2026 07:39:25 +0200 Subject: [PATCH 48/55] [#23897] Updated tests Signed-off-by: danipiza --- src/vulcanai/console/console.py | 3 +- tests/unittest/test_console_regressions.py | 138 ++++++++++++++++++ tests/unittest/test_default_tools.py | 13 +- .../test_default_tools_optional_args.py | 4 +- tests/unittest/test_tool_registry.py | 15 ++ 5 files changed, 165 insertions(+), 8 deletions(-) create mode 100644 tests/unittest/test_console_regressions.py diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index 798b970..19618eb 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -722,7 +722,8 @@ def cmd_help(self, _) -> None: def cmd_tools(self, _) -> None: def get_tool_summary(tool) -> str: - return getattr(tool, "tool_description", None) or tool.description + summary = getattr(tool, "tool_description", None) or tool.description + return escape(summary) tmp_msg = f"(current index k={self.manager.k})" tool_msg = ("_" * len(tmp_msg)) + "\n" diff --git a/tests/unittest/test_console_regressions.py b/tests/unittest/test_console_regressions.py new file mode 100644 index 0000000..6deb594 --- /dev/null +++ b/tests/unittest/test_console_regressions.py @@ -0,0 +1,138 @@ +# Copyright 2026 Proyectos y Sistemas de Mantenimiento SL (eProsima). +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import importlib +import os +import sys +import types +import unittest +from types import SimpleNamespace +from unittest.mock import Mock + + +class _DummySentenceTransformer: + def __init__(self, *args, **kwargs): + pass + + def encode(self, text, convert_to_numpy=True): + return None + + def similarity(self, a, b): + return None + + +sys.modules.setdefault("sentence_transformers", types.SimpleNamespace(SentenceTransformer=_DummySentenceTransformer)) + + +CURRENT_DIR = os.path.dirname(__file__) +SRC_DIR = os.path.abspath(os.path.join(CURRENT_DIR, os.path.pardir, os.path.pardir, "src")) +if SRC_DIR not in sys.path: + sys.path.insert(0, SRC_DIR) + + +class _CaptureSink: + def __init__(self): + self.messages = [] + + def write(self, msg: str, color: str = "") -> None: + self.messages.append((msg, color)) + + +class _FakeInput: + def __init__(self): + self.id = "cmd" + self.value = "" + self.focused = False + + def focus(self): + self.focused = True + + +class TestConsoleRegressions(unittest.IsolatedAsyncioTestCase): + @classmethod + def setUpClass(cls): + cls.console_mod = importlib.import_module("vulcanai.console.console") + cls.logger_mod = importlib.import_module("vulcanai.console.logger") + + def setUp(self): + self._default_logger_instance = self.logger_mod.VulcanAILogger._default_instance + self._rich_markup_enabled = self.logger_mod.VulcanAILogger._rich_markup + + def tearDown(self): + self.logger_mod.VulcanAILogger._default_instance = self._default_logger_instance + self.logger_mod.VulcanAILogger._rich_markup = self._rich_markup_enabled + + def test_tools_command_escapes_tool_summaries(self): + """`/tools` should escape tool descriptions that contain markup-like tokens.""" + console = self.console_mod.VulcanConsole(default_tools=False) + sink = _CaptureSink() + console.logger = self.logger_mod.VulcanAILogger(sink=sink) + + topic_tool = SimpleNamespace( + name="ros2_topic_type", + tool_description="Equivalent to `ros2 topic type [topic_name]`.", + description="fallback", + ) + console.manager = SimpleNamespace( + k=7, + registry=SimpleNamespace( + tools={"ros2_topic_type": topic_tool}, + group_tool_names=lambda names: [("ros2_topic", ["type"])], + ), + ) + console.commands = {"/tools": console.cmd_tools} + + console.handle_command("/tools") + + rendered = "\n".join(msg for msg, _ in sink.messages) + self.assertNotIn("Error:", rendered) + self.assertIn(r"\[topic_name]", rendered) + + async def test_on_input_submitted_removes_angle_brackets_from_queries(self): + """User queries should be sanitized before echo/history/task scheduling.""" + console = self.console_mod.VulcanConsole(default_tools=False) + console.is_ready = True + console.logger = Mock() + console._update_history_panel = Mock() + console._is_stream_task_active = Mock(return_value=False) + console._route_logs_to_stream_panel = 0 + + input_widget = _FakeInput() + console.query_one = Mock(return_value=input_widget) + + captured = {} + + async def fake_queriestrap(user_input): + captured["query"] = user_input + + console.queriestrap = fake_queriestrap + + raw_query = "show topic <>" + event = SimpleNamespace(value=raw_query, input=input_widget) + + await console.on_input_submitted(event) + await asyncio.sleep(0) + + self.assertIn("query", captured) + self.assertNotIn("<", captured["query"]) + self.assertNotIn(">", captured["query"]) + self.assertEqual(console.history[-1], captured["query"]) + console.logger.log_user.assert_called_once_with(captured["query"]) + self.assertEqual(input_widget.value, "") + self.assertTrue(input_widget.focused) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unittest/test_default_tools.py b/tests/unittest/test_default_tools.py index 1a5186a..e20f880 100644 --- a/tests/unittest/test_default_tools.py +++ b/tests/unittest/test_default_tools.py @@ -1038,13 +1038,20 @@ def test_param_describe(self): def test_param_set(self): """`set` should update a parameter on parameter_blackboard.""" self._start_parameter_blackboard() + param_name = "vulcan_test_param_set" result = self._run_param_without_param_suggestion( command="set", node_name="/parameter_blackboard", - param_name="vulcan_test_param_set", + param_name=param_name, set_value="123", ) self.assertIn("Set parameter", result["output"]) + get_result = self._run_param_without_param_suggestion( + command="get", + node_name="/parameter_blackboard", + param_name=param_name, + ) + self.assertIn("123", get_result["output"]) # ------------------------------------------------------------------------- # Tests — ros2 param delete @@ -1361,7 +1368,6 @@ def test_publish_with_max_duration(self): """Publisher should stop due to max_duration and report progress/output.""" max_lines = 100 max_duration = 3.0 - start_time = time.monotonic() result = self._run_publish( topic="/vulcan_publish_tool_test", message_data="hello_publish", @@ -1369,15 +1375,12 @@ def test_publish_with_max_duration(self): period_sec=1.0, max_duration=max_duration, ) - elapsed = time.monotonic() - start_time self.assertEqual("True", result["published"]) self.assertGreaterEqual(result["count"], 1) self.assertLess(result["count"], max_lines) self.assertEqual("/vulcan_publish_tool_test", result["topic"]) self.assertIn("Publishing: 'hello_publish'", result["output"]) - self.assertGreaterEqual(elapsed, max_duration * 0.8) - self.assertLess(elapsed, max_duration + 5.0) # ------------------------------------------------------------------------- # Tests — Errors diff --git a/tests/unittest/test_default_tools_optional_args.py b/tests/unittest/test_default_tools_optional_args.py index d8d45b7..9f44d5d 100644 --- a/tests/unittest/test_default_tools_optional_args.py +++ b/tests/unittest/test_default_tools_optional_args.py @@ -354,7 +354,7 @@ def test_publish_defaults_period_sec_when_omitted(self): result, publisher, _, sleep_mock = self._run_publish( tool, - monotonic_values=[0.0, 0.0], + monotonic_values=[0.0, 0.0, 0.0, 0.0], topic="/demo_publish", message_data="hello", max_lines=1, @@ -363,7 +363,7 @@ def test_publish_defaults_period_sec_when_omitted(self): self.assertEqual(result["count"], 1) self.assertEqual(len(publisher.messages), 1) - sleep_mock.assert_called_once_with(0.25) + sleep_mock.assert_not_called() # endregion diff --git a/tests/unittest/test_tool_registry.py b/tests/unittest/test_tool_registry.py index 4ad770a..648ca59 100644 --- a/tests/unittest/test_tool_registry.py +++ b/tests/unittest/test_tool_registry.py @@ -348,6 +348,21 @@ def test_group_tool_names_keeps_deactivated_grouped_tools_together(self): self.assertEqual(grouped, [("demo_group", ["info", "list"])]) + def test_group_tool_names_keeps_deactivated_groups_when_built_from_registry_state(self): + """Test grouped tools remain grouped when names come from active + deactivated registries.""" + r = self.ToolRegistry(embedder=self.Embedder(), default_tools=False) + r.register_tool(self.DemoGroupListTool()) + r.register_tool(self.DemoGroupInfoTool()) + + self.assertTrue(r.deactivate_tool("demo_group_list")) + self.assertTrue(r.deactivate_tool("demo_group_info")) + + all_names = sorted(n for n in list(r.tools.keys()) + list(r.deactivated_tools.keys()) if n != "help") + + grouped = r.group_tool_names(all_names) + + self.assertEqual(grouped, [("demo_group", ["info", "list"])]) + if __name__ == "__main__": unittest.main() From 957eee06e72a7068b3161143d73e09aecdaecf17 Mon Sep 17 00:00:00 2001 From: danipiza Date: Mon, 8 Jun 2026 08:27:11 +0200 Subject: [PATCH 49/55] [#23897] Added more tests Signed-off-by: danipiza --- tests/unittest/test_default_tools.py | 11 +- .../test_default_tools_optional_args.py | 124 +++++++++++++++++- tests/unittest/test_validator.py | 56 ++++++++ 3 files changed, 185 insertions(+), 6 deletions(-) diff --git a/tests/unittest/test_default_tools.py b/tests/unittest/test_default_tools.py index e20f880..841725e 100644 --- a/tests/unittest/test_default_tools.py +++ b/tests/unittest/test_default_tools.py @@ -1347,13 +1347,18 @@ def _run_publish(self, node=None, **kwargs): # Tests — Publish max_lines # ------------------------------------------------------------------------- def test_publish_with_max_lines(self): - """Publisher should stop after max_lines and report count/output.""" + """Publisher should stop after max_lines and report count/output. + + Keep this focused on the max_lines stop condition rather than wall-clock + timing. Small publish periods can be flaky in CI when an iteration is + delayed long enough for max_duration to win before the second publish. + """ result = self._run_publish( topic="/vulcan_publish_tool_test", message_data="hello_publish", max_lines=2, - period_sec=0.01, - max_duration=2.0, + period_sec=0.0, + max_duration=10.0, ) self.assertEqual("True", result["published"]) diff --git a/tests/unittest/test_default_tools_optional_args.py b/tests/unittest/test_default_tools_optional_args.py index 9f44d5d..4b5c7ed 100644 --- a/tests/unittest/test_default_tools_optional_args.py +++ b/tests/unittest/test_default_tools_optional_args.py @@ -155,6 +155,11 @@ def __init__(self): self.data = "" +class _CustomMessage: + def __init__(self): + self.value = None + + class _DefaultToolsOptionalArgsBase(unittest.TestCase): @classmethod def setUpClass(cls): @@ -261,13 +266,13 @@ def test_param_list_passes_none_when_node_name_is_omitted(self): class TestPublishOptionalArgs(_DefaultToolsOptionalArgsBase): - def _run_publish(self, tool, monotonic_values, **kwargs): + def _run_publish(self, tool, monotonic_values, message_cls=_StringMessage, future_cls=_NeverCancelledFuture, **kwargs): imported_types = [] sleep_mock = Mock() def fake_import_msg_type(type_str, node): imported_types.append(type_str) - return _StringMessage + return message_cls with ( patch.object(self.default_tools, "import_msg_type", side_effect=fake_import_msg_type), @@ -275,7 +280,7 @@ def fake_import_msg_type(type_str, node): patch.object(self.default_tools, "print_tool_output"), patch.object(self.default_tools, "_get_node_spin_lock", return_value=None), patch.object(self.default_tools.rclpy, "spin_once", create=True), - patch.object(self.default_tools, "Future", _NeverCancelledFuture), + patch.object(self.default_tools, "Future", future_cls), patch.object(self.default_tools.time, "monotonic", side_effect=monotonic_values), patch.object(self.default_tools.time, "sleep", sleep_mock), ): @@ -365,6 +370,78 @@ def test_publish_defaults_period_sec_when_omitted(self): self.assertEqual(len(publisher.messages), 1) sleep_mock.assert_not_called() + def test_publish_invalid_max_duration_falls_back_to_default(self): + tool = self._make_tool("Ros2PublishTool") + tool.input_defaults = {**tool.input_defaults, "max_duration": 0.001} + + result, publisher, _, _ = self._run_publish( + tool, + monotonic_values=[0.0, 0.0, 0.1], + topic="/demo_publish", + message_data="hello", + max_lines=50, + max_duration="not_a_number", + period_sec=0.0, + ) + + self.assertEqual(result["count"], 1) + self.assertEqual(len(publisher.messages), 1) + + def test_publish_invalid_max_lines_falls_back_to_default(self): + tool = self._make_tool("Ros2PublishTool") + tool.input_defaults = {**tool.input_defaults, "max_lines": 2} + + result, publisher, _, _ = self._run_publish( + tool, + monotonic_values=[0.0, 0.0, 0.1], + topic="/demo_publish", + message_data="hello", + max_lines="not_an_int", + max_duration=10.0, + period_sec=0.0, + ) + + self.assertEqual(result["count"], 2) + self.assertEqual(len(publisher.messages), 2) + + def test_publish_negative_period_sec_falls_back_to_default(self): + tool = self._make_tool("Ros2PublishTool") + tool.input_defaults = {**tool.input_defaults, "period_sec": 0.25} + + result, publisher, _, sleep_mock = self._run_publish( + tool, + monotonic_values=[0.0] * 10, + topic="/demo_publish", + message_data="hello", + max_lines=2, + max_duration=10.0, + period_sec=-1.0, + ) + + self.assertEqual(result["count"], 2) + self.assertEqual(len(publisher.messages), 2) + sleep_mock.assert_called() + + def test_publish_valid_custom_message_json_populates_message(self): + tool = self._make_tool("Ros2PublishTool") + + result, publisher, imported_types, _ = self._run_publish( + tool, + monotonic_values=[0.0, 0.0], + message_cls=_CustomMessage, + topic="/demo_publish", + msg_type="demo_msgs/msg/Custom", + message_data='{"value": 42}', + max_lines=1, + max_duration=10.0, + period_sec=0.0, + ) + + self.assertEqual(result["published"], "True") + self.assertEqual(imported_types, ["demo_msgs/msg/Custom"]) + self.assertEqual(len(publisher.messages), 1) + self.assertEqual(publisher.messages[0].value, 42) + # endregion @@ -406,6 +483,47 @@ def test_subscribe_defaults_max_lines_when_omitted(self): self.assertEqual(execute.call_args.args[3], 2.5) self.assertEqual(execute.call_args.args[4], 100) + def test_subscribe_invalid_limits_fall_back_to_defaults(self): + tool = self._make_tool("Ros2SubscribeTool") + + with ( + patch.object(self.default_tools, "execute_subprocess", return_value="hello") as execute, + patch.object(self.default_tools, "print_tool_output"), + ): + result = tool.run(topic="/demo_topic", max_duration="invalid", max_lines="invalid") + + self.assertEqual(result["subscribed"], "True") + self.assertEqual(execute.call_args.args[3], 60) + self.assertEqual(execute.call_args.args[4], 100) + + def test_subscribe_truncates_output_to_max_lines(self): + tool = self._make_tool("Ros2SubscribeTool") + + with ( + patch.object(self.default_tools, "execute_subprocess", return_value="one\ntwo\nthree") as execute, + patch.object(self.default_tools, "print_tool_output"), + ): + result = tool.run(topic="/demo_topic", max_duration=2.0, max_lines=2) + + self.assertEqual(result["subscribed"], "True") + self.assertEqual(result["count"], 2) + self.assertEqual(result["output"], "one\ntwo") + self.assertEqual(execute.call_args.args[4], 2) + + def test_subscribe_none_output_keeps_default_unsubscribed_result(self): + tool = self._make_tool("Ros2SubscribeTool") + + with ( + patch.object(self.default_tools, "execute_subprocess", return_value=None), + patch.object(self.default_tools, "print_tool_output"), + ): + result = tool.run(topic="/demo_topic", max_duration=1.0, max_lines=1) + + self.assertEqual(result["subscribed"], "False") + self.assertEqual(result["count"], "0") + self.assertEqual(result["topic"], "") + self.assertEqual(result["output"], "") + # endregion diff --git a/tests/unittest/test_validator.py b/tests/unittest/test_validator.py index a45a9c5..a08687b 100644 --- a/tests/unittest/test_validator.py +++ b/tests/unittest/test_validator.py @@ -227,6 +227,36 @@ def test_step_requires_tool_name(self): self.assertIn("tool", str(ctx.exception)) self.assertIn("Field required", str(ctx.exception)) + def test_validator_rejects_non_global_plan(self): + with self.assertRaises(ValueError) as ctx: + self.validator.validate(object()) + + self.assertIn("not a GlobalPlan instance", str(ctx.exception)) + + def test_validate_step_rejects_non_step(self): + with self.assertRaises(ValueError) as ctx: + self.validator._validate_step(object()) + + self.assertIn("not a Step instance", str(ctx.exception)) + + def test_validator_accepts_required_arg_with_optional_arg_omitted(self): + plan = self.GlobalPlan( + summary="Use tool with omitted optional arg", + plan=[ + self.PlanNode( + kind="SEQUENCE", + steps=[ + self.Step(tool="optional", args=[self.Arg(key="required", val="hello")]), + ], + ) + ], + ) + + try: + self.validator.validate(plan) + except Exception as e: + self.fail(f"Validation failed with omitted optional arg: {e}") + def test_validator_non_existing_key(self): plan = self.GlobalPlan( summary="Optional tool with an unknown key", @@ -393,6 +423,32 @@ def test_validator_wrong_all_brackets_missing(self): self.assertIn("Blackboard reference in argument", str(e)) self.assertTrue(fail, "Validator did not catch non-existing key error") + def test_validator_accepts_multiple_well_formed_bb_refs_in_one_string(self): + plan = self.GlobalPlan( + summary="Speak using multiple blackboard references", + plan=[ + self.PlanNode( + kind="SEQUENCE", + steps=[ + self.Step( + tool="speak", + args=[ + self.Arg( + key="text", + val="Detected at {{bb.detect_object.pose.x}}, {{bb.detect_object.pose.y}}", + ) + ], + ), + ], + ) + ], + ) + + try: + self.validator.validate(plan) + except Exception as e: + self.fail(f"Validation failed for well-formed multiple bb refs: {e}") + def test_validator_correct_types(self): plan = self.GlobalPlan( summary="Check types of arguments", From 6a7bdbe2f9f72c808d6895aa023b46040acc31a6 Mon Sep 17 00:00:00 2001 From: danipiza Date: Mon, 8 Jun 2026 10:23:57 +0200 Subject: [PATCH 50/55] [#23897] Fixed Ruff and Subscribe.max_duration Signed-off-by: danipiza --- src/vulcanai/core/validator.py | 4 -- src/vulcanai/tools/default_tools.py | 2 +- .../test_default_tools_optional_args.py | 4 +- tests/unittest/test_executor.py | 43 +++++++++++++++++++ 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/vulcanai/core/validator.py b/src/vulcanai/core/validator.py index 5753d4e..d8a1ac3 100644 --- a/src/vulcanai/core/validator.py +++ b/src/vulcanai/core/validator.py @@ -117,12 +117,8 @@ def _validate_step(self, step: Step): expected_type = None for schema in tool.input_schema: if arg.key in schema: - print(f"Schema for arg '{arg.key}' in tool '{tool.name}': {schema}") # Debug print # Use TYPE_ALIAS to map string type names to actual types expected_type = TYPE_ALIAS.get(_base_schema_type(schema[1])) - print( - f"Expected type for arg '{arg.key}' in tool '{tool.name}': {expected_type}" - ) # Debug print break if expected_type and not isinstance(arg.val, expected_type): if expected_type is float and isinstance(arg.val, int): diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index a45ae77..ade2ec8 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -2094,7 +2094,7 @@ class Ros2SubscribeTool(AtomicTool): input_schema = [ ("topic", "string"), # topic name - ("max_duration", "int?"), # (optional) stop after this seconds + ("max_duration", "float?"), # (optional) stop after this seconds ("max_lines", "int?"), # (optional) stop after this number of messages ] input_defaults = {"max_duration": 60, "max_lines": 100} diff --git a/tests/unittest/test_default_tools_optional_args.py b/tests/unittest/test_default_tools_optional_args.py index 4b5c7ed..13b9484 100644 --- a/tests/unittest/test_default_tools_optional_args.py +++ b/tests/unittest/test_default_tools_optional_args.py @@ -266,7 +266,9 @@ def test_param_list_passes_none_when_node_name_is_omitted(self): class TestPublishOptionalArgs(_DefaultToolsOptionalArgsBase): - def _run_publish(self, tool, monotonic_values, message_cls=_StringMessage, future_cls=_NeverCancelledFuture, **kwargs): + def _run_publish( + self, tool, monotonic_values, message_cls=_StringMessage, future_cls=_NeverCancelledFuture, **kwargs + ): imported_types = [] sleep_mock = Mock() diff --git a/tests/unittest/test_executor.py b/tests/unittest/test_executor.py index d09b37a..8fc52ad 100644 --- a/tests/unittest/test_executor.py +++ b/tests/unittest/test_executor.py @@ -187,7 +187,21 @@ class AddTool(self.AtomicTool): def run(self, a: float, b: float): return {"result": a + b} + class SubscribeLikeTool(self.AtomicTool): + name = "subscribe_like" + description = "Records subscription-like optional arguments." + input_schema = [("topic", "string"), ("max_duration", "float?"), ("max_lines", "int?")] + output_schema = {"topic": "string", "max_duration": "float", "max_lines": "int"} + + def run(self, **kwargs): + return { + "topic": kwargs.get("topic"), + "max_duration": kwargs.get("max_duration"), + "max_lines": kwargs.get("max_lines"), + } + self.registry.register_tool(AddTool()) + self.registry.register_tool(SubscribeLikeTool()) self.registry.register_tool(NavTool()) self.registry.register_tool(DetectTool()) self.registry.register_tool(SpeakTool()) @@ -238,6 +252,35 @@ def test_simple_plan_executes(self): self.assertTrue(bb["go_to_pose"]["arrived"]) self.assertEqual(bb["detect_object"]["pose"], {"x": 4.0, "y": 2.0, "z": 0.0}) + def test_optional_float_argument_preserves_fractional_value(self): + """Optional float arguments should keep fractional values through executor binding.""" + plan = self.GlobalPlan( + summary="Preserve a fractional optional duration", + plan=[ + self.PlanNode( + kind="SEQUENCE", + steps=[ + self.Step( + tool="subscribe_like", + args=[ + self.Arg(key="topic", val="/demo_topic"), + self.Arg(key="max_duration", val=0.5), + self.Arg(key="max_lines", val=2), + ], + ), + ], + ) + ], + ) + + result = self.exec.run(plan, {}) + self.assertTrue(result["success"]) + bb = result["blackboard"] + self.assertEqual(bb["subscribe_like"]["topic"], "/demo_topic") + self.assertEqual(bb["subscribe_like"]["max_duration"], 0.5) + self.assertIsInstance(bb["subscribe_like"]["max_duration"], float) + self.assertEqual(bb["subscribe_like"]["max_lines"], 2) + def test_false_condition_skips_step(self): """ Test that a step with a False condition is skipped and a non-existing blackboard entry From 4c3012b9fe74dff9a607e3fc4cbdbe57d64bb1c2 Mon Sep 17 00:00:00 2001 From: danipiza Date: Mon, 15 Jun 2026 16:17:14 +0200 Subject: [PATCH 51/55] [#23897] Added Keyboard support to the modalscreens (e.g.: /edit_tools) Signed-off-by: danipiza --- src/vulcanai/console/modal_screens.py | 97 ++++++++++++++++++---- tests/unittest/test_console_regressions.py | 17 ++++ 2 files changed, 98 insertions(+), 16 deletions(-) diff --git a/src/vulcanai/console/modal_screens.py b/src/vulcanai/console/modal_screens.py index 7dbd14d..f7fb444 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.binding import Binding from textual.containers import Container, Horizontal, Vertical, VerticalScroll from textual.content import Content from textual.screen import ModalScreen @@ -145,6 +146,28 @@ async def on_key(self, event: events.Key) -> None: class CheckListModal(ModalScreen[list[str] | None]): + class NavigableCheckbox(Checkbox): + BINDINGS = [ + Binding("enter", "submit_selection", "Submit", show=False), + Binding("space", "toggle_button", "Toggle", show=False), + Binding("down", "next_checkbox", "Next checkbox", show=False), + Binding("up", "previous_checkbox", "Previous checkbox", show=False), + ] + + def action_submit_selection(self) -> None: + self.screen.dismiss_selected() + + def action_next_checkbox(self) -> None: + self.screen.action_next_checkbox() + + def action_previous_checkbox(self) -> None: + self.screen.action_previous_checkbox() + + BINDINGS = [ + Binding("down", "next_checkbox", "Next checkbox", show=False), + Binding("up", "previous_checkbox", "Previous checkbox", show=False), + ] + CSS = """ CheckListModal { align: center middle; @@ -210,7 +233,7 @@ def compose(self) -> ComposeResult: # Standalone tool cb_id = f"cb{idx}" self._id_to_tool[cb_id] = group_name - yield Checkbox( + yield self.NavigableCheckbox( group_name, value=group_name in self.active_tools, id=cb_id, @@ -233,11 +256,11 @@ def compose(self) -> ComposeResult: self._child_to_parent[cid] = parent_id all_active = all(f"{group_name}_{s}" in self.active_tools for s in subtools) - yield Checkbox(group_name, value=all_active, id=parent_id) + yield self.NavigableCheckbox(group_name, value=all_active, id=parent_id) for subtool, child_id in zip(subtools, child_ids): full_name = f"{group_name}_{subtool}" - yield Checkbox( + yield self.NavigableCheckbox( subtool, value=full_name in self.active_tools, id=child_id, @@ -266,15 +289,37 @@ def on_checkbox_changed(self, event: Checkbox.Changed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "submit": - selected = [ - tool_name - for cb_id, tool_name in self._id_to_tool.items() - if self.query_one(f"#{cb_id}", Checkbox).value - ] - self.dismiss(selected) + self.dismiss_selected() elif event.button.id == "cancel": self.dismiss(None) + def dismiss_selected(self) -> None: + selected = [ + tool_name + for cb_id, tool_name in self._id_to_tool.items() + if self.query_one(f"#{cb_id}", Checkbox).value + ] + self.dismiss(selected) + + def _move_checkbox_focus(self, step: int) -> None: + checkboxes = list(self.query(Checkbox)) + if not checkboxes: + return + + if isinstance(self.focused, Checkbox) and self.focused in checkboxes: + current_index = checkboxes.index(self.focused) + target_index = max(0, min(len(checkboxes) - 1, current_index + step)) + else: + target_index = 0 if step > 0 else len(checkboxes) - 1 + + self.set_focus(checkboxes[target_index]) + + def action_next_checkbox(self) -> None: + self._move_checkbox_focus(1) + + def action_previous_checkbox(self) -> None: + self._move_checkbox_focus(-1) + def on_mount(self) -> None: first_cb = self.query_one(Checkbox) self.set_focus(first_cb) @@ -293,6 +338,17 @@ def _button(self) -> Content: (" ", button_style), ) + class SuggestionRadioSet(RadioSet): + BINDINGS = [ + Binding("down,right", "next_button", "Next option", show=False), + Binding("enter", "submit_selection", "Submit", show=False), + Binding("space", "toggle_button", "Toggle", show=False), + Binding("up,left", "previous_button", "Previous option", show=False), + ] + + def action_submit_selection(self) -> None: + self.screen.dismiss_selected() + CSS = """ RadioListModal { align: center middle; @@ -343,7 +399,7 @@ def compose(self) -> ComposeResult: # One-select radio list with VerticalScroll(classes="radio-list"): - with RadioSet(id="radio-set"): + with self.SuggestionRadioSet(id="radio-set"): for i, line in enumerate(self.lines): yield self.SquareRadioButton(line, id=f"rb{i}", value=(i == self.default_index)) @@ -353,14 +409,23 @@ def compose(self) -> ComposeResult: yield Button("Submit", variant="primary", id="submit") def on_mount(self) -> None: - first_rb = self.query_one(self.SquareRadioButton) - self.set_focus(first_rb) + radio_set = self.query_one("#radio-set", RadioSet) + self.set_focus(radio_set) + + def dismiss_selected(self) -> None: + radioset = self.query_one("#radio-set", RadioSet) + selected = radioset.pressed_index + highlighted = getattr(radioset, "_selected", None) + + # Arrow navigation changes the highlighted row before the radio value updates. + if highlighted is not None and highlighted != selected: + selected = highlighted + + if selected >= 0: + self.dismiss(selected) def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "submit": - radioset = self.query_one("#radio-set", RadioSet) - selected = radioset.pressed_index - if selected is not None: - self.dismiss(selected) + self.dismiss_selected() else: self.dismiss(None) diff --git a/tests/unittest/test_console_regressions.py b/tests/unittest/test_console_regressions.py index 6deb594..0ad4482 100644 --- a/tests/unittest/test_console_regressions.py +++ b/tests/unittest/test_console_regressions.py @@ -65,6 +65,7 @@ class TestConsoleRegressions(unittest.IsolatedAsyncioTestCase): def setUpClass(cls): cls.console_mod = importlib.import_module("vulcanai.console.console") cls.logger_mod = importlib.import_module("vulcanai.console.logger") + cls.modal_screens_mod = importlib.import_module("vulcanai.console.modal_screens") def setUp(self): self._default_logger_instance = self.logger_mod.VulcanAILogger._default_instance @@ -133,6 +134,22 @@ async def fake_queriestrap(user_input): self.assertEqual(input_widget.value, "") self.assertTrue(input_widget.focused) + async def test_modal_enter_binding(self): + """Suggestion modal should accept the highlighted item when Enter is used.""" + modal = self.modal_screens_mod.RadioListModal(["alpha", "beta", "gamma"]) + modal.dismiss = Mock() + modal.query_one = Mock(return_value=SimpleNamespace(pressed_index=0, _selected=2)) + + modal.dismiss_selected() + + modal.dismiss.assert_called_once_with(2) + bindings = { + (binding.key, binding.action) + for binding in self.modal_screens_mod.RadioListModal.SuggestionRadioSet.BINDINGS + } + self.assertIn(("enter", "submit_selection"), bindings) + self.assertIn(("space", "toggle_button"), bindings) + if __name__ == "__main__": unittest.main() From d0aa5d05f9717bf2785b232291b9657800d3c9d9 Mon Sep 17 00:00:00 2001 From: danipiza Date: Mon, 15 Jun 2026 16:35:28 +0200 Subject: [PATCH 52/55] [#23897] Added toggle all/default tools buttom in '/edit_tools' Signed-off-by: danipiza --- src/vulcanai/console/console.py | 21 ++++-- src/vulcanai/console/modal_screens.py | 43 ++++++++++-- tests/unittest/test_console_regressions.py | 76 ++++++++++++++++++++++ 3 files changed, 130 insertions(+), 10 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index 19618eb..5c49e8f 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -626,13 +626,13 @@ def _update_variables_panel(self) -> None: kvalue_widget.update(text) @work # Runs in a worker. waiting won't freeze the UI - async def open_checklist(self, grouped: list, active_set: set) -> None: + async def open_checklist(self, grouped: list, active_set: set, default_set: set | None = None) -> None: """ Function used to open a Checklist ModalScreen in the console. Used in the /edit_tools command. """ # Create the checklist dialog - selected = await self.push_screen_wait(CheckListModal(grouped, active_set)) + selected = await self.push_screen_wait(CheckListModal(grouped, active_set, default_set)) if selected is None: self.logger.log_msg("Selection cancelled.") @@ -647,10 +647,10 @@ async def open_checklist(self, grouped: list, active_set: set) -> None: for tool_name in all_tool_names: if tool_name in selected_set: if self.manager.registry.activate_tool(tool_name): - self.logger.log_console(f"Activated tool '{tool_name}'") + self.logger.log_console(self._tool_toggle_log_message(tool_name, activated=True)) else: if self.manager.registry.deactivate_tool(tool_name): - self.logger.log_console(f"Deactivated tool '{tool_name}'") + self.logger.log_console(self._tool_toggle_log_message(tool_name, activated=False)) @work async def open_radiolist( @@ -750,6 +750,16 @@ def get_tool_summary(tool) -> str: self.logger.log_console(tool_msg, "console") + def _is_default_tool(self, tool_name: str) -> bool: + tool = self.manager.registry._get_tool_by_name(tool_name) + return tool is not None and tool.__class__.__module__ == "vulcanai.tools.default_tools" + + def _tool_toggle_log_message(self, tool_name: str, activated: bool) -> str: + action = "Activated" if activated else "Deactivated" + if self._is_default_tool(tool_name): + return f"{action} default tool '{tool_name}'" + return f"{action} tool '{tool_name}'" + def cmd_edit_tools(self, _) -> None: all_names = sorted( n @@ -757,8 +767,9 @@ def cmd_edit_tools(self, _) -> None: if n != "help" ) active_set = set(self.manager.registry.tools.keys()) + default_set = {name for name in all_names if self._is_default_tool(name)} grouped = self.manager.registry.group_tool_names(all_names) - self.open_checklist(grouped, active_set) + self.open_checklist(grouped, active_set, default_set) def cmd_change_k(self, args) -> None: if len(args) == 0: diff --git a/src/vulcanai/console/modal_screens.py b/src/vulcanai/console/modal_screens.py index f7fb444..9dff142 100644 --- a/src/vulcanai/console/modal_screens.py +++ b/src/vulcanai/console/modal_screens.py @@ -204,20 +204,21 @@ def action_previous_checkbox(self) -> None: width: 100%; margin-top: 1; padding: 0; - content-align: center middle; - align-horizontal: center; + content-align: right middle; + align-horizontal: right; } .btns Button { - padding: 0 3; - margin: 0 2; + padding: 0 2; + margin-left: 1; } """ - def __init__(self, grouped: list, active_tools: set) -> None: + def __init__(self, grouped: list, active_tools: set, default_tools: set[str] | None = None) -> None: super().__init__() self.grouped = grouped # [(prefix, [subtools]) | (name, None), ...] self.active_tools = active_tools + self.default_tools = default_tools or set() self._parent_to_children: dict[str, list[str]] = {} self._child_to_parent: dict[str, str] = {} self._id_to_tool: dict[str, str] = {} # cb_id -> full tool name (children & standalone only) @@ -268,6 +269,8 @@ def compose(self) -> ComposeResult: ) with Horizontal(classes="btns"): + yield Button("Toggle Default Tools", variant="default", id="toggle-default") + yield Button("Toggle All Tools", variant="default", id="toggle-all") yield Button("Cancel", variant="default", id="cancel") yield Button("Submit", variant="primary", id="submit") @@ -290,6 +293,10 @@ def on_checkbox_changed(self, event: Checkbox.Changed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "submit": self.dismiss_selected() + elif event.button.id == "toggle-all": + self._toggle_tool_subset(set(self._id_to_tool.values())) + elif event.button.id == "toggle-default": + self._toggle_tool_subset(self.default_tools) elif event.button.id == "cancel": self.dismiss(None) @@ -301,6 +308,32 @@ def dismiss_selected(self) -> None: ] self.dismiss(selected) + def _set_tool_subset(self, tool_names: set[str], enabled: bool) -> None: + matched_tool_names = {tool_name for tool_name in tool_names if tool_name in self._id_to_tool.values()} + if not matched_tool_names: + return + + with self.prevent(Checkbox.Changed): + for cb_id, tool_name in self._id_to_tool.items(): + if tool_name in matched_tool_names: + self.query_one(f"#{cb_id}", Checkbox).value = enabled + + for parent_id, child_ids in self._parent_to_children.items(): + all_checked = all(self.query_one(f"#{cid}", Checkbox).value for cid in child_ids) + self.query_one(f"#{parent_id}", Checkbox).value = all_checked + + def _toggle_tool_subset(self, tool_names: set[str]) -> None: + matched_tool_names = {tool_name for tool_name in tool_names if tool_name in self._id_to_tool.values()} + if not matched_tool_names: + return + + should_enable = not all( + self.query_one(f"#{cb_id}", Checkbox).value + for cb_id, tool_name in self._id_to_tool.items() + if tool_name in matched_tool_names + ) + self._set_tool_subset(matched_tool_names, should_enable) + def _move_checkbox_focus(self, step: int) -> None: checkboxes = list(self.query(Checkbox)) if not checkboxes: diff --git a/tests/unittest/test_console_regressions.py b/tests/unittest/test_console_regressions.py index 0ad4482..7c32305 100644 --- a/tests/unittest/test_console_regressions.py +++ b/tests/unittest/test_console_regressions.py @@ -150,6 +150,82 @@ async def test_modal_enter_binding(self): self.assertIn(("enter", "submit_selection"), bindings) self.assertIn(("space", "toggle_button"), bindings) + def test_cmd_edit_tools_passes_default_tool_subset_to_modal(self): + """`/edit_tools` should expose built-in default tools to the checklist modal.""" + + class DefaultTool: + pass + + class CustomTool: + pass + + DefaultTool.__module__ = "vulcanai.tools.default_tools" + CustomTool.__module__ = "demo.custom_tools" + + default_active = DefaultTool() + default_inactive = DefaultTool() + custom_tool = CustomTool() + + tool_lookup = { + "ros2_topic_list": default_active, + "ros2_node_info": default_inactive, + "custom_tool": custom_tool, + } + registry = SimpleNamespace( + tools={"ros2_topic_list": default_active, "custom_tool": custom_tool}, + deactivated_tools={"ros2_node_info": default_inactive}, + group_tool_names=Mock(return_value=[("custom_tool", None)]), + _get_tool_by_name=lambda name: tool_lookup[name], + ) + + console = self.console_mod.VulcanConsole(default_tools=False) + console.manager = SimpleNamespace(registry=registry) + console.open_checklist = Mock() + + console.cmd_edit_tools([]) + + registry.group_tool_names.assert_called_once_with(["custom_tool", "ros2_node_info", "ros2_topic_list"]) + console.open_checklist.assert_called_once_with( + [("custom_tool", None)], + {"ros2_topic_list", "custom_tool"}, + {"ros2_topic_list", "ros2_node_info"}, + ) + + def test_tool_toggle_log_message_marks_default_tools(self): + """Activation logs should label built-in tools as default tools.""" + + class DefaultTool: + pass + + class CustomTool: + pass + + DefaultTool.__module__ = "vulcanai.tools.default_tools" + CustomTool.__module__ = "demo.custom_tools" + + registry = SimpleNamespace( + _get_tool_by_name=lambda name: { + "ros2_topic_list": DefaultTool(), + "custom_tool": CustomTool(), + }.get(name) + ) + + console = self.console_mod.VulcanConsole(default_tools=False) + console.manager = SimpleNamespace(registry=registry) + + self.assertEqual( + console._tool_toggle_log_message("ros2_topic_list", activated=True), + "Activated default tool 'ros2_topic_list'", + ) + self.assertEqual( + console._tool_toggle_log_message("ros2_topic_list", activated=False), + "Deactivated default tool 'ros2_topic_list'", + ) + self.assertEqual( + console._tool_toggle_log_message("custom_tool", activated=True), + "Activated tool 'custom_tool'", + ) + if __name__ == "__main__": unittest.main() From f8d5acfdfcdc7524811db674c8003e4ac402d41e Mon Sep 17 00:00:00 2001 From: danipiza Date: Tue, 16 Jun 2026 07:11:50 +0200 Subject: [PATCH 53/55] [#23897] Applied ruff Signed-off-by: danipiza --- src/vulcanai/console/modal_screens.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/vulcanai/console/modal_screens.py b/src/vulcanai/console/modal_screens.py index 9dff142..fbbc47a 100644 --- a/src/vulcanai/console/modal_screens.py +++ b/src/vulcanai/console/modal_screens.py @@ -302,9 +302,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: def dismiss_selected(self) -> None: selected = [ - tool_name - for cb_id, tool_name in self._id_to_tool.items() - if self.query_one(f"#{cb_id}", Checkbox).value + tool_name for cb_id, tool_name in self._id_to_tool.items() if self.query_one(f"#{cb_id}", Checkbox).value ] self.dismiss(selected) From fe77d61824cf45c536c7b2e1adbc5b648a4eee36 Mon Sep 17 00:00:00 2001 From: danipiza Date: Thu, 18 Jun 2026 07:56:37 +0200 Subject: [PATCH 54/55] [#23897] Fixed 'test_publish_with_max_lines' flaky test Signed-off-by: danipiza --- src/vulcanai/tools/default_tools.py | 29 +++++++++++----- .../test_default_tools_optional_args.py | 34 ++++++++++++++++++- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index ade2ec8..a5f3f1d 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -281,6 +281,14 @@ def _get_node_spin_lock(node): return spin_lock +def _node_spins_in_background(node) -> bool: + if node is None: + return False + + spin_thread = getattr(node, "_vulcanai_spin_thread", None) + return spin_thread is not None and spin_thread.is_alive() + + def _normalize_command(command): if command is None: raise ValueError("`command` is required.") @@ -2032,15 +2040,20 @@ def run(self, **kwargs): published_msgs.append(msg.data if hasattr(msg, "data") else str(msg)) published_count += 1 - if hasattr(node, "spin_once"): - node.spin_once(timeout_sec=0.05) - else: - spin_lock = _get_node_spin_lock(node) - if spin_lock is None: - rclpy.spin_once(node, timeout_sec=0.05) + # Publishing does not require a blocking spin. + # When the helper node already owns a background spinner, + # competing for the same executor lock here can starve + # this loop long enough for 'max_duration' to win before 'max_lines' + if not _node_spins_in_background(node): + if hasattr(node, "spin_once"): + node.spin_once(timeout_sec=0.0) else: - with spin_lock: - rclpy.spin_once(node, timeout_sec=0.05) + spin_lock = _get_node_spin_lock(node) + if spin_lock is None: + rclpy.spin_once(node, timeout_sec=0.0) + else: + with spin_lock: + rclpy.spin_once(node, timeout_sec=0.0) if period_sec and period_sec > 0.0: next_publish_time += period_sec diff --git a/tests/unittest/test_default_tools_optional_args.py b/tests/unittest/test_default_tools_optional_args.py index 13b9484..723c59a 100644 --- a/tests/unittest/test_default_tools_optional_args.py +++ b/tests/unittest/test_default_tools_optional_args.py @@ -145,6 +145,20 @@ def get_logger(self): return self.logger +class _AliveSpinThread: + def is_alive(self): + return True + + +class _BackgroundSpinningNode(_FakeNode): + def __init__(self): + super().__init__() + self._vulcanai_spin_thread = _AliveSpinThread() + + def spin_once(self, timeout_sec=0.1): + raise AssertionError("publish loop should not manually spin a node with a background spinner") + + class _NeverCancelledFuture: def cancelled(self): return False @@ -271,6 +285,7 @@ def _run_publish( ): imported_types = [] sleep_mock = Mock() + active_node = tool.bb["main_node"] def fake_import_msg_type(type_str, node): imported_types.append(type_str) @@ -288,7 +303,7 @@ def fake_import_msg_type(type_str, node): ): result = tool.run(**kwargs) - publisher = self.node.publishers[-1] if self.node.publishers else None + publisher = active_node.publishers[-1] if active_node.publishers else None return result, publisher, imported_types, sleep_mock def test_publish_defaults_msg_type_when_omitted(self): @@ -424,6 +439,23 @@ def test_publish_negative_period_sec_falls_back_to_default(self): self.assertEqual(len(publisher.messages), 2) sleep_mock.assert_called() + def test_publish_skips_manual_spin_when_node_already_spins_in_background(self): + tool = self._make_tool("Ros2PublishTool") + tool.bb = {"console": self.console, "main_node": _BackgroundSpinningNode()} + + result, publisher, _, _ = self._run_publish( + tool, + monotonic_values=[0.0, 0.0, 0.1], + topic="/demo_publish", + message_data="hello", + max_lines=2, + max_duration=10.0, + period_sec=0.0, + ) + + self.assertEqual(result["count"], 2) + self.assertEqual(len(publisher.messages), 2) + def test_publish_valid_custom_message_json_populates_message(self): tool = self._make_tool("Ros2PublishTool") From 3cc3c16964382ed8901f3217e8440bb06f0c8c60 Mon Sep 17 00:00:00 2001 From: danipiza Date: Fri, 19 Jun 2026 12:27:41 +0200 Subject: [PATCH 55/55] [#23897] Fixed terminal focus Signed-off-by: danipiza --- src/vulcanai/console/console.py | 8 +- .../console/widget_custom_log_text_area.py | 139 ++++++++++++------ src/vulcanai/console/widget_spinner.py | 8 +- src/vulcanai/tools/default_tools.py | 4 +- tests/unittest/test_console_regressions.py | 72 ++++++++- 5 files changed, 175 insertions(+), 56 deletions(-) diff --git a/src/vulcanai/console/console.py b/src/vulcanai/console/console.py index 5c49e8f..8ba2ce0 100644 --- a/src/vulcanai/console/console.py +++ b/src/vulcanai/console/console.py @@ -939,7 +939,7 @@ def show_subprocess_panel(self, show_notice: bool = True) -> None: follow_main_output = False if self.main_pannel is not None: - follow_main_output = self.main_pannel.is_near_vertical_scroll_end(tolerance=2) + follow_main_output = self.main_pannel.should_follow_output() if show_notice: self.logger.log_tool( @@ -1022,7 +1022,7 @@ def write_deferred_main_output(self) -> None: if not self._deferred_main_lines_after_stream or self.main_pannel is None: return - follow_main_output = self.main_pannel.is_near_vertical_scroll_end(tolerance=2) + follow_main_output = self.main_pannel.should_follow_output() pending = self._deferred_main_lines_after_stream self._deferred_main_lines_after_stream = [] @@ -1040,7 +1040,7 @@ def hide_subprocess_panel(self) -> None: follow_main_output = False if self.main_pannel is not None: - follow_main_output = self.main_pannel.is_near_vertical_scroll_end(tolerance=2) + follow_main_output = self.main_pannel.should_follow_output() self.stream_pannel.display = False self.stream_pannel.styles.height = 0 @@ -1110,7 +1110,7 @@ def add_line(self, input: str, color: str = "", subprocess_flag: bool = False) - force_follow_main = False if target_panel is self.main_pannel: - force_follow_main = self.main_pannel.is_near_vertical_scroll_end(tolerance=2) + force_follow_main = self.main_pannel.should_follow_output() # Append each line; deque automatically truncates old ones for line in lines: diff --git a/src/vulcanai/console/widget_custom_log_text_area.py b/src/vulcanai/console/widget_custom_log_text_area.py index 66d8fc3..345779c 100644 --- a/src/vulcanai/console/widget_custom_log_text_area.py +++ b/src/vulcanai/console/widget_custom_log_text_area.py @@ -16,6 +16,7 @@ import re import threading from collections import defaultdict, deque +from contextlib import contextmanager import pyperclip from rich.style import Style @@ -70,6 +71,11 @@ def __init__(self, **kwargs): # A row with N styles # (e.g.: N = 2: two colors, one bold and color) # will have N entries. self._lines_styles = deque(maxlen=self.MAX_LINES) + # Sticky terminal-like follow state + # When True, new output should keep the viewport anchored to the end + self._follow_output = True + # Suppress follow-state updates while we are applying programmatic scrolls + self._suspend_follow_tracking = 0 # region UTILS @@ -84,6 +90,50 @@ def is_near_vertical_scroll_end(self, tolerance: int = 1) -> bool: return True return (self.max_scroll_y - self.scroll_offset.y) <= max(0, tolerance) + def should_follow_output(self) -> bool: + """ + Return True when the log should keep following new output. + """ + return self._follow_output + + def _update_follow_output_from_viewport(self, tolerance: int = 1) -> None: + """ + Refresh sticky follow state from the current viewport position. + """ + self._follow_output = self.is_near_vertical_scroll_end(tolerance=tolerance) + + @contextmanager + def _suspend_follow_output_tracking(self): + """ + Temporarily ignore viewport changes triggered by programmatic scrolling. + """ + self._suspend_follow_tracking += 1 + try: + yield + finally: + self._suspend_follow_tracking = max(0, self._suspend_follow_tracking - 1) + + def _scroll_to_output_end(self) -> None: + """ + Anchor the viewport to the end and keep follow mode enabled. + """ + self._follow_output = True + with self._suspend_follow_output_tracking(): + self.scroll_end(animate=False, immediate=True, x_axis=False) + + def _restore_scroll_position(self, x: int, y: int) -> None: + """ + Restore the viewport to a previous position and keep follow mode disabled. + """ + self._follow_output = False + with self._suspend_follow_output_tracking(): + self.scroll_to(x=x, y=y, animate=False, immediate=True, force=True) + + def _watch_scroll_y(self) -> None: + super()._watch_scroll_y() + if self._suspend_follow_tracking == 0: + self._update_follow_output_from_viewport() + def _trim_highlights(self) -> None: """ Function used to trim the CustomLogTextArea to the maximum number of lines. @@ -296,55 +346,43 @@ def append_line(self, text: str, force_follow_output: bool = False) -> bool: # [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 = force_follow_output or self.is_near_vertical_scroll_end() + # keep following output only if the user was already following output. + should_follow_output = force_follow_output or self.should_follow_output() previous_scroll_x = self.scroll_offset.x previous_scroll_y = self.scroll_offset.y - # Append via document API to keep row tracking consistent - # Only add a newline before the new line if there is already content - insert_text = ("\n" if self.document.text else "") + plain - self.insert(insert_text, location=self.document.end) + with self._suspend_follow_output_tracking(): + # Append via document API to keep row tracking consistent + # Only add a newline before the new line if there is already content + insert_text = ("\n" if self.document.text else "") + plain + self.insert(insert_text, location=self.document.end) - # Track styles for the new line (always at the end) - self._line_count += 1 + # Track styles for the new line (always at the end) + self._line_count += 1 - if self._line_count > self.MAX_LINES: - self._highlights.pop(self._line_count - self.MAX_LINES, None) + if self._line_count > self.MAX_LINES: + self._highlights.pop(self._line_count - self.MAX_LINES, None) - # Store styles - # Each line may have multiple styles (spans) - current_line = [] - for start, end, token in spans: - current_line.append((start, end, token)) + # Store styles + # Each line may have multiple styles (spans) + current_line = [] + for start, end, token in spans: + current_line.append((start, end, token)) - self._lines_styles.append(current_line) + self._lines_styles.append(current_line) - # Trim now - self._trim_highlights() + # Trim now + self._trim_highlights() # Scroll to end only when the user was already at the bottom. if should_follow_output: - self.scroll_end(animate=False, immediate=True, x_axis=False) + self._scroll_to_output_end() # Ensure we stay anchored after any pending layout updates. - self.call_after_refresh(self.scroll_end, animate=False, immediate=True, x_axis=False) + self.call_after_refresh(self._scroll_to_output_end) else: # Keep user viewport stable while new lines arrive in the background - self.scroll_to( - x=previous_scroll_x, - y=previous_scroll_y, - animate=False, - immediate=True, - force=True, - ) - self.call_after_refresh( - self.scroll_to, - x=previous_scroll_x, - y=previous_scroll_y, - animate=False, - immediate=True, - force=True, - ) + self._restore_scroll_position(previous_scroll_x, previous_scroll_y) + self.call_after_refresh(self._restore_scroll_position, previous_scroll_x, previous_scroll_y) # Rebuild highlights and refresh self._rebuild_highlights() @@ -362,27 +400,34 @@ def delete_last_row(self) -> None: # No lines, does nothing. return + should_follow_output = self.should_follow_output() + last_row = self._line_count - 1 if last_row > 0: # Delete the newline before the last line + the line itself - self.replace( - "", - start=(last_row - 1, len(self.document.get_line(last_row - 1))), - end=(last_row, len(self.document.get_line(last_row))), - ) + with self._suspend_follow_output_tracking(): + self.replace( + "", + start=(last_row - 1, len(self.document.get_line(last_row - 1))), + end=(last_row, len(self.document.get_line(last_row))), + ) else: # Only one line - self.replace( - "", - start=(0, 0), - end=(0, len(self.document.get_line(0))), - ) + with self._suspend_follow_output_tracking(): + self.replace( + "", + start=(0, 0), + end=(0, len(self.document.get_line(0))), + ) # Decrease line count and remove styles self._line_count -= 1 self._lines_styles.pop() + if should_follow_output: + self._scroll_to_output_end() + # Rebuild highlights and refresh self._rebuild_highlights() self.refresh() @@ -401,10 +446,12 @@ def clear_console(self) -> None: # Clear the document self._line_count = 0 self._lines_styles.clear() + self._follow_output = True # Refresh and clear the TextArea self.refresh() - self.clear() + with self._suspend_follow_output_tracking(): + self.clear() def action_copy_selection(self) -> None: """ diff --git a/src/vulcanai/console/widget_spinner.py b/src/vulcanai/console/widget_spinner.py index 33c5c97..8da665a 100644 --- a/src/vulcanai/console/widget_spinner.py +++ b/src/vulcanai/console/widget_spinner.py @@ -50,7 +50,9 @@ def _log_is_filling_space(self) -> bool: 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"): + if hasattr(self.logcontent, "should_follow_output"): + was_at_bottom = self.logcontent.should_follow_output() + elif hasattr(self.logcontent, "is_near_vertical_scroll_end"): was_at_bottom = self.logcontent.is_near_vertical_scroll_end() else: was_at_bottom = self.logcontent.is_vertical_scroll_end @@ -73,7 +75,9 @@ def start(self, text: str = "Querying LLM...") -> None: 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"): + if hasattr(self.logcontent, "should_follow_output"): + was_at_bottom = self.logcontent.should_follow_output() + elif hasattr(self.logcontent, "is_near_vertical_scroll_end"): was_at_bottom = self.logcontent.is_near_vertical_scroll_end() else: was_at_bottom = self.logcontent.is_vertical_scroll_end diff --git a/src/vulcanai/tools/default_tools.py b/src/vulcanai/tools/default_tools.py index a5f3f1d..ab983b3 100644 --- a/src/vulcanai/tools/default_tools.py +++ b/src/vulcanai/tools/default_tools.py @@ -2040,8 +2040,8 @@ def run(self, **kwargs): published_msgs.append(msg.data if hasattr(msg, "data") else str(msg)) published_count += 1 - # Publishing does not require a blocking spin. - # When the helper node already owns a background spinner, + # Publishing does not require a blocking spin. + # When the helper node already owns a background spinner, # competing for the same executor lock here can starve # this loop long enough for 'max_duration' to win before 'max_lines' if not _node_spins_in_background(node): diff --git a/tests/unittest/test_console_regressions.py b/tests/unittest/test_console_regressions.py index 7c32305..4714e2e 100644 --- a/tests/unittest/test_console_regressions.py +++ b/tests/unittest/test_console_regressions.py @@ -19,7 +19,9 @@ import types import unittest from types import SimpleNamespace -from unittest.mock import Mock +from unittest.mock import Mock, PropertyMock, patch + +from textual.geometry import Offset class _DummySentenceTransformer: @@ -66,6 +68,7 @@ def setUpClass(cls): cls.console_mod = importlib.import_module("vulcanai.console.console") cls.logger_mod = importlib.import_module("vulcanai.console.logger") cls.modal_screens_mod = importlib.import_module("vulcanai.console.modal_screens") + cls.log_text_area_mod = importlib.import_module("vulcanai.console.widget_custom_log_text_area") def setUp(self): self._default_logger_instance = self.logger_mod.VulcanAILogger._default_instance @@ -150,6 +153,53 @@ async def test_modal_enter_binding(self): self.assertIn(("enter", "submit_selection"), bindings) self.assertIn(("space", "toggle_button"), bindings) + def test_log_text_area_keeps_following_output_when_sticky_follow_is_enabled(self): + """Appending output should stay anchored even if a one-shot end check would fail.""" + widget = self.log_text_area_mod.CustomLogTextArea() + widget.is_near_vertical_scroll_end = Mock(return_value=False) + widget.scroll_end = Mock() + widget.scroll_to = Mock() + widget.call_after_refresh = Mock() + widget.refresh = Mock() + + widget.append_line("hello") + + self.assertEqual(widget.document.text, "hello") + self.assertTrue(widget.should_follow_output()) + widget.scroll_end.assert_called_once_with(animate=False, immediate=True, x_axis=False) + widget.scroll_to.assert_not_called() + widget.call_after_refresh.assert_called_once_with(widget._scroll_to_output_end) + + def test_log_text_area_preserves_viewport_when_follow_is_disabled(self): + """Appending output should not yank the viewport when the user scrolled up.""" + widget = self.log_text_area_mod.CustomLogTextArea() + widget._follow_output = False + widget.scroll_end = Mock() + widget.scroll_to = Mock() + widget.call_after_refresh = Mock() + widget.refresh = Mock() + + with patch.object(type(widget), "scroll_offset", new_callable=PropertyMock, return_value=Offset(3, 7)): + widget.append_line("hello") + + self.assertFalse(widget.should_follow_output()) + widget.scroll_end.assert_not_called() + widget.scroll_to.assert_called_once_with(x=3, y=7, animate=False, immediate=True, force=True) + widget.call_after_refresh.assert_called_once_with(widget._restore_scroll_position, 3, 7) + + def test_log_text_area_follow_state_updates_when_user_moves_viewport(self): + """Returning to the end should resume sticky follow mode.""" + widget = self.log_text_area_mod.CustomLogTextArea() + widget._follow_output = False + widget.is_near_vertical_scroll_end = Mock(return_value=True) + + widget._update_follow_output_from_viewport() + self.assertTrue(widget.should_follow_output()) + + widget.is_near_vertical_scroll_end.return_value = False + widget._update_follow_output_from_viewport() + self.assertFalse(widget.should_follow_output()) + def test_cmd_edit_tools_passes_default_tool_subset_to_modal(self): """`/edit_tools` should expose built-in default tools to the checklist modal.""" @@ -223,8 +273,26 @@ class CustomTool: ) self.assertEqual( console._tool_toggle_log_message("custom_tool", activated=True), - "Activated tool 'custom_tool'", + "Activated tool 'custom_tool'", + ) + + def test_add_line_uses_sticky_follow_state_for_main_log(self): + """Main log writes should rely on the shared sticky follow state.""" + console = self.console_mod.VulcanConsole(default_tools=False) + console.main_pannel = SimpleNamespace( + should_follow_output=Mock(return_value=True), + append_line=Mock(return_value=True), ) + console.stream_pannel = None + console.logger = Mock() + console._route_logs_to_stream_panel = 0 + console._stream_panel_visible = False + console._is_stream_task_active = Mock(return_value=False) + + console.add_line("hello") + + console.main_pannel.should_follow_output.assert_called_once_with() + console.main_pannel.append_line.assert_called_once_with("hello", force_follow_output=True) if __name__ == "__main__":