diff --git a/dimos/agents2/skills/demo_calculator_skill.py b/dimos/agents2/skills/demo_calculator_skill.py new file mode 100644 index 0000000000..841d571606 --- /dev/null +++ b/dimos/agents2/skills/demo_calculator_skill.py @@ -0,0 +1,43 @@ +# Copyright 2025 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dimos.core.skill_module import SkillModule +from dimos.protocol.skill.skill import skill + + +class DemoCalculatorSkill(SkillModule): + def start(self) -> None: + super().start() + + def stop(self) -> None: + super().stop() + + @skill() + def sum_numbers(self, n1: int, n2: int, *args: int, **kwargs: int) -> str: + """This skill adds two numbers. Always use this tool. Never add up numbers yourself. + + Example: + + sum_numbers(100, 20) + + Args: + sum (str): The sum, as a string. E.g., "120" + """ + + return f"{int(n1) + int(n2)}" + + +demo_calculator_skill = DemoCalculatorSkill.blueprint + +__all__ = ["DemoCalculatorSkill", "demo_calculator_skill"] diff --git a/dimos/agents2/skills/demo_skill.py b/dimos/agents2/skills/demo_skill.py new file mode 100644 index 0000000000..f549e6115c --- /dev/null +++ b/dimos/agents2/skills/demo_skill.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +# Copyright 2025 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dotenv import load_dotenv + +from dimos.agents2.agent import llm_agent +from dimos.agents2.cli.human import human_input +from dimos.agents2.skills.demo_calculator_skill import demo_calculator_skill +from dimos.agents2.system_prompt import get_system_prompt +from dimos.core.blueprints import autoconnect + +load_dotenv() + + +demo_skill = autoconnect( + demo_calculator_skill(), + human_input(), + llm_agent(system_prompt=get_system_prompt()), +) diff --git a/dimos/core/__init__.py b/dimos/core/__init__.py index a3ded7a003..cf040f77eb 100644 --- a/dimos/core/__init__.py +++ b/dimos/core/__init__.py @@ -289,3 +289,4 @@ def wait_exit() -> None: time.sleep(1) except KeyboardInterrupt: print("exiting...") + return diff --git a/dimos/core/module_coordinator.py b/dimos/core/module_coordinator.py index a740bef494..88dfe3b129 100644 --- a/dimos/core/module_coordinator.py +++ b/dimos/core/module_coordinator.py @@ -68,6 +68,6 @@ def loop(self) -> None: while True: time.sleep(0.1) except KeyboardInterrupt: - pass + return finally: self.stop() diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index f6ea5deda9..1054b8133c 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -32,6 +32,7 @@ "unitree-g1-joystick": "dimos.robot.unitree_webrtc.unitree_g1_blueprints:with_joystick", "unitree-g1-full": "dimos.robot.unitree_webrtc.unitree_g1_blueprints:full_featured", "demo-osm": "dimos.mapping.osm.demo_osm:demo_osm", + "demo-skill": "dimos.agents2.skills.demo_skill:demo_skill", "demo-gps-nav": "dimos.agents2.skills.demo_gps_nav:demo_gps_nav_skill", "demo-google-maps-skill": "dimos.agents2.skills.demo_google_maps_skill:demo_google_maps_skill", "demo-remapping": "dimos.robot.unitree_webrtc.demo_remapping:remapping", diff --git a/dimos/robot/cli/test_dimos_robot_e2e.py b/dimos/robot/cli/test_dimos_robot_e2e.py new file mode 100644 index 0000000000..72e638abc8 --- /dev/null +++ b/dimos/robot/cli/test_dimos_robot_e2e.py @@ -0,0 +1,156 @@ +# Copyright 2025 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import signal +import subprocess +import time + +import lcm +import pytest + +from dimos.core.transport import pLCMTransport +from dimos.protocol.service.lcmservice import LCMService + + +class LCMSpy(LCMService): + messages: dict[str, list[bytes]] = {} + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self.l = lcm.LCM() + + def start(self) -> None: + super().start() + if self.l: + self.l.subscribe(".*", self.msg) + + def wait_for_topic(self, topic: str, timeout: float = 30.0) -> list[bytes]: + start_time = time.time() + while time.time() - start_time < timeout: + if topic in self.messages: + return self.messages[topic] + time.sleep(0.1) + raise TimeoutError(f"Timeout waiting for topic {topic}") + + def wait_for_message_content( + self, topic: str, content_contains: bytes, timeout: float = 30.0 + ) -> None: + start_time = time.time() + while time.time() - start_time < timeout: + if topic in self.messages: + for msg in self.messages[topic]: + if content_contains in msg: + return + time.sleep(0.1) + raise TimeoutError(f"Timeout waiting for message content on topic {topic}") + + def stop(self) -> None: + super().stop() + + def msg(self, topic, data) -> None: + self.messages.setdefault(topic, []).append(data) + + +class DimosRobotCall: + process: subprocess.Popen | None + + def __init__(self) -> None: + self.process = None + + def start(self): + self.process = subprocess.Popen( + ["dimos-robot", "run", "demo-skill"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + def stop(self) -> None: + if self.process is None: + return + + try: + # Send the kill signal (SIGTERM for graceful shutdown) + self.process.send_signal(signal.SIGTERM) + + # Record the time when we sent the kill signal + shutdown_start = time.time() + + # Wait for the process to terminate with a 30-second timeout + try: + self.process.wait(timeout=30) + shutdown_duration = time.time() - shutdown_start + + # Verify it shut down in time + assert shutdown_duration <= 30, ( + f"Process took {shutdown_duration:.2f} seconds to shut down, " + f"which exceeds the 30-second limit" + ) + except subprocess.TimeoutExpired: + # If we reach here, the process didn't terminate in 30 seconds + self.process.kill() # Force kill + self.process.wait() # Clean up + raise AssertionError( + "Process did not shut down within 30 seconds after receiving SIGTERM" + ) + + except Exception: + # Clean up if something goes wrong + if self.process.poll() is None: # Process still running + self.process.kill() + self.process.wait() + raise + + +@pytest.fixture +def lcm_spy(): + lcm_spy = LCMSpy() + lcm_spy.start() + yield lcm_spy + lcm_spy.stop() + + +@pytest.fixture +def dimos_robot_call(): + dimos_robot_call = DimosRobotCall() + dimos_robot_call.start() + yield dimos_robot_call + dimos_robot_call.stop() + + +@pytest.fixture +def human_input(): + transport = pLCMTransport("/human_input") + transport.lcm.start() + + def send_human_input(message: str) -> None: + transport.publish(message) + + yield send_human_input + + transport.lcm.stop() + + +@pytest.mark.skipif(bool(os.getenv("CI")), reason="LCM spy doesn't work in CI.") +def test_dimos_robot_demo_e2e(lcm_spy, dimos_robot_call, human_input): + lcm_spy.wait_for_topic("/rpc/DemoCalculatorSkill/set_LlmAgent_register_skills/res") + lcm_spy.wait_for_topic("/rpc/HumanInput/start/res") + lcm_spy.wait_for_message_content("/agent", b"AIMessage") + + human_input("what is 52983 + 587237") + + lcm_spy.wait_for_message_content("/agent", b"640220") + + assert "/rpc/DemoCalculatorSkill/sum_numbers/req" in lcm_spy.messages + assert "/rpc/DemoCalculatorSkill/sum_numbers/res" in lcm_spy.messages