├── 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 | 
2 | 
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 |
--------------------------------------------------------------------------------