├── images ├── gpt-4o-mini │ ├── demo.txt │ └── level3_star_shape_made_of_lines.png └── gemini-2.0-flash │ └── demo.txt ├── resource └── turtlesim_agent ├── turtlesim_agent ├── __init__.py ├── tools │ ├── __init__.py │ ├── all_tools.py │ ├── math_tools.py │ ├── pen_tools.py │ ├── sim_tools.py │ ├── status_tools.py │ └── motion_tools.py ├── interface │ ├── __init__.py │ ├── base_interface.py │ ├── gui_interface.py │ └── chat_gui.py ├── utils.py ├── turtlesim_node_entrypoint.py ├── main.py ├── chat_entrypoint.py ├── prompts.py ├── llms.py └── turtlesim_node.py ├── setup.cfg ├── .github ├── ISSUE_TEMPLATE │ ├── custom.md │ ├── feature_request.md │ └── bug_report.md ├── pull_request_template.md └── workflows │ └── ros2_ci.yml ├── .vscode └── settings.json ├── requirements.txt ├── launch └── turtlesim_agent.launch.xml ├── .gitignore ├── test ├── test_pep257.py ├── test_flake8.py └── test_copyright.py ├── package.xml ├── setup.py ├── LICENSE └── README.md /images/gpt-4o-mini/demo.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resource/turtlesim_agent: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /turtlesim_agent/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /images/gemini-2.0-flash/demo.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /turtlesim_agent/tools/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /turtlesim_agent/interface/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [develop] 2 | script_dir=$base/lib/turtlesim_agent 3 | [install] 4 | install_scripts=$base/lib/turtlesim_agent 5 | -------------------------------------------------------------------------------- /images/gpt-4o-mini/level3_star_shape_made_of_lines.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yutarop/turtlesim_agent/HEAD/images/gpt-4o-mini/level3_star_shape_made_of_lines.png -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.defaultFormatter": "ms-python.black-formatter", 4 | "editor.formatOnSave": true, 5 | "editor.codeActionsOnSave": { 6 | "source.organizeImports": "explicit" 7 | }, 8 | }, 9 | } -------------------------------------------------------------------------------- /turtlesim_agent/interface/base_interface.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class BaseAgentInterface(ABC): 5 | @abstractmethod 6 | def send_user_input(self, user_input: str) -> str: 7 | """Send user input to the agent and return the response.""" 8 | 9 | @abstractmethod 10 | def shutdown(self): 11 | """Perform cleanup operations when shutting down.""" 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | langchain==0.3.25 2 | langchain-openai==0.3.16 3 | langchain-anthropic==0.3.13 4 | langchain-ollama==0.3.2 5 | langchain-google-genai==2.1.4 6 | langchain-community==0.3.23 7 | langchain-core==0.3.59 8 | python-dotenv==1.1.0 9 | black==25.1.0 10 | rclpy==3.3.16 11 | pydantic==2.11.3 12 | numpy==1.26.4 13 | google-ai-generativelanguage==0.6.18 14 | langchain-cohere==0.4.4 15 | langchain-mistralai==0.2.10 -------------------------------------------------------------------------------- /launch/turtlesim_agent.launch.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /turtlesim_agent/utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Utility functions for the Turtlesim agent. 4 | Contains helper functions used across the project. 5 | """ 6 | 7 | import math 8 | 9 | 10 | def normalize_angle(angle: float) -> float: 11 | """ 12 | Normalize angle to the range [-π, π]. 13 | 14 | Args: 15 | angle (float): The input angle in radians 16 | 17 | Returns: 18 | float: The normalized angle in radians 19 | """ 20 | return math.atan2(math.sin(angle), math.cos(angle)) 21 | -------------------------------------------------------------------------------- /turtlesim_agent/interface/gui_interface.py: -------------------------------------------------------------------------------- 1 | from turtlesim_agent.interface.base_interface import BaseAgentInterface 2 | 3 | 4 | class GUIAgentInterface(BaseAgentInterface): 5 | def __init__(self, agent_executor): 6 | self.agent_executor = agent_executor 7 | 8 | async def send_user_input(self, user_input: str) -> str: 9 | try: 10 | result = await self.agent_executor.ainvoke({"input": user_input}) 11 | return result.get("output", "[No response]") 12 | except Exception as e: 13 | return f"[Error] {e}" 14 | 15 | def shutdown(self): 16 | pass # Any specific shutdown procedures can go here 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## PR Type 2 | 5 | - [ ] Feature 6 | - [ ] Bug fix 7 | - [ ] Refactor 8 | - [ ] Documentation 9 | - [ ] Other 10 | 11 | ## Overview 12 | - 13 | 16 | 17 | ## Detail 18 | - 19 | 22 | 23 | ## Test 24 | - [ ] 25 | 31 | 32 | ## Attention 33 | - 34 | 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | images/ 3 | __pycache__/ 4 | .vscode/* 5 | !.vscode/extensions.json 6 | !.vscode/settings.json 7 | 8 | 9 | devel/ 10 | logs/ 11 | build/ 12 | bin/ 13 | lib/ 14 | msg_gen/ 15 | srv_gen/ 16 | msg/*Action.msg 17 | msg/*ActionFeedback.msg 18 | msg/*ActionGoal.msg 19 | msg/*ActionResult.msg 20 | msg/*Feedback.msg 21 | msg/*Goal.msg 22 | msg/*Result.msg 23 | msg/_*.py 24 | build_isolated/ 25 | devel_isolated/ 26 | 27 | # Generated by dynamic reconfigure 28 | *.cfgc 29 | /cfg/cpp/ 30 | /cfg/*.py 31 | 32 | # Ignore generated docs 33 | *.dox 34 | *.wikidoc 35 | 36 | # eclipse stuff 37 | .project 38 | .cproject 39 | 40 | # qcreator stuff 41 | CMakeLists.txt.user 42 | 43 | srv/_*.py 44 | *.pcd 45 | *.pyc 46 | qtcreator-* 47 | *.user 48 | 49 | /planning/cfg 50 | /planning/docs 51 | /planning/src 52 | 53 | *~ 54 | 55 | # Emacs 56 | .#* 57 | 58 | # Catkin custom files 59 | CATKIN_IGNORE -------------------------------------------------------------------------------- /test/test_pep257.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Open Source Robotics Foundation, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import pytest 16 | from ament_pep257.main import main 17 | 18 | 19 | @pytest.mark.linter 20 | @pytest.mark.pep257 21 | def test_pep257(): 22 | rc = main(argv=[".", "test"]) 23 | assert rc == 0, "Found code style errors / warnings" 24 | -------------------------------------------------------------------------------- /package.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | turtlesim_agent 5 | 0.0.0 6 | 7 | TurtleSim Agent is an AI agent for the turtlesim simulator that interprets text instructions and transforms them into visual outputs using LangChain-turning the simulated turtle into a creative artist. 8 | 9 | ubuntu 10 | TODO: License declaration 11 | geometry_msgs 12 | turtlesim 13 | ament_copyright 14 | ament_flake8 15 | ament_pep257 16 | python3-pytest 17 | 18 | ament_python 19 | 20 | 21 | -------------------------------------------------------------------------------- /test/test_flake8.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Open Source Robotics Foundation, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import pytest 16 | from ament_flake8.main import main_with_errors 17 | 18 | 19 | @pytest.mark.flake8 20 | @pytest.mark.linter 21 | def test_flake8(): 22 | rc, errors = main_with_errors(argv=[]) 23 | assert rc == 0, "Found %d code style errors / warnings:\n" % len( 24 | errors 25 | ) + "\n".join(errors) 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from glob import glob 3 | 4 | from setuptools import find_packages, setup 5 | 6 | package_name = "turtlesim_agent" 7 | 8 | setup( 9 | name=package_name, 10 | version="0.0.0", 11 | packages=find_packages(exclude=["test"]), 12 | data_files=[ 13 | ("share/ament_index/resource_index/packages", ["resource/" + package_name]), 14 | (os.path.join("share", package_name, "launch"), glob("launch/*.launch.xml")), 15 | ("share/" + package_name, ["package.xml"]), 16 | ], 17 | install_requires=["setuptools"], 18 | zip_safe=True, 19 | maintainer="ubuntu", 20 | maintainer_email="yutarop.storm.7@gmail.com", 21 | description="TurtleSim Agent is an AI agent for the turtlesim simulator that interprets text instructions and transforms them into visual outputs using LangChain-turning the simulated turtle into a creative artist", 22 | license="MIT License", 23 | tests_require=["pytest"], 24 | entry_points={ 25 | "console_scripts": ["turtlesim_agent_node = turtlesim_agent.main:main"], 26 | }, 27 | ) 28 | -------------------------------------------------------------------------------- /test/test_copyright.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Open Source Robotics Foundation, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import pytest 16 | from ament_copyright.main import main 17 | 18 | 19 | # Remove the `skip` decorator once the source file(s) have a copyright header 20 | @pytest.mark.skip( 21 | reason="No copyright header has been placed in the generated source file." 22 | ) 23 | @pytest.mark.copyright 24 | @pytest.mark.linter 25 | def test_copyright(): 26 | rc = main(argv=[".", "test"]) 27 | assert rc == 0, "Found errors" 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Yutarop 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /turtlesim_agent/turtlesim_node_entrypoint.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import threading 3 | 4 | import rclpy 5 | from rclpy.executors import ExternalShutdownException, SingleThreadedExecutor 6 | 7 | from turtlesim_agent.turtlesim_node import TurtleSimAgent 8 | 9 | 10 | def spin_node_thread(node): 11 | """ 12 | Run the ROS2 node in a separate thread to allow concurrent execution. 13 | 14 | Args: 15 | node: The ROS2 node to spin 16 | """ 17 | executor = SingleThreadedExecutor() 18 | executor.add_node(node) 19 | try: 20 | executor.spin() 21 | except (KeyboardInterrupt, ExternalShutdownException): 22 | pass 23 | finally: 24 | executor.shutdown() 25 | 26 | 27 | async def main(): 28 | rclpy.init() 29 | node = TurtleSimAgent() 30 | 31 | spin_thread = threading.Thread(target=spin_node_thread, args=(node,), daemon=True) 32 | spin_thread.start() 33 | 34 | if not node.wait_for_pose(timeout=3.0): 35 | node.get_logger().error("Initial pose not received") 36 | node.destroy_node() 37 | rclpy.shutdown() 38 | return 39 | 40 | node.move_linear(2.0) 41 | await node.call_set_pen_async(0, 0, 255, 3, False) 42 | node.move_linear(2.0) 43 | await node.call_reset_async() 44 | print("finish") 45 | node.destroy_node() 46 | rclpy.shutdown() 47 | 48 | 49 | if __name__ == "__main__": 50 | asyncio.run(main()) 51 | -------------------------------------------------------------------------------- /turtlesim_agent/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Main entry point for the TurtleSim agent with LangChain integration. 4 | This module initializes the ROS2 node and connects it with a language-based agent 5 | that can interpret natural language commands to control the robot. 6 | """ 7 | 8 | import threading 9 | 10 | import rclpy 11 | from langchain.agents import AgentExecutor 12 | from rclpy.executors import ExternalShutdownException, SingleThreadedExecutor 13 | 14 | from turtlesim_agent.chat_entrypoint import chat_invoke 15 | from turtlesim_agent.llms import create_agent 16 | from turtlesim_agent.tools.all_tools import make_all_tools 17 | from turtlesim_agent.turtlesim_node import TurtleSimAgent 18 | 19 | 20 | def spin_node_thread(node): 21 | """ 22 | Run the ROS2 node in a separate thread to allow concurrent execution. 23 | 24 | Args: 25 | node: The ROS2 node to spin 26 | """ 27 | executor = SingleThreadedExecutor() 28 | executor.add_node(node) 29 | try: 30 | executor.spin() 31 | except (KeyboardInterrupt, ExternalShutdownException): 32 | pass 33 | finally: 34 | executor.shutdown() 35 | 36 | 37 | def main(): 38 | """ 39 | Main function that initializes and runs the TurtleSim agent with LangChain integration. 40 | """ 41 | 42 | rclpy.init() 43 | node = TurtleSimAgent() 44 | 45 | spin_thread = threading.Thread(target=spin_node_thread, args=(node,), daemon=True) 46 | spin_thread.start() 47 | 48 | if not node.wait_for_pose(timeout=3.0): 49 | node.get_logger().error("Initial pose not received") 50 | node.destroy_node() 51 | rclpy.shutdown() 52 | return 53 | 54 | tools = make_all_tools(node) 55 | agent = create_agent(model_name=node.agent_model, tools=tools, temperature=0.0) 56 | agent_executor = AgentExecutor( 57 | agent=agent, tools=tools, verbose=True, max_iterations=30 58 | ) 59 | chat_invoke( 60 | interface=node.interface, 61 | agent_executor=agent_executor, 62 | node=node, 63 | spin_thread=spin_thread, 64 | ) 65 | 66 | 67 | if __name__ == "__main__": 68 | main() 69 | -------------------------------------------------------------------------------- /.github/workflows/ros2_ci.yml: -------------------------------------------------------------------------------- 1 | name: ROS2 CI 2 | on: 3 | pull_request: 4 | jobs: 5 | auto_format: 6 | name: Auto-format Python & XML 7 | runs-on: ubuntu-22.04 8 | permissions: 9 | contents: write 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | with: 14 | ref: ${{ github.head_ref }} 15 | - name: Set up Python 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: "3.10" 19 | - name: Install format tools 20 | run: | 21 | sudo apt update 22 | sudo apt install -y libxml2-utils 23 | pip install black isort autoflake 24 | - name: Run autoflake 25 | run: | 26 | find . -type f -name "*.py" -exec autoflake --in-place --remove-unused-variables --remove-all-unused-imports {} \; 27 | - name: Run isort 28 | run: | 29 | find . -type f -name "*.py" -exec isort {} \; 30 | - name: Run black 31 | run: | 32 | black . 33 | - name: Format XML files 34 | run: | 35 | find . -type f \( -name "*.xml" -or -name "*.urdf" -or -name "*.xacro" -or -name "*.sdf" \) -exec xmllint --format -o {} {} \; 36 | - name: Auto-commit changes 37 | uses: stefanzweifel/git-auto-commit-action@v4 38 | with: 39 | commit_message: "Apply formatting to PR files" 40 | commit_user_name: github-actions[bot] 41 | commit_user_email: github-actions[bot]@users.noreply.github.com 42 | 43 | build_check: 44 | name: ROS2 Build Check (colcon via industrial_ci) 45 | runs-on: ubuntu-22.04 46 | strategy: 47 | fail-fast: false 48 | matrix: 49 | ros_distro: [humble] 50 | ros_repo: [main, testing] 51 | steps: 52 | - name: Checkout code 53 | uses: actions/checkout@v2 54 | with: 55 | ref: ${{ github.head_ref }} 56 | - name: Run industrial_ci 57 | uses: ros-industrial/industrial_ci@master 58 | env: 59 | ROS_DISTRO: ${{ matrix.ros_distro }} 60 | ROS_REPO: ${{ matrix.ros_repo }} 61 | BEFORE_BUILD_TARGET_WORKSPACE: 'apt update -q && python3 -m pip install -q -r requirements.txt' 62 | IMMEDIATE_TEST_OUTPUT: true 63 | CLANG_FORMAT_CHECK: none 64 | CLANG_TIDY: none 65 | PYLINT_CHECK: false 66 | BLACK_CHECK: false 67 | -------------------------------------------------------------------------------- /turtlesim_agent/chat_entrypoint.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | import threading 4 | import tkinter as tk 5 | 6 | import rclpy 7 | from langchain.agents import AgentExecutor 8 | from rclpy.node import Node 9 | 10 | from turtlesim_agent.interface.chat_gui import ChatUI 11 | from turtlesim_agent.interface.gui_interface import GUIAgentInterface 12 | 13 | 14 | def chat_invoke( 15 | interface: str, 16 | agent_executor: AgentExecutor, 17 | node: Node, 18 | spin_thread: threading.Thread, 19 | ): 20 | if interface == "cli": 21 | asyncio.run(_run_cli(agent_executor, node, spin_thread)) 22 | elif interface == "gui": 23 | asyncio.run(_run_gui(agent_executor, node)) 24 | else: 25 | raise ValueError(f"Unsupported interface: {interface}") 26 | 27 | 28 | async def _run_cli( 29 | agent_executor: AgentExecutor, node: Node, spin_thread: threading.Thread 30 | ): 31 | try: 32 | while True: 33 | user_input = input("user: ") 34 | if user_input.lower() in {"quit", "exit"}: 35 | break 36 | result = await agent_executor.ainvoke(input={"input": user_input}) 37 | print("turtlebot agent:", result["output"]) 38 | except KeyboardInterrupt: 39 | node.get_logger().info("User interrupted. Shutting down...") 40 | finally: 41 | _shutdown(node, spin_thread) 42 | 43 | 44 | def _run_gui(agent_executor: AgentExecutor, node: Node): 45 | loop = asyncio.new_event_loop() 46 | asyncio.set_event_loop(loop) 47 | 48 | interface = GUIAgentInterface(agent_executor) 49 | 50 | def start_loop(): 51 | asyncio.set_event_loop(loop) 52 | loop.run_forever() 53 | 54 | loop_thread = threading.Thread(target=start_loop, daemon=True) 55 | loop_thread.start() 56 | 57 | def on_close(): 58 | interface.shutdown() 59 | _shutdown(node) 60 | root.destroy() 61 | loop.call_soon_threadsafe(loop.stop) 62 | sys.exit(0) 63 | 64 | try: 65 | root = tk.Tk() 66 | ChatUI(root, interface, loop) 67 | root.protocol("WM_DELETE_WINDOW", on_close) 68 | root.mainloop() 69 | except KeyboardInterrupt: 70 | print("KeyboardInterrupt detected. Exiting.") 71 | on_close() 72 | 73 | 74 | def _shutdown(node: Node, spin_thread: threading.Thread = None): 75 | node.destroy_node() 76 | rclpy.shutdown() 77 | if spin_thread and spin_thread.is_alive(): 78 | spin_thread.join(timeout=1.0) 79 | -------------------------------------------------------------------------------- /turtlesim_agent/tools/all_tools.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | TurtleSim Tools Package 5 | 6 | This package provides LangChain-compatible tools for controlling TurtleSim. 7 | Tools are organized by functionality: 8 | - status: Information retrieval tools (pose, pen status, bounds checking) 9 | - motion: Movement and rotation tools 10 | - pen: Drawing tools (pen control, color, width) 11 | - simulation: Simulation control tools (reset, clear, spawn, kill, teleport) 12 | """ 13 | 14 | from turtlesim_agent.tools.math_tools import ( 15 | calculate_euclidean_distance, 16 | compute_duration_from_circular_angle_and_angular_velocity, 17 | compute_linear_and_angular_velocity_from_radius, 18 | degrees_to_radians, 19 | ) 20 | from turtlesim_agent.tools.motion_tools import ( 21 | make_move_linear_tool, 22 | make_move_non_linear_tool, 23 | make_move_on_arc_tool, 24 | make_rotate_tool, 25 | make_teleport_absolute_tool, 26 | make_teleport_relative_tool, 27 | ) 28 | from turtlesim_agent.tools.pen_tools import ( 29 | make_set_pen_color_width_tool, 30 | make_set_pen_up_down_tool, 31 | ) 32 | from turtlesim_agent.tools.sim_tools import ( 33 | make_clear_tool, 34 | make_kill_tool, 35 | make_reset_tool, 36 | make_spawn_tool, 37 | ) 38 | from turtlesim_agent.tools.status_tools import ( 39 | make_check_bounds_tool, 40 | make_get_pen_status_tool, 41 | make_get_turtle_pose_tool, 42 | ) 43 | 44 | 45 | def make_all_tools(node) -> list: 46 | """ 47 | Creates all the tools needed for the LangChain agent. 48 | 49 | Args: 50 | node: The TurtleSimAgent node instance 51 | 52 | Returns: 53 | list: List of tools available to the agent 54 | """ 55 | return [ 56 | # Math tools 57 | degrees_to_radians, 58 | calculate_euclidean_distance, 59 | compute_duration_from_circular_angle_and_angular_velocity, 60 | compute_linear_and_angular_velocity_from_radius, 61 | make_move_on_arc_tool(node), 62 | # Status tools 63 | make_check_bounds_tool(node), 64 | make_get_turtle_pose_tool(node), 65 | make_get_pen_status_tool(node), 66 | # Motion tools 67 | make_move_linear_tool(node), 68 | make_move_non_linear_tool(node), 69 | make_rotate_tool(node), 70 | make_teleport_absolute_tool(node), 71 | make_teleport_relative_tool(node), 72 | # Pen tools 73 | make_set_pen_color_width_tool(node), 74 | make_set_pen_up_down_tool(node), 75 | # Simulation tools 76 | make_reset_tool(node), 77 | make_clear_tool(node), 78 | make_kill_tool(node), 79 | make_spawn_tool(node), 80 | ] 81 | -------------------------------------------------------------------------------- /turtlesim_agent/tools/math_tools.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import math 4 | 5 | from langchain.tools import tool 6 | 7 | TWIST_ANGULAR = 0.8 8 | TWIST_VELOCITY = 1.0 9 | 10 | 11 | @tool 12 | def degrees_to_radians(degrees: float) -> float: 13 | """ 14 | Converts an angle in degrees to radians. 15 | 16 | Args: 17 | degrees (float): The angle in degrees. 18 | 19 | Returns: 20 | float: The angle in radians. 21 | """ 22 | radians = round(math.radians(degrees), 3) 23 | return radians 24 | 25 | 26 | @tool 27 | def calculate_euclidean_distance( 28 | current_x: float, 29 | current_y: float, 30 | target_x: float, 31 | target_y: float, 32 | ) -> float: 33 | """ 34 | Calculates the Euclidean distance between the current position and the target position. 35 | 36 | Args: 37 | current_x (float): X coordinate of the current position. 38 | current_y (float): Y coordinate of the current position. 39 | target_x (float): X coordinate of the target position. 40 | target_y (float): Y coordinate of the target position. 41 | 42 | Returns: 43 | float: Distance in meters. 44 | """ 45 | dx = target_x - current_x 46 | dy = target_y - current_y 47 | return round(math.sqrt(dx**2 + dy**2), 3) 48 | 49 | 50 | @tool 51 | def compute_linear_and_angular_velocity_from_radius( 52 | radius: float, 53 | ) -> list[float]: 54 | """ 55 | Compute linear velocity (v) and angular velocity (ω) for a circular trajectory based on the given radius (r). 56 | 57 | Args: 58 | radius (float): Radius of the circular path (in centimeters) 59 | 60 | Returns: 61 | list[float]: [linear_velocity (cm/s), angular_velocity (rad/s)] 62 | """ 63 | if radius > 0.7: 64 | linear_velocity = TWIST_VELOCITY 65 | angular_velocity = linear_velocity / radius 66 | else: 67 | angular_velocity = TWIST_ANGULAR 68 | linear_velocity = radius * angular_velocity 69 | 70 | return [linear_velocity, angular_velocity] 71 | 72 | 73 | @tool 74 | def compute_duration_from_circular_angle_and_angular_velocity( 75 | circular_angle: float, angular_velocity: float 76 | ) -> float: 77 | """ 78 | Compute the time required to rotate a given angle at a specified angular velocity. 79 | 80 | Formula: time = angle / angular_velocity 81 | 82 | Args: 83 | circular_angle (float): The angle to rotate along the circular path (in radians) 84 | angular_velocity (float): Angular velocity for rotation (in rad/s) 85 | 86 | Returns: 87 | float: Duration in seconds needed to rotate the specified angle 88 | """ 89 | duration = abs(circular_angle / angular_velocity) 90 | return round(duration, 3) 91 | -------------------------------------------------------------------------------- /turtlesim_agent/prompts.py: -------------------------------------------------------------------------------- 1 | """ 2 | Prompt template definition for the TurtleSim agent. 3 | This module defines the system and user prompt structure used by the LangChain agent. 4 | """ 5 | 6 | from langchain_core.prompts import ChatPromptTemplate 7 | 8 | prompt = ChatPromptTemplate.from_messages( 9 | [ 10 | ( 11 | "system", 12 | "You are TurtleBot, a simple mobile robot operating in a simulated 2D environment called TurtleSim using ROS 2. " 13 | "You are an artist. Your task is to draw shapes and even letters beautifully and precisely. " 14 | "1. Simulation Environment: " 15 | "- The simulation space is a fixed-size square ranging from (0, 0) at the bottom-left to (11, 11) at the top-right. " 16 | "- The unit of length is centimeters. " 17 | "- The x-axis increases toward the right (East), and the y-axis increases upward (North). " 18 | "- You (turtle1) initially spawn at approximately (5.544, 5.544), facing East (0 radians). " 19 | "- All coordinates must stay within the [0, 11] range. If you go out of bounds, you must correct your position. " 20 | "2. Direction and Orientation: " 21 | "- The directions are defined as follows (radians): East = 0, North = +1.57, South = -1.57, West = ±3.14. " 22 | "- All movements and rotations must be defined relative to your current pose (position and orientation). " 23 | "- Do not assume global directions unless explicitly instructed. " 24 | "- Example: If you're facing North (+1.57) and must turn West (±3.14), rotate left by +1.57 radians. " 25 | "3. Movement Guidelines: " 26 | "- Always check your current pose before executing a series of movements. " 27 | "- Use both straight and curved motions where necessary. " 28 | "- Angles are in radians. Distances are in centimeters (cm). " 29 | "- If the user does not specify a size, assume 1 cm per side by default. " 30 | "- Execute actions sequentially—wait for one command to finish before issuing the next. " 31 | "- Track your resulting pose at each step. " 32 | "- At the end of each task, ensure you are within 0.1 cm of the goal. If not, recalculate or reset the canvas and try again. " 33 | "4. Drawing Guidelines: " 34 | "- To draw, lower the pen. To move without drawing, raise the pen. " 35 | "- The default pen color is white (255, 255, 255) and the default width is 3. " 36 | "5. Planning and Execution: " 37 | "- Before taking action, briefly describe your intended steps. Then immediately proceed by using the appropriate tools. Do not stop at thinking — always follow up with action." 38 | "- Use your tools one at a time to receive feedback after each. " 39 | "- Before moving forward, ensure you are correctly oriented toward your desired direction. " 40 | "- Letters composed entirely of straight lines can be drawn using a combination of move_linear and teleport_absolute. " 41 | "- For example, to draw the letter 'H', follow this procedure: " 42 | "- Use teleport_absolute to move to a suitable starting position, facing North. " 43 | "- Use move_linear to draw the left vertical line of the 'H'. " 44 | "- Use teleport_absolute to move to the midpoint of the line you just drew, now facing East. " 45 | "- Use move_linear to draw the horizontal bar of the 'H'. " 46 | "- Finally, use teleport_absolute to move to the bottom-right point, again facing North, and use move_linear to draw the right vertical line. ", 47 | ), 48 | ("human", "{input}"), 49 | ("placeholder", "{agent_scratchpad}"), 50 | ] 51 | ) 52 | -------------------------------------------------------------------------------- /turtlesim_agent/interface/chat_gui.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | import tkinter as tk 4 | from asyncio import run_coroutine_threadsafe 5 | from tkinter import scrolledtext 6 | 7 | from turtlesim_agent.interface.gui_interface import GUIAgentInterface 8 | 9 | 10 | class ChatUI: 11 | def __init__( 12 | self, root, interface: GUIAgentInterface, loop: asyncio.AbstractEventLoop 13 | ): 14 | self.interface = interface 15 | self.root = root 16 | self.loop = loop 17 | self.root.title("TurtleBot Agent Chat") 18 | self.root.configure(bg="black") 19 | 20 | self.chat_display = scrolledtext.ScrolledText( 21 | root, 22 | wrap=tk.WORD, 23 | height=20, 24 | width=60, 25 | state="disabled", 26 | bg="black", 27 | fg="white", 28 | insertbackground="white", 29 | font=("Consolas", 12), 30 | ) 31 | self.chat_display.pack(padx=10, pady=(10, 0)) 32 | 33 | self.entry_frame = tk.Frame(root, bg="black") 34 | self.entry_frame.pack(fill=tk.X, padx=10, pady=(10, 10)) 35 | 36 | self.entry = tk.Entry( 37 | self.entry_frame, 38 | width=50, 39 | bg="black", 40 | fg="white", 41 | insertbackground="white", 42 | font=("Consolas", 12), 43 | ) 44 | self.entry.pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(0, 5)) 45 | self.entry.bind("", self.send_message) 46 | 47 | self.send_button = tk.Button( 48 | self.entry_frame, 49 | text="Send", 50 | command=self.send_message, 51 | bg="skyblue", 52 | fg="black", 53 | font=("Consolas", 11, "bold"), 54 | ) 55 | self.send_button.pack(side=tk.RIGHT) 56 | 57 | self.root.protocol("WM_DELETE_WINDOW", self.quit_app) 58 | 59 | def send_message(self, event=None): 60 | user_input = self.entry.get().strip() 61 | if not user_input: 62 | return 63 | 64 | if user_input.lower() in ["quit", "exit"]: 65 | self.quit_app() 66 | 67 | self._append_chat("User", user_input, "white", "white") 68 | self.entry.delete(0, tk.END) 69 | 70 | # threading.Thread(target=self.process_agent_response, args=(user_input,)).start() 71 | run_coroutine_threadsafe(self.process_agent_response(user_input), self.loop) 72 | 73 | async def process_agent_response(self, user_input): 74 | response = await self.interface.send_user_input(user_input) 75 | self._append_chat("TurtleBot Agent", response, "#00FF00", "#00FF00") 76 | 77 | def _append_chat(self, speaker, message, label_color, text_color): 78 | self.chat_display.config(state="normal") 79 | self.chat_display.insert(tk.END, f"{speaker}: ", f"label_{speaker}") 80 | self.chat_display.insert(tk.END, f"{message}\n", f"text_{speaker}") 81 | self.chat_display.config(state="disabled") 82 | self.chat_display.yview(tk.END) 83 | 84 | self.chat_display.tag_config( 85 | f"label_User", foreground="white", font=("Consolas", 12, "bold") 86 | ) 87 | self.chat_display.tag_config( 88 | f"label_TurtleBot Agent", 89 | foreground=label_color, 90 | font=("Consolas", 12, "bold"), 91 | ) 92 | self.chat_display.tag_config(f"text_User", foreground="white") 93 | self.chat_display.tag_config(f"text_TurtleBot Agent", foreground=text_color) 94 | 95 | def quit_app(self): 96 | print("Exiting application...") 97 | self.interface.shutdown() 98 | self.root.destroy() 99 | sys.exit(0) 100 | -------------------------------------------------------------------------------- /turtlesim_agent/tools/pen_tools.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Pen Tools for TurtleSim 5 | 6 | This module contains tools for controlling the turtle's drawing pen, 7 | including color, width, and up/down state management. 8 | """ 9 | 10 | from langchain.tools import StructuredTool 11 | from pydantic import BaseModel, Field 12 | from pydantic.v1 import BaseModel, Field 13 | 14 | 15 | def make_set_pen_color_width_tool(node): 16 | """ 17 | Create a tool that sets the pen color and width while maintaining current pen state (up/down). 18 | 19 | Args: 20 | node: The TurtleSimAgent node instance 21 | 22 | Returns: 23 | StructuredTool: A LangChain tool for setting pen color and width 24 | """ 25 | 26 | class SetPenColorWidthInput(BaseModel): 27 | r: int = Field(description="Red color component (0-255)") 28 | g: int = Field(description="Green color component (0-255)") 29 | b: int = Field(description="Blue color component (0-255)") 30 | width: int = Field(description="Pen width (1-10)") 31 | 32 | async def inner(r: int, g: int, b: int, width: int) -> str: 33 | # Use current pen up/down state 34 | current_pen_off = node.pen_off 35 | await node.call_set_pen_async(r, g, b, width, current_pen_off) 36 | 37 | pen_state = "up (not drawing)" if current_pen_off else "down (drawing)" 38 | return f"Pen color set to RGB({r}, {g}, {b}) with width {width}. Pen is {pen_state}." 39 | 40 | return StructuredTool.from_function( 41 | func=inner, 42 | coroutine=inner, 43 | name="set_pen_color_width", 44 | description=""" 45 | Sets the pen color and width while maintaining the current pen state (up/down). 46 | 47 | Args: 48 | - r (int): Red color component (0-255). 49 | - g (int): Green color component (0-255). 50 | - b (int): Blue color component (0-255). 51 | - width (int): Pen width (1-10). 52 | 53 | The pen's current up/down state will be preserved. 54 | """, 55 | args_schema=SetPenColorWidthInput, 56 | ) 57 | 58 | 59 | def make_set_pen_up_down_tool(node): 60 | """ 61 | Create a tool that sets the pen up or down while maintaining current color and width. 62 | 63 | Args: 64 | node: The TurtleSimAgent node instance 65 | 66 | Returns: 67 | StructuredTool: A LangChain tool for setting pen up/down 68 | """ 69 | 70 | class SetPenUpDownInput(BaseModel): 71 | pen_up: bool = Field( 72 | description="Set pen up (True) to stop drawing, or down (False) to start drawing" 73 | ) 74 | 75 | async def inner(pen_up: bool) -> str: 76 | # Use current pen color and width 77 | current_r = node.pen_r 78 | current_g = node.pen_g 79 | current_b = node.pen_b 80 | current_width = node.pen_width 81 | 82 | await node.call_set_pen_async( 83 | current_r, current_g, current_b, current_width, pen_up 84 | ) 85 | 86 | if pen_up: 87 | return f"Pen lifted up - turtle will not draw trails when moving. Color: RGB({current_r}, {current_g}, {current_b}), Width: {current_width}" 88 | else: 89 | return f"Pen put down - turtle will draw trails when moving. Color: RGB({current_r}, {current_g}, {current_b}), Width: {current_width}" 90 | 91 | return StructuredTool.from_function( 92 | func=inner, 93 | coroutine=inner, 94 | name="set_pen_up_down", 95 | description=""" 96 | Sets the pen up (stop drawing) or down (start drawing) while maintaining current color and width. 97 | 98 | Args: 99 | - pen_up (bool): Set to True to lift pen up (stop drawing), False to put pen down (start drawing). 100 | 101 | The pen's current color and width settings will be preserved. 102 | """, 103 | args_schema=SetPenUpDownInput, 104 | ) 105 | -------------------------------------------------------------------------------- /turtlesim_agent/tools/sim_tools.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Simulation Tools for TurtleSim 5 | 6 | This module contains tools for controlling the TurtleSim simulation, 7 | including reset, clear, spawn, kill, and teleportation functions. 8 | """ 9 | 10 | from langchain.tools import StructuredTool, Tool 11 | from pydantic import BaseModel, Field 12 | from pydantic.v1 import BaseModel, Field 13 | 14 | 15 | def make_reset_tool(node): 16 | """ 17 | Create a tool that resets the turtle simulation. 18 | 19 | Args: 20 | node: The TurtleSimAgent node instance 21 | 22 | Returns: 23 | Tool: A LangChain tool for resetting the simulation 24 | """ 25 | 26 | async def inner(_input: str = "") -> str: 27 | """ 28 | Resets the turtle simulation to initial state. 29 | The input is unused but required by LangChain. 30 | """ 31 | await node.call_reset_async() 32 | return "Turtle simulation has been reset to initial state." 33 | 34 | return Tool.from_function( 35 | func=inner, 36 | coroutine=inner, 37 | name="reset_simulation", 38 | description=""" 39 | Resets the turtle simulation to its initial state. 40 | 41 | Args: 42 | - _input: the input is unused but required by LangChain. Set an empty string "". 43 | 44 | Returns: 45 | - Confirmation message that the simulation has been reset. 46 | """, 47 | ) 48 | 49 | 50 | def make_clear_tool(node): 51 | """ 52 | Create a tool that clears the turtlesim screen. 53 | 54 | Args: 55 | node: The TurtleSimAgent node instance 56 | 57 | Returns: 58 | Tool: A LangChain tool for clearing the screen 59 | """ 60 | 61 | async def inner(_input: str = "") -> str: 62 | """ 63 | Clears the turtlesim screen of all drawing traces. 64 | The input is unused but required by LangChain. 65 | """ 66 | await node.call_clear_async() 67 | return "Screen has been cleared of all drawing traces." 68 | 69 | return Tool.from_function( 70 | func=inner, 71 | coroutine=inner, 72 | name="clear_screen", 73 | description=""" 74 | Clears the turtlesim screen of all drawing traces. 75 | 76 | Args: 77 | - _input: the input is unused but required by LangChain. Set an empty string "". 78 | 79 | Returns: 80 | - Confirmation message that the screen has been cleared. 81 | """, 82 | ) 83 | 84 | 85 | def make_kill_tool(node): 86 | """ 87 | Create a tool that kills a turtle by name. 88 | 89 | Args: 90 | node: The TurtleSimAgent node instance 91 | 92 | Returns: 93 | StructuredTool: A LangChain tool for killing turtles 94 | """ 95 | 96 | class KillInput(BaseModel): 97 | name: str = Field( 98 | description="Name of the turtle to kill (e.g., 'turtle1', 'turtle2')" 99 | ) 100 | 101 | async def inner(name: str) -> str: 102 | await node.call_kill_async(name) 103 | return f"Turtle '{name}' has been killed." 104 | 105 | return StructuredTool.from_function( 106 | func=inner, 107 | coroutine=inner, 108 | name="kill_turtle", 109 | description=""" 110 | Kills a turtle by its name, removing it from the simulation. 111 | 112 | Args: 113 | - name (str): Name of the turtle to kill (e.g., 'turtle1', 'turtle2'). 114 | """, 115 | args_schema=KillInput, 116 | ) 117 | 118 | 119 | def make_spawn_tool(node): 120 | """ 121 | Create a tool that spawns a new turtle. 122 | 123 | Args: 124 | node: The TurtleSimAgent node instance 125 | 126 | Returns: 127 | StructuredTool: A LangChain tool for spawning turtles 128 | """ 129 | 130 | class SpawnInput(BaseModel): 131 | x: float = Field(description="X position to spawn the turtle (0.0-11.0)") 132 | y: float = Field(description="Y position to spawn the turtle (0.0-11.0)") 133 | theta: float = Field(description="Initial orientation in radians") 134 | name: str = Field(default="", description="Name for the new turtle (optional)") 135 | 136 | async def inner(x: float, y: float, theta: float, name: str = "") -> str: 137 | spawned_name = await node.call_spawn_async(x, y, theta, name) 138 | if spawned_name: 139 | return f"New turtle '{spawned_name}' spawned at position ({x}, {y}) with orientation {theta} rad." 140 | else: 141 | return "Failed to spawn new turtle." 142 | 143 | return StructuredTool.from_function( 144 | func=inner, 145 | coroutine=inner, 146 | name="spawn_turtle", 147 | description=""" 148 | Spawns a new turtle at the specified position and orientation. 149 | 150 | Args: 151 | - x (float): X position to spawn the turtle (0.0-11.0). 152 | - y (float): Y position to spawn the turtle (0.0-11.0). 153 | - theta (float): Initial orientation in radians. 154 | - name (str): Optional name for the new turtle. If empty, a name will be auto-generated. 155 | """, 156 | args_schema=SpawnInput, 157 | ) 158 | -------------------------------------------------------------------------------- /turtlesim_agent/tools/status_tools.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Status Tools for TurtleSim 5 | 6 | This module contains tools for retrieving status information about the turtle, 7 | including position, pen status, and boundary checking. 8 | """ 9 | 10 | from langchain.tools import StructuredTool, Tool 11 | from pydantic import BaseModel, Field 12 | from pydantic.v1 import BaseModel, Field 13 | 14 | 15 | def make_get_turtle_pose_tool(node): 16 | """ 17 | Create a tool that returns the current pose of the TurtleSim. 18 | 19 | Args: 20 | node: The TurtleSim node instance 21 | 22 | Returns: 23 | Tool: A LangChain tool for getting the robot's pose 24 | """ 25 | 26 | def inner(_input: str = "") -> str: 27 | """ 28 | Returns the current pose of the TurtleSim by subscribing to the /odom topic. 29 | The input is unused but required by LangChain. 30 | """ 31 | if not node.wait_for_pose(timeout=0.5): 32 | result = "Could not retrieve pose for the turtle bot." 33 | else: 34 | p = node.current_pose 35 | result = ( 36 | f"Current Pose of the turtlebot: x={p.x:.2f}, y={p.y:.2f}, " 37 | f"yaw={p.theta:.2f} rad, " 38 | ) 39 | return result 40 | 41 | return Tool.from_function( 42 | func=inner, 43 | name="get_turtle_pose", 44 | description=""" 45 | Returns the current pose of the TurtleSim by subscribing to the /odom topic. 46 | 47 | Args: 48 | - _input: the input is unused but required by LangChain. Set an empty string "". 49 | 50 | Returns: 51 | - Position: x (centimeters), y (centimeters) 52 | - Orientation: yaw (in radians) 53 | """, 54 | ) 55 | 56 | 57 | def make_get_pen_status_tool(node): 58 | """ 59 | Create a tool that returns the current pen status and properties. 60 | 61 | Args: 62 | node: The TurtleSimAgent node instance 63 | 64 | Returns: 65 | Tool: A LangChain tool for getting the pen status 66 | """ 67 | 68 | def inner(_input: str = "") -> str: 69 | """ 70 | Returns the current pen status and properties. 71 | The input is unused but required by LangChain. 72 | """ 73 | pen_info = node.pen_info 74 | status = "up (not drawing)" if pen_info["off"] else "down (drawing)" 75 | 76 | result = ( 77 | f"Current Pen Status:\n" 78 | f"- State: {status}\n" 79 | f"- Color: RGB({pen_info['r']}, {pen_info['g']}, {pen_info['b']})\n" 80 | f"- Width: {pen_info['width']}\n" 81 | f"- Drawing: {'No' if pen_info['off'] else 'Yes'}" 82 | ) 83 | return result 84 | 85 | return Tool.from_function( 86 | func=inner, 87 | name="get_pen_status", 88 | description=""" 89 | Returns the current pen status and properties including color, width, and drawing state. 90 | 91 | Args: 92 | - _input: the input is unused but required by LangChain. Set an empty string "". 93 | 94 | Returns: 95 | - Current pen state (up/down) 96 | - Color values (RGB) 97 | - Pen width 98 | - Whether the pen is currently drawing 99 | """, 100 | ) 101 | 102 | 103 | def make_check_bounds_tool(node): 104 | """ 105 | Create a tool that checks if the turtle is within specified boundaries. 106 | 107 | Args: 108 | node: The TurtleSimAgent node instance 109 | 110 | Returns: 111 | StructuredTool: A LangChain tool for checking turtle boundaries 112 | """ 113 | 114 | class CheckBoundsInput(BaseModel): 115 | x_min: float = Field( 116 | default=0.0, description="Minimum x coordinate (default: 0.0)" 117 | ) 118 | x_max: float = Field( 119 | default=11.0, description="Maximum x coordinate (default: 11.0)" 120 | ) 121 | y_min: float = Field( 122 | default=0.0, description="Minimum y coordinate (default: 0.0)" 123 | ) 124 | y_max: float = Field( 125 | default=11.0, description="Maximum y coordinate (default: 11.0)" 126 | ) 127 | 128 | def inner( 129 | x_min: float = 0.0, x_max: float = 11.0, y_min: float = 0.0, y_max: float = 11.0 130 | ) -> str: 131 | """ 132 | Checks if the turtle is within the specified boundaries. 133 | """ 134 | if not node.wait_for_pose(timeout=0.5): 135 | return "Could not retrieve pose to check boundaries." 136 | 137 | is_within = node.is_within_bounds( 138 | x_min=x_min, 139 | x_max=x_max, 140 | y_min=y_min, 141 | y_max=y_max, 142 | ) 143 | 144 | current_pos = node.current_pose 145 | if is_within: 146 | result = ( 147 | f"Turtle is WITHIN bounds.\n" 148 | f"Current position: x={current_pos.x:.2f}, y={current_pos.y:.2f}\n" 149 | f"Allowed bounds: {x_min} < x < {x_max}, {y_min} < y < {y_max}" 150 | ) 151 | else: 152 | result = ( 153 | f"Turtle is OUT of bounds!\n" 154 | f"Current position: x={current_pos.x:.2f}, y={current_pos.y:.2f}\n" 155 | f"Allowed bounds: {x_min} < x < {x_max}, {y_min} < y < {y_max}" 156 | ) 157 | 158 | return result 159 | 160 | return StructuredTool.from_function( 161 | func=inner, 162 | name="check_turtle_bounds", 163 | description=""" 164 | Checks if the turtle is within the specified boundaries. 165 | 166 | Args: 167 | - x_min (float): Minimum x coordinate (default: 0.0) 168 | - x_max (float): Maximum x coordinate (default: 11.0) 169 | - y_min (float): Minimum y coordinate (default: 0.0) 170 | - y_max (float): Maximum y coordinate (default: 11.0) 171 | 172 | Returns: 173 | - Whether the turtle is within or outside the specified bounds 174 | - Current turtle position 175 | - The boundary limits being checked 176 | """, 177 | args_schema=CheckBoundsInput, 178 | ) 179 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![turtlesim_agent_logo](https://github.com/user-attachments/assets/282f0708-41b6-45f9-9ce4-c749014e9183) 2 | ![ROS2-humble Industrial CI](https://github.com/Yutarop/turtlesim_agent/actions/workflows/ros2_ci.yml/badge.svg) 3 | ## Project Overview 4 | `turtlesim_agent` is an AI agent that transforms the classic ROS [turtlesim](http://wiki.ros.org/turtlesim) simulator into a creative canvas driven by natural language. Powered by LangChain, this AI agent interprets text-based instructions and translates them into visual drawings, turning the simulated turtle into a digital artist. This project explores how large language models can interact with external environments to exhibit creative behavior. Users can describe shapes or drawing intentions in plain English, and the AI agent reasons through the instructions to execute them using turtlesim's motion commands. 5 | 6 | ## TurtleSim Agent Demo 7 | #### 📐 Drawing Basic Shapes 8 | 9 | https://github.com/user-attachments/assets/049a6c99-ab22-458f-abac-61694f82df55 10 | 11 | 12 | #### 🌈 Drawing a Rainbow 13 | ##### Prompt used 14 | > I want you to draw a rainbow composed of 7 semi-circular arcs, each with a different color and a radius ranging from 2.0 cm to 2.7 cm. The colors should follow the traditional rainbow order: violet, indigo, blue, green, yellow, orange, red with the pen's width of 5. Please offset the starting position of each semi-circle by 0.1 cm to avoid overlap. 15 | 16 | https://github.com/user-attachments/assets/ea9aee14-bdff-4f2b-8b49-c262b09e9051 17 | 18 | Note: This demo was generated using the `gemini-2.0-flash` model. 19 | Please note that results may vary even when using the same model, due to the non-deterministic nature of language models. Outputs may differ depending on factors like prompt phrasing, timing, or model updates. 20 | 21 | 22 | ## 🛠 Getting Started 23 | #### Requirements 24 | - ROS 2 Humble Hawksbill (This project has only been tested with ROS 2 Humble. Compatibility with other ROS 2 distributions is not guaranteed.) 25 | - Python 3.10+ 26 | - Other dependencies as listed in `requirements.txt` 27 | ### 1. Clone and build in a ROS2 workspace 28 | ```bash 29 | $ cd ~/{ROS_WORKSPACE}/src 30 | $ git clone -b humble-devel https://github.com/Yutarop/turtlesim_agent.git 31 | $ python3 -m pip install -r turtlesim_agent/requirements.txt 32 | $ cd ~/{ROS_WORKSPACE} && colcon build 33 | ``` 34 | ### 2. API Key Setup 35 | 36 | `turtlesim_agent` supports multiple language model providers via LangChain. You need to set API keys for the providers you intend to use. 37 | 38 | #### 🔐 Add API Keys to Your Shell Configuration 39 | 40 | To make your API keys available in your development environment, add them to your shell configuration file (e.g., `~/.bashrc`, `~/.zshrc`), then reload the file using `source`. 41 | 42 | ```bash 43 | export OPENAI_API_KEY=your_openai_api_key 44 | export ANTHROPIC_API_KEY=your_anthropic_api_key 45 | export GOOGLE_API_KEY=your_google_api_key 46 | export COHERE_API_KEY=your_cohere_api_key 47 | export MISTRAL_API_KEY=your_mistral_api_key 48 | ``` 49 | > 💡 You only need to set the keys for the providers you plan to use. 50 | 51 | #### 🖥️ Using Self-Hosted LLMs (e.g., Ollama) 52 | If you're running a local or remote LLM server (e.g., via Ollama), specify the server URL as follows: 53 | ```bash 54 | export URL="https:your_ollama_server_ip:11434" 55 | ``` 56 | ### 3. (Optional) Enable Tracing with LangSmith 57 | To trace and debug agent behavior using LangSmith, set the following environment variables: 58 | 59 | Basic Tracing Configuration: 60 | ```bash 61 | export LANGSMITH_ENDPOINT="https://api.smith.langchain.com" 62 | export LANGSMITH_TRACING=false 63 | ``` 64 | Full Configuration with API Key and Project Name: 65 | ```bash 66 | export LANGSMITH_ENDPOINT="https://api.smith.langchain.com" 67 | export LANGSMITH_TRACING=true 68 | export LANGSMITH_API_KEY=your_api_key_here 69 | export LANGSMITH_PROJECT=your_project_name_here 70 | ``` 71 | 72 | ### 4. Set LLM Models 73 | To specify which Large Language Model (LLM) your agent should use, you need to configure the model name in two places: 74 | 75 | - `turtlesim_node.py` 76 | - `turtlesim_agent.launch.xml` (only if you use ROS 2 launch files) 77 | #### **Edit the default model name** 78 | 79 | In both `turtlesim_node.py` and `turtlesim_agent.launch.xml`, update the `agent_model` parameter to match the model you want to use. 80 | 81 | - **Python ([turtlesim_node.py](https://github.com/Yutarop/turtlesim_agent/blob/main/turtlesim_agent/turtlesim_node.py)):** 82 | 83 | ```python 84 | self.declare_parameter("agent_model", "gemini-2.0-flash") 85 | ``` 86 | 87 | - **Launch file ([turtlesim_agent.launch.xml](https://github.com/Yutarop/turtlesim_agent/blob/main/launch/turtlesim_agent.launch.xml)):** 88 | 89 | ```xml 90 | 91 | ``` 92 | 93 | > 💡 The default model is `"gemini-2.0-flash"`. Replace it with your preferred model name (e.g., `"gpt-4"`, `"claude-3-opus"`, etc.). 94 | 95 | #### **Ensure LangChain supports your model** 96 | 97 | If you specify a custom model name, make sure it is supported by LangChain. You can verify this by checking or updating the logic inside `llm.py`. 98 | 99 | - If the model is not yet handled, add a corresponding case in [`llm.py`](https://github.com/Yutarop/turtlesim_agent/blob/main/turtlesim_agent/llms.py) to load the model correctly. 100 | - Refer to [LangChain documentation](https://docs.langchain.com/docs/) for the latest supported models and providers. 101 | 102 | 103 | ### 5. Apply the changes 104 | Once you have configured the variables, proceed to build and apply the changes to finalize the setup: 105 | ```bash 106 | $ cd ~/{ROS_WORKSPACE} && colcon build 107 | $ source ~/.bashrc # or source ~/.zshrc 108 | ``` 109 | ## ▶️ How to Run 110 | `turtlesim_agent` offers two modes of interaction: 111 | - A **CLI-based interface**, recommended for debugging and understanding the agent’s internal reasoning. 112 | - A **GUI-based chat interface**, ideal for intuitive and user-friendly interaction. 113 | 114 | #### ⌨️ Run with CLI (Recommended for Development) 115 | ```bash 116 | $ ros2 run turtlesim turtlesim_node 117 | $ ros2 run turtlesim_agent turtlesim_agent_node 118 | ``` 119 | #### 🖼️ Run with GUI (Chat Interface) 120 | ```bash 121 | $ ros2 launch turtlesim_agent turtlesim_agent.launch.xml 122 | ``` 123 | 124 | ## 🧰 Provided Tools for the AI Agent 125 | `turtlesim_agent` utilizes the tools implemented in the `tools/` directory as callable functions that it can invoke during the reasoning process to accomplish user-defined drawing tasks. 126 | 127 | #### 📁 File Structure 128 | ``` 129 | tools/ 130 | ├── all_tools.py # Imports and exports all available tools for the agent 131 | ├── math_tools.py # Basic arithmetic and geometric calculations 132 | ├── status_tools.py # Queries the current status of the turtle (e.g., position, orientation) 133 | ├── motion_tools.py # Controls the movement of the turtle (e.g., forward, rotate) 134 | ├── pen_tools.py # Manages pen states (e.g., color, on/off, width) 135 | └── simulation_tools.py # Resets simulation or spawns new turtles 136 | ``` 137 | #### 🚀 Extending the Agent's Creativity 138 | One of the core ideas behind this project is **enabling creative expression through tool augmentation**. If you'd like to enhance the agent's capabilities further, feel free to add your own tools to the `tools/` directory. 139 | 140 | To make new tools available: 141 | 1. Create a new `*_tools.py` file in the `tools/` directory. 142 | 2. Define your custom functions using LangChain-compatible signatures. 143 | 3. Import them in `all_tools.py` so that the agent can access them. 144 | -------------------------------------------------------------------------------- /turtlesim_agent/llms.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from langchain.agents import create_react_agent, create_tool_calling_agent 4 | from langchain.hub import pull 5 | from langchain_anthropic import ChatAnthropic 6 | from langchain_cohere import ChatCohere 7 | from langchain_google_genai import ChatGoogleGenerativeAI 8 | from langchain_mistralai import ChatMistralAI 9 | from langchain_ollama import ChatOllama 10 | from langchain_openai import ChatOpenAI 11 | 12 | from turtlesim_agent.prompts import prompt 13 | 14 | URL = os.getenv("URL") 15 | 16 | 17 | def create_agent(model_name: str, tools: list, temperature: float): 18 | """ 19 | Create an agent with the specified language model. 20 | 21 | Args: 22 | model_name: Name of the model to use 23 | tools: List of tools available to the agent 24 | temperature: Temperature parameter for model randomness 25 | 26 | Returns: 27 | Agent instance 28 | """ 29 | 30 | # OpenAI Models 31 | if model_name == "gpt-4o": 32 | llm = ChatOpenAI(model="gpt-4o", temperature=temperature) 33 | return create_tool_calling_agent(llm, tools=tools, prompt=prompt) 34 | elif model_name == "gpt-4o-mini": 35 | llm = ChatOpenAI(model="gpt-4o-mini", temperature=temperature) 36 | return create_tool_calling_agent(llm, tools=tools, prompt=prompt) 37 | elif model_name == "gpt-4-turbo": 38 | llm = ChatOpenAI(model="gpt-4-turbo", temperature=temperature) 39 | return create_tool_calling_agent(llm, tools=tools, prompt=prompt) 40 | elif model_name == "gpt-3.5-turbo": 41 | llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=temperature) 42 | return create_tool_calling_agent(llm, tools=tools, prompt=prompt) 43 | 44 | # Anthropic Models 45 | elif model_name == "claude-3.5-sonnet": 46 | llm = ChatAnthropic(model="claude-3-5-sonnet-20241022", temperature=temperature) 47 | return create_tool_calling_agent(llm, tools=tools, prompt=prompt) 48 | elif model_name == "claude-3-opus": 49 | llm = ChatAnthropic(model="claude-3-opus-20240229", temperature=temperature) 50 | return create_tool_calling_agent(llm, tools=tools, prompt=prompt) 51 | elif model_name == "claude-3-haiku": 52 | llm = ChatAnthropic(model="claude-3-haiku-20240307", temperature=temperature) 53 | return create_tool_calling_agent(llm, tools=tools, prompt=prompt) 54 | elif model_name == "claude": # Keep backward compatibility 55 | llm = ChatAnthropic(model="claude-3-opus-20240229", temperature=temperature) 56 | return create_tool_calling_agent(llm, tools=tools, prompt=prompt) 57 | 58 | # Google Models 59 | elif model_name == "gemini-2.0-flash": 60 | llm = ChatGoogleGenerativeAI(model="gemini-2.0-flash", temperature=temperature) 61 | return create_tool_calling_agent(llm, tools=tools, prompt=prompt) 62 | elif model_name == "gemini-1.5-pro": 63 | llm = ChatGoogleGenerativeAI(model="gemini-1.5-pro", temperature=temperature) 64 | return create_tool_calling_agent(llm, tools=tools, prompt=prompt) 65 | elif model_name == "gemini-1.5-flash": 66 | llm = ChatGoogleGenerativeAI(model="gemini-1.5-flash", temperature=temperature) 67 | return create_tool_calling_agent(llm, tools=tools, prompt=prompt) 68 | 69 | # Mistral AI Models 70 | elif model_name == "mistral-large": 71 | llm = ChatMistralAI(model="mistral-large-latest", temperature=temperature) 72 | return create_tool_calling_agent(llm, tools=tools, prompt=prompt) 73 | elif model_name == "mistral-medium": 74 | llm = ChatMistralAI(model="mistral-medium-latest", temperature=temperature) 75 | return create_tool_calling_agent(llm, tools=tools, prompt=prompt) 76 | elif model_name == "mistral-small": 77 | llm = ChatMistralAI(model="mistral-small-latest", temperature=temperature) 78 | return create_tool_calling_agent(llm, tools=tools, prompt=prompt) 79 | 80 | # Cohere Models 81 | elif model_name == "command-r-plus": 82 | llm = ChatCohere(model="command-r-plus", temperature=temperature) 83 | return create_tool_calling_agent(llm, tools=tools, prompt=prompt) 84 | elif model_name == "command-r": 85 | llm = ChatCohere(model="command-r", temperature=temperature) 86 | return create_tool_calling_agent(llm, tools=tools, prompt=prompt) 87 | 88 | # Ollama Models (Local/Self-hosted) 89 | elif model_name == "mistral-ollama": 90 | llm = ChatOllama(model="mistral", base_url=URL, temperature=temperature) 91 | try: 92 | return create_react_agent(llm, tools=tools, prompt=prompt) 93 | except Exception: 94 | react_prompt = pull("hwchase17/react") 95 | return create_react_agent(llm, tools=tools, prompt=react_prompt) 96 | elif model_name == "gemma2": 97 | llm = ChatOllama(model="gemma2", base_url=URL, temperature=temperature) 98 | try: 99 | return create_react_agent(llm, tools=tools, prompt=prompt) 100 | except Exception: 101 | react_prompt = pull("hwchase17/react") 102 | return create_react_agent(llm, tools=tools, prompt=react_prompt) 103 | elif model_name == "llama3.1": 104 | llm = ChatOllama(model="llama3.1", base_url=URL, temperature=temperature) 105 | try: 106 | return create_react_agent(llm, tools=tools, prompt=prompt) 107 | except Exception: 108 | react_prompt = pull("hwchase17/react") 109 | return create_react_agent(llm, tools=tools, prompt=react_prompt) 110 | elif model_name == "qwen2.5": 111 | llm = ChatOllama(model="qwen2.5", base_url=URL, temperature=temperature) 112 | try: 113 | return create_react_agent(llm, tools=tools, prompt=prompt) 114 | except Exception: 115 | react_prompt = pull("hwchase17/react") 116 | return create_react_agent(llm, tools=tools, prompt=react_prompt) 117 | elif model_name == "phi3": 118 | llm = ChatOllama(model="phi3", base_url=URL, temperature=temperature) 119 | try: 120 | return create_react_agent(llm, tools=tools, prompt=prompt) 121 | except Exception: 122 | react_prompt = pull("hwchase17/react") 123 | return create_react_agent(llm, tools=tools, prompt=react_prompt) 124 | 125 | # Backward compatibility for existing model names 126 | elif model_name == "mistral": 127 | llm = ChatOllama(model="mistral", base_url=URL, temperature=temperature) 128 | try: 129 | return create_react_agent(llm, tools=tools, prompt=prompt) 130 | except Exception: 131 | react_prompt = pull("hwchase17/react") 132 | return create_react_agent(llm, tools=tools, prompt=react_prompt) 133 | elif model_name == "gemma3": 134 | llm = ChatOllama(model="gemma3", base_url=URL, temperature=temperature) 135 | try: 136 | return create_react_agent(llm, tools=tools, prompt=prompt) 137 | except Exception: 138 | react_prompt = pull("hwchase17/react") 139 | return create_react_agent(llm, tools=tools, prompt=react_prompt) 140 | 141 | else: 142 | available_models = [ 143 | # OpenAI 144 | "gpt-4o", 145 | "gpt-4o-mini", 146 | "gpt-4-turbo", 147 | "gpt-3.5-turbo", 148 | # Anthropic 149 | "claude-3.5-sonnet", 150 | "claude-3-opus", 151 | "claude-3-haiku", 152 | "claude", 153 | # Google 154 | "gemini-2.0-flash", 155 | "gemini-1.5-pro", 156 | "gemini-1.5-flash", 157 | # Mistral AI 158 | "mistral-large", 159 | "mistral-medium", 160 | "mistral-small", 161 | # Cohere 162 | "command-r-plus", 163 | "command-r", 164 | # Ollama 165 | "mistral-ollama", 166 | "gemma2", 167 | "llama3.1", 168 | "qwen2.5", 169 | "phi3", 170 | # Backward compatibility 171 | "mistral", 172 | "gemma3", 173 | ] 174 | raise ValueError( 175 | f"Unsupported model: {model_name}. Available models: {', '.join(available_models)}" 176 | ) 177 | 178 | 179 | def get_available_models(): 180 | """ 181 | Get list of all available models grouped by provider. 182 | 183 | Returns: 184 | dict: Dictionary of models grouped by provider 185 | """ 186 | return { 187 | "OpenAI": ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-3.5-turbo"], 188 | "Anthropic": ["claude-3.5-sonnet", "claude-3-opus", "claude-3-haiku", "claude"], 189 | "Google": ["gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash"], 190 | "Mistral AI": ["mistral-large", "mistral-medium", "mistral-small"], 191 | "Cohere": ["command-r-plus", "command-r"], 192 | "Ollama (Local)": [ 193 | "mistral-ollama", 194 | "gemma2", 195 | "llama3.1", 196 | "qwen2.5", 197 | "phi3", 198 | "mistral", 199 | "gemma3", 200 | ], 201 | } 202 | -------------------------------------------------------------------------------- /turtlesim_agent/tools/motion_tools.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Motion Tools for TurtleSim 5 | 6 | This module contains tools for controlling turtle movement including 7 | linear movement, rotation, and curved trajectories. 8 | """ 9 | 10 | 11 | from langchain.tools import StructuredTool 12 | from pydantic import BaseModel, Field 13 | from pydantic.v1 import BaseModel, Field 14 | 15 | from turtlesim_agent.tools.math_tools import ( 16 | compute_duration_from_circular_angle_and_angular_velocity, 17 | compute_linear_and_angular_velocity_from_radius, 18 | ) 19 | 20 | 21 | def make_move_linear_tool(node): 22 | """ 23 | Create a tool that publishes Twist commands to move the TurtleSim. 24 | 25 | Args: 26 | node: The TurtleSim node instance 27 | 28 | Returns: 29 | StructuredTool: A LangChain tool for sending movement commands 30 | """ 31 | 32 | class MoveInput(BaseModel): 33 | distance: float = Field( 34 | description="Linear distance in centimeters. Positive for forward, negative for backward." 35 | ) 36 | 37 | async def inner(distance: float) -> str: 38 | if distance != 0.0: 39 | result = await node.move_linear(distance_m=distance) 40 | # Check if movement was interrupted due to out of bounds 41 | if not result: 42 | return "Turtle went out of bounds during movement and has been reset to the starting position." 43 | 44 | if not node.wait_for_pose(timeout=0.5): 45 | return "Pose data not received yet." 46 | p = node.current_pose 47 | return ( 48 | f"Current Pose of the turtlebot: x={p.x:.2f}, y={p.y:.2f}, " 49 | f"yaw={p.theta:.2f} rad, " 50 | ) 51 | 52 | return StructuredTool.from_function( 53 | func=inner, 54 | coroutine=inner, 55 | name="move_linear", 56 | description=""" 57 | Moves the TurtleSim forward or backward by the specified distance. 58 | 59 | Args: 60 | - distance (float): Linear distance in centimeters. Positive for forward, negative for backward. Default value is 2.0. 61 | """, 62 | args_schema=MoveInput, 63 | ) 64 | 65 | 66 | def make_rotate_tool(node): 67 | """ 68 | Create a tool that publishes Twist commands to rotate the TurtleSim. 69 | 70 | Args: 71 | node: The TurtleSim node instance 72 | 73 | Returns: 74 | StructuredTool: A LangChain tool for sending movement commands 75 | """ 76 | 77 | class RotateInput(BaseModel): 78 | angle: float = Field( 79 | description="Relative rotation in radians. Positive to turn left (counterclockwise), negative to turn right (clockwise)." 80 | ) 81 | 82 | def inner(angle: float) -> str: 83 | if angle != 0.0: 84 | node.rotate_angle(angle_rad=angle) 85 | 86 | if not node.wait_for_pose(timeout=0.5): 87 | return "Pose data not received yet." 88 | p = node.current_pose 89 | return ( 90 | f"Current Pose of the turtlebot: x={p.x:.2f}, y={p.y:.2f}, " 91 | f"yaw={p.theta:.2f} rad, " 92 | ) 93 | 94 | return StructuredTool.from_function( 95 | func=inner, 96 | name="rotate_robot", 97 | description=""" 98 | Rotates the TurtleSim by the specified angle (in radians). 99 | 100 | Args: 101 | - angle (float): Relative rotation in radians. Positive for left (counterclockwise), negative for right (clockwise). 102 | """, 103 | args_schema=RotateInput, 104 | ) 105 | 106 | 107 | def make_move_non_linear_tool(node): 108 | """ 109 | Create a LangChain tool for executing curved movement. 110 | 111 | Args: 112 | node: The TurtleSim node instance 113 | 114 | Returns: 115 | StructuredTool: LangChain-compatible tool 116 | """ 117 | 118 | class MoveCurveInput(BaseModel): 119 | linear_velocity: float = Field( 120 | description="Forward/backward speed in cm/s. Positive to move forward, negative to move backward." 121 | ) 122 | angular_velocity: float = Field( 123 | description="Rotational speed in rad/s. Positive to turn left (counterclockwise), negative to turn right (clockwise)." 124 | ) 125 | duration_sec: float = Field(description="Duration of movement in seconds.") 126 | 127 | async def inner( 128 | linear_velocity: float, angular_velocity: float, duration_sec: float 129 | ) -> str: 130 | result = await node.move_non_linear( 131 | linear_velocity=linear_velocity, 132 | angular_velocity=angular_velocity, 133 | duration_sec=duration_sec, 134 | ) 135 | # Check if movement was interrupted due to out of bounds 136 | if not result: 137 | return "Turtle went out of bounds during curved movement and has been reset to the starting position." 138 | 139 | if not node.wait_for_pose(timeout=0.5): 140 | return "Pose data not received yet." 141 | p = node.current_pose 142 | return ( 143 | f"Current Pose of the turtlebot: x={p.x:.2f}, y={p.y:.2f}, " 144 | f"yaw={p.theta:.2f} rad, " 145 | ) 146 | 147 | return StructuredTool.from_function( 148 | func=inner, 149 | coroutine=inner, 150 | name="move_non_linear", 151 | description=""" 152 | Moves the TurtleSim in a curved trajectory. 153 | 154 | Args: 155 | - linear_velocity (cm/s): Forward/backward speed. Positive for forward motion. 156 | - angular_velocity (rad/s): Rotational speed. Positive for left (CCW) turns. 157 | - duration_sec (s): How long the motion lasts. 158 | 159 | Use this to make the robot move in arcs or spirals. 160 | """, 161 | args_schema=MoveCurveInput, 162 | ) 163 | 164 | 165 | def make_teleport_absolute_tool(node): 166 | """ 167 | Create a tool that teleports the turtle to an absolute position. 168 | 169 | Args: 170 | node: The TurtleSimAgent node instance 171 | 172 | Returns: 173 | StructuredTool: A LangChain tool for absolute teleportation 174 | """ 175 | 176 | class TeleportAbsInput(BaseModel): 177 | x: float = Field(description="Target X position (0.0-11.0)") 178 | y: float = Field(description="Target Y position (0.0-11.0)") 179 | yaw: float = Field(description="Target orientation in radians") 180 | 181 | async def inner(x: float, y: float, yaw: float) -> str: 182 | current_r = node.pen_r 183 | current_g = node.pen_g 184 | current_b = node.pen_b 185 | current_width = node.pen_width 186 | 187 | await node.call_set_pen_async( 188 | current_r, current_g, current_b, current_width, True 189 | ) 190 | await node.call_teleport_absolute_async(x, y, yaw) 191 | await node.call_set_pen_async( 192 | current_r, current_g, current_b, current_width, False 193 | ) 194 | return f"Turtle teleported to position ({x}, {y}) with orientation {yaw} rad." 195 | 196 | return StructuredTool.from_function( 197 | func=inner, 198 | coroutine=inner, 199 | name="teleport_absolute", 200 | description=""" 201 | Teleports the turtle to an absolute position and absolute orientation instantly without drawing a trail. 202 | The pen is automatically lifted up before teleporting and lowered afterward so don't have to use set_pen_up_down tool at the same time. 203 | Example: If you put {'x': 5.0, 'y': 5.0, 'yaw': 1.57}, you will be at the position of (5.0, 5.0) facing north. 204 | 205 | Args: 206 | - x (float): Target X position (0.0-11.0). 207 | - y (float): Target Y position (0.0-11.0). 208 | - yaw (float): Target yaw in radians. 209 | """, 210 | args_schema=TeleportAbsInput, 211 | ) 212 | 213 | 214 | def make_teleport_relative_tool(node): 215 | """ 216 | Create a tool that teleports the turtle relative to its current position. 217 | 218 | Args: 219 | node: The TurtleSimAgent node instance 220 | 221 | Returns: 222 | StructuredTool: A LangChain tool for relative teleportation 223 | """ 224 | 225 | class TeleportRelInput(BaseModel): 226 | linear: float = Field(description="Linear distance to teleport forward") 227 | angular: float = Field(description="Angular rotation in radians") 228 | 229 | async def inner(linear: float, angular: float) -> str: 230 | current_r = node.pen_r 231 | current_g = node.pen_g 232 | current_b = node.pen_b 233 | current_width = node.pen_width 234 | 235 | await node.call_set_pen_async( 236 | current_r, current_g, current_b, current_width, True 237 | ) 238 | await node.call_teleport_relative_async(linear, angular) 239 | await node.call_set_pen_async( 240 | current_r, current_g, current_b, current_width, False 241 | ) 242 | return ( 243 | f"Turtle teleported {linear} units forward and rotated {angular} radians." 244 | ) 245 | 246 | return StructuredTool.from_function( 247 | func=inner, 248 | coroutine=inner, 249 | name="teleport_relative", 250 | description=""" 251 | Teleports the turtle relative to its current position and orientation instantly without drawing a trail. 252 | 253 | Args: 254 | - linear (float): Linear distance to teleport forward from current position. 255 | - angular (float): Angular rotation in radians from current orientation. 256 | """, 257 | args_schema=TeleportRelInput, 258 | ) 259 | 260 | 261 | def make_move_on_arc_tool(node): 262 | """ 263 | Create a tool that moves turtle along a circular arc using radius and angle. 264 | 265 | Args: 266 | node: The TB3Agent node instance 267 | 268 | Returns: 269 | StructuredTool: LangChain tool to move turtle along an arc. 270 | """ 271 | 272 | class ArcMoveInput(BaseModel): 273 | radius: float = Field(description="Radius of the circular path in centimeters") 274 | circular_angle: float = Field( 275 | description="Angle to rotate along the arc in radians" 276 | ) 277 | 278 | async def inner(radius: float, circular_angle: float) -> str: 279 | linear_velocity, angular_velocity = ( 280 | compute_linear_and_angular_velocity_from_radius.invoke({"radius": radius}) 281 | ) 282 | 283 | duration = compute_duration_from_circular_angle_and_angular_velocity.invoke( 284 | {"circular_angle": circular_angle, "angular_velocity": angular_velocity} 285 | ) 286 | 287 | if circular_angle < 0: 288 | angular_velocity = -angular_velocity 289 | 290 | result = await node.move_non_linear( 291 | linear_velocity=linear_velocity, 292 | angular_velocity=angular_velocity, 293 | duration_sec=duration, 294 | ) 295 | if not result: 296 | return "Turtle went out of bounds during movement and has been reset to the starting position." 297 | if not node.wait_for_pose(timeout=0.5): 298 | return "Pose data not received yet." 299 | p = node.current_pose 300 | return ( 301 | f"Current Pose of the turtlebot: x={p.x:.2f}, y={p.y:.2f}, " 302 | f"yaw={p.theta:.2f} rad, " 303 | ) 304 | 305 | return StructuredTool.from_function( 306 | func=inner, 307 | coroutine=inner, 308 | name="move_on_arc", 309 | description=""" 310 | Moves the turtle along a circular arc using the specified radius and angle. 311 | Example: If you want to draw a semi-circular arc from left to right, you need to face north and set the circular_angle parameter to -3.14. 312 | Conversely, if you want to draw a semi-circular arc from right to left, you need to face north and set circular_angle to +3.14. 313 | 314 | Args: 315 | radius (float): radius of the circular path in centimeters 316 | circular_angle (float) : angle to rotate along the arc in radians 317 | """, 318 | args_schema=ArcMoveInput, 319 | ) 320 | -------------------------------------------------------------------------------- /turtlesim_agent/turtlesim_node.py: -------------------------------------------------------------------------------- 1 | import math 2 | import threading 3 | import time 4 | 5 | import rclpy 6 | from geometry_msgs.msg import Twist 7 | from rclpy.node import Node 8 | from std_srvs.srv import Empty 9 | from turtlesim.msg import Pose 10 | from turtlesim.srv import Kill, SetPen, Spawn, TeleportAbsolute, TeleportRelative 11 | 12 | from turtlesim_agent.utils import normalize_angle 13 | 14 | TWIST_ANGULAR = 0.8 15 | TWIST_VELOCITY = 1.0 16 | ROTATION_ERROR_THRESHOLD = 0.034 # radians 17 | DISTANCE_ERROR_THRESHOLD = 0.1 # centimeters 18 | PUBLISH_RATE = 0.05 19 | 20 | 21 | class TurtleSimAgent(Node): 22 | def __init__(self): 23 | super().__init__("turtlesim_agent") 24 | self.declare_parameter("interface", "cli") 25 | self.declare_parameter("agent_model", "gemini-2.0-flash") 26 | 27 | self.interface = ( 28 | self.get_parameter("interface").get_parameter_value().string_value 29 | ) 30 | self.agent_model = ( 31 | self.get_parameter("agent_model").get_parameter_value().string_value 32 | ) 33 | 34 | # Movement publisher and subscription 35 | self.pub = self.create_publisher(Twist, f"/turtle1/cmd_vel", 10) 36 | self.create_subscription(Pose, f"/turtle1/pose", self.pose_callback, 10) 37 | self.current_pose = None 38 | 39 | # Synchronization 40 | self.pose_ready = threading.Event() 41 | 42 | # Pen state management 43 | self._pen_r = 255 44 | self._pen_g = 255 45 | self._pen_b = 255 46 | self._pen_width = 3 47 | self._pen_off = False 48 | 49 | # Create service clients 50 | self.reset_client = self.create_client(Empty, "/reset") 51 | self.clear_client = self.create_client(Empty, "/clear") 52 | self.kill_client = self.create_client(Kill, "/kill") 53 | self.spawn_client = self.create_client(Spawn, "/spawn") 54 | self.set_pen_client = self.create_client(SetPen, "/turtle1/set_pen") 55 | self.teleport_abs_client = self.create_client( 56 | TeleportAbsolute, "/turtle1/teleport_absolute" 57 | ) 58 | self.teleport_rel_client = self.create_client( 59 | TeleportRelative, "/turtle1/teleport_relative" 60 | ) 61 | 62 | # ===== Pen property accessors ===== 63 | @property 64 | def pen_r(self): 65 | """Get the red component of the pen color (0-255)""" 66 | return self._pen_r 67 | 68 | @property 69 | def pen_g(self): 70 | """Get the green component of the pen color (0-255)""" 71 | return self._pen_g 72 | 73 | @property 74 | def pen_b(self): 75 | """Get the blue component of the pen color (0-255)""" 76 | return self._pen_b 77 | 78 | @property 79 | def pen_width(self): 80 | """Get the pen width""" 81 | return self._pen_width 82 | 83 | @property 84 | def pen_off(self): 85 | """Get whether the pen is off (True) or on (False)""" 86 | return self._pen_off 87 | 88 | @property 89 | def pen_color(self): 90 | """Get the pen color as RGB tuple (r, g, b)""" 91 | return (self._pen_r, self._pen_g, self._pen_b) 92 | 93 | @property 94 | def pen_info(self): 95 | """Get complete pen information as dictionary""" 96 | return { 97 | "r": self._pen_r, 98 | "g": self._pen_g, 99 | "b": self._pen_b, 100 | "width": self._pen_width, 101 | "off": self._pen_off, 102 | "color_rgb": (self._pen_r, self._pen_g, self._pen_b), 103 | "is_drawing": not self._pen_off, 104 | } 105 | 106 | def pose_callback(self, msg): 107 | self.current_pose = msg 108 | self.pose_ready.set() 109 | 110 | def wait_for_pose(self, timeout=1.0): 111 | return self.pose_ready.wait(timeout=timeout) 112 | 113 | def _stop_robot(self): 114 | """Send stop commands to bring the robot to a complete halt.""" 115 | stop_cmd = Twist() 116 | for _ in range(5): 117 | self.pub.publish(stop_cmd) 118 | time.sleep(PUBLISH_RATE) 119 | 120 | # ===== Boundary check method ===== 121 | def is_within_bounds(self, x_min=0, x_max=11, y_min=0, y_max=11): 122 | """ 123 | Check if the turtle is within the specified bounds. 124 | 125 | Args: 126 | x_min (float): Minimum x coordinate (default: 0) 127 | x_max (float): Maximum x coordinate (default: 11) 128 | y_min (float): Minimum y coordinate (default: 0) 129 | y_max (float): Maximum y coordinate (default: 11) 130 | 131 | Returns: 132 | bool: True if turtle is within bounds, False otherwise 133 | """ 134 | if self.current_pose is None: 135 | self.get_logger().warning("Current pose is not available") 136 | return False 137 | 138 | x, y = self.current_pose.x, self.current_pose.y 139 | within_bounds = (x_min < x < x_max) and (y_min < y < y_max) 140 | 141 | if not within_bounds: 142 | self.get_logger().info( 143 | f"Turtle is out of bounds: x={x:.2f}, y={y:.2f} " 144 | f"(bounds: {x_min} 0 else -TWIST_ANGULAR 191 | 192 | while rclpy.ok(): 193 | current = self.current_pose.theta 194 | diff = normalize_angle(target_yaw - current) 195 | 196 | if abs(diff) < ROTATION_ERROR_THRESHOLD: 197 | break 198 | 199 | twist = Twist() 200 | twist.angular.z = angular_velocity 201 | self.pub.publish(twist) 202 | time.sleep(PUBLISH_RATE) 203 | 204 | self._stop_robot() 205 | return True 206 | 207 | async def move_linear(self, distance_m): 208 | """ 209 | Move the robot by a specified distance. 210 | 211 | Args: 212 | distance_m: Distance to move in centimeters (positive=forward) 213 | 214 | Returns: 215 | bool: True if movement completed successfully, False if out of bounds 216 | """ 217 | if not self.wait_for_pose(): 218 | self.get_logger().warning("Failed to get initial pose for movement") 219 | return False 220 | 221 | start_x, start_y = self.current_pose.x, self.current_pose.y 222 | self.get_logger().info( 223 | f"Moving: distance={distance_m}cm from ({start_x}, {start_y})" 224 | ) 225 | 226 | linear_velocity = TWIST_VELOCITY if distance_m > 0 else -TWIST_VELOCITY 227 | 228 | while rclpy.ok(): 229 | # Check bounds and reset if out of bounds 230 | if not self.is_within_bounds(): 231 | self.get_logger().warning( 232 | "Turtle went out of bounds during linear movement" 233 | ) 234 | await self.call_reset_async() 235 | self._stop_robot() 236 | return False 237 | 238 | dx = self.current_pose.x - start_x 239 | dy = self.current_pose.y - start_y 240 | moved_distance = math.sqrt(dx**2 + dy**2) 241 | remaining = abs(distance_m) - moved_distance 242 | 243 | if remaining < DISTANCE_ERROR_THRESHOLD: 244 | break 245 | 246 | twist = Twist() 247 | twist.linear.x = linear_velocity 248 | self.pub.publish(twist) 249 | time.sleep(PUBLISH_RATE) 250 | 251 | self._stop_robot() 252 | return True 253 | 254 | async def move_non_linear( 255 | self, 256 | duration_sec, 257 | linear_velocity=TWIST_VELOCITY, 258 | angular_velocity=TWIST_ANGULAR, 259 | ): 260 | """ 261 | Move the robot with specified linear and angular velocities for a certain duration. 262 | 263 | Args: 264 | linear_velocity (float): Linear velocity in cm/s. Positive for forward. 265 | angular_velocity (float): Angular velocity in rad/s. Positive for left turn. 266 | duration_sec (float): Time in seconds to apply the twist. 267 | 268 | Returns: 269 | bool: True if movement executed successfully, False if out of bounds 270 | """ 271 | if not self.wait_for_pose(): 272 | self.get_logger().warning("Failed to get initial pose for curve movement") 273 | return False 274 | 275 | self.get_logger().info( 276 | f"Moving in a curve: linear_velocity={linear_velocity} cm/s, " 277 | f"angular_velocity={angular_velocity} rad/s, duration={duration_sec} sec" 278 | ) 279 | 280 | start_time = time.time() 281 | while rclpy.ok() and (time.time() - start_time < duration_sec): 282 | # Check bounds and reset if out of bounds 283 | if not self.is_within_bounds(): 284 | self.get_logger().warning( 285 | "Turtle went out of bounds during non-linear movement" 286 | ) 287 | await self.call_reset_async() 288 | self._stop_robot() 289 | return False 290 | 291 | twist = Twist() 292 | twist.linear.x = linear_velocity 293 | twist.angular.z = angular_velocity 294 | self.pub.publish(twist) 295 | time.sleep(PUBLISH_RATE) 296 | 297 | self._stop_robot() 298 | return True 299 | 300 | # ===== Service client methods ===== 301 | def wait_for_service(self, client): 302 | """Wait for a service to become available.""" 303 | while not client.wait_for_service(timeout_sec=1.0): 304 | self.get_logger().info("Service not available, waiting...") 305 | 306 | async def call_reset_async(self): 307 | """Reset the turtle simulation.""" 308 | self.wait_for_service(self.reset_client) 309 | request = Empty.Request() 310 | future = self.reset_client.call_async(request) 311 | await future 312 | if future.result() is not None: 313 | self.get_logger().info("Reset the turtle") 314 | # Reset pen to default values after reset 315 | self._pen_r = 0 316 | self._pen_g = 0 317 | self._pen_b = 255 318 | self._pen_width = 3 319 | self._pen_off = False 320 | else: 321 | self.get_logger().error("Failed to reset turtle") 322 | 323 | async def call_clear_async(self): 324 | """Clear the turtlesim screen.""" 325 | self.wait_for_service(self.clear_client) 326 | request = Empty.Request() 327 | future = self.clear_client.call_async(request) 328 | await future 329 | if future.result() is not None: 330 | self.get_logger().info("Cleared the screen") 331 | else: 332 | self.get_logger().error("Failed to clear screen") 333 | 334 | async def call_kill_async(self, name): 335 | """Kill a turtle by name.""" 336 | self.wait_for_service(self.kill_client) 337 | request = Kill.Request() 338 | request.name = name 339 | future = self.kill_client.call_async(request) 340 | await future 341 | if future.result() is not None: 342 | self.get_logger().info(f"Killed turtle: {name}") 343 | else: 344 | self.get_logger().error(f"Failed to kill turtle: {name}") 345 | 346 | async def call_spawn_async(self, x, y, theta, name=""): 347 | """Spawn a new turtle at specified position and orientation.""" 348 | self.wait_for_service(self.spawn_client) 349 | request = Spawn.Request() 350 | request.x = x 351 | request.y = y 352 | request.theta = theta 353 | request.name = name 354 | future = self.spawn_client.call_async(request) 355 | await future 356 | if future.result() is not None: 357 | self.get_logger().info(f"Spawned turtle: {future.result().name}") 358 | return future.result().name 359 | else: 360 | self.get_logger().error("Failed to spawn turtle") 361 | return None 362 | 363 | async def call_set_pen_async(self, r, g, b, width, off): 364 | """Set the pen properties for drawing.""" 365 | self.wait_for_service(self.set_pen_client) 366 | request = SetPen.Request() 367 | request.r = r 368 | request.g = g 369 | request.b = b 370 | request.width = width 371 | request.off = off 372 | future = self.set_pen_client.call_async(request) 373 | await future 374 | if future.result() is not None: 375 | # Update internal pen state after successful service call 376 | self._pen_r = r 377 | self._pen_g = g 378 | self._pen_b = b 379 | self._pen_width = width 380 | self._pen_off = off 381 | self.get_logger().info( 382 | f"Set pen: R={r}, G={g}, B={b}, width={width}, off={off}" 383 | ) 384 | else: 385 | self.get_logger().error("Failed to set pen") 386 | 387 | async def call_teleport_absolute_async(self, x, y, theta): 388 | """Teleport turtle to absolute position and orientation.""" 389 | self.wait_for_service(self.teleport_abs_client) 390 | request = TeleportAbsolute.Request() 391 | request.x = x 392 | request.y = y 393 | request.theta = theta 394 | future = self.teleport_abs_client.call_async(request) 395 | await future 396 | if future.result() is not None: 397 | self.get_logger().info(f"Teleported to: x={x}, y={y}, theta={theta}") 398 | else: 399 | self.get_logger().error("Failed to teleport") 400 | 401 | async def call_teleport_relative_async(self, linear, angular): 402 | """Teleport turtle relative to current position.""" 403 | self.wait_for_service(self.teleport_rel_client) 404 | request = TeleportRelative.Request() 405 | request.linear = linear 406 | request.angular = angular 407 | future = self.teleport_rel_client.call_async(request) 408 | await future 409 | if future.result() is not None: 410 | self.get_logger().info( 411 | f"Relative teleport: linear={linear}, angular={angular}" 412 | ) 413 | else: 414 | self.get_logger().error("Failed to teleport") 415 | --------------------------------------------------------------------------------