From c107465b6d78f84099fe255f7b3473b0d05feee8 Mon Sep 17 00:00:00 2001 From: Paul Nechifor Date: Sat, 1 Nov 2025 02:45:06 +0200 Subject: [PATCH 1/4] e2e dimos-robot test with skills --- dimos/agents2/skills/demo_calculator_skill.py | 46 ++++++ dimos/agents2/skills/demo_skill.py | 31 ++++ dimos/robot/all_blueprints.py | 1 + dimos/robot/cli/test_dimos_robot_e2e.py | 154 ++++++++++++++++++ 4 files changed, 232 insertions(+) create mode 100644 dimos/agents2/skills/demo_calculator_skill.py create mode 100644 dimos/agents2/skills/demo_skill.py create mode 100644 dimos/robot/cli/test_dimos_robot_e2e.py diff --git a/dimos/agents2/skills/demo_calculator_skill.py b/dimos/agents2/skills/demo_calculator_skill.py new file mode 100644 index 0000000000..739c691553 --- /dev/null +++ b/dimos/agents2/skills/demo_calculator_skill.py @@ -0,0 +1,46 @@ +# 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.core.stream import Out +from dimos.protocol.skill.skill import skill + + +class DemoCalculatorSkill(SkillModule): + output: Out[int] = None + + 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/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index c177723e66..32c020411d 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -23,6 +23,7 @@ "unitree-go2-jpeglcm": "dimos.robot.unitree_webrtc.unitree_go2_blueprints:standard_with_jpeglcm", "unitree-go2-agentic": "dimos.robot.unitree_webrtc.unitree_go2_blueprints:agentic", "demo-osm": "dimos.mapping.osm.demo_osm:demo_osm", + "demo-skill": "dimos.agents2.skills.demo_skill:demo_skill", "demo-remapping": "dimos.robot.unitree_webrtc.demo_remapping:remapping", "demo-remapping-transport": "dimos.robot.unitree_webrtc.demo_remapping:remapping_and_transport", } 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..29e241895c --- /dev/null +++ b/dimos/robot/cli/test_dimos_robot_e2e.py @@ -0,0 +1,154 @@ +# 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 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() + + +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 From 1155097fc285c0194e5a2b5ac237cf86c359862e Mon Sep 17 00:00:00 2001 From: Paul Nechifor Date: Sat, 1 Nov 2025 03:58:09 +0200 Subject: [PATCH 2/4] remove bad --- dimos/agents2/skills/demo_calculator_skill.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/dimos/agents2/skills/demo_calculator_skill.py b/dimos/agents2/skills/demo_calculator_skill.py index 739c691553..841d571606 100644 --- a/dimos/agents2/skills/demo_calculator_skill.py +++ b/dimos/agents2/skills/demo_calculator_skill.py @@ -13,13 +13,10 @@ # limitations under the License. from dimos.core.skill_module import SkillModule -from dimos.core.stream import Out from dimos.protocol.skill.skill import skill class DemoCalculatorSkill(SkillModule): - output: Out[int] = None - def start(self) -> None: super().start() From b9934cde1a6109abcb5b0b29f660ba86643d5caf Mon Sep 17 00:00:00 2001 From: Paul Nechifor Date: Tue, 4 Nov 2025 02:37:30 +0200 Subject: [PATCH 3/4] skip test in CI --- dimos/robot/cli/test_dimos_robot_e2e.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dimos/robot/cli/test_dimos_robot_e2e.py b/dimos/robot/cli/test_dimos_robot_e2e.py index 29e241895c..72e638abc8 100644 --- a/dimos/robot/cli/test_dimos_robot_e2e.py +++ b/dimos/robot/cli/test_dimos_robot_e2e.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os import signal import subprocess import time @@ -141,6 +142,7 @@ def send_human_input(message: str) -> None: 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") From 198215d0b131283d9d2d95c3b2b04d24ec22ac05 Mon Sep 17 00:00:00 2001 From: Paul Nechifor Date: Fri, 7 Nov 2025 23:22:36 +0200 Subject: [PATCH 4/4] fix wait_exit --- dimos/core/__init__.py | 1 + dimos/core/module_coordinator.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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()