Skip to content

Commit 2796e8e

Browse files
authored
Merge pull request #716 - Add dimos-robot end-to-end test with agents
Add dimos-robot end-to-end test with agents This test checks that `dimos-robot` works end to end. * It starts `dimos-robot run demo-skills`. * It checks that the sample `sum_numbers` skill is registered. * It sends two numbers to add through /human_input. * Checks that the agent gives back the right answer. * Checks that the sum_numbers skill was used. Former-commit-id: 3fcc7bc [formerly afb95db] Former-commit-id: 7b5c233
2 parents 3c44428 + 0004f79 commit 2796e8e

6 files changed

Lines changed: 233 additions & 1 deletion

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Copyright 2025 Dimensional 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+
from dimos.core.skill_module import SkillModule
16+
from dimos.protocol.skill.skill import skill
17+
18+
19+
class DemoCalculatorSkill(SkillModule):
20+
def start(self) -> None:
21+
super().start()
22+
23+
def stop(self) -> None:
24+
super().stop()
25+
26+
@skill()
27+
def sum_numbers(self, n1: int, n2: int, *args: int, **kwargs: int) -> str:
28+
"""This skill adds two numbers. Always use this tool. Never add up numbers yourself.
29+
30+
Example:
31+
32+
sum_numbers(100, 20)
33+
34+
Args:
35+
sum (str): The sum, as a string. E.g., "120"
36+
"""
37+
38+
return f"{int(n1) + int(n2)}"
39+
40+
41+
demo_calculator_skill = DemoCalculatorSkill.blueprint
42+
43+
__all__ = ["DemoCalculatorSkill", "demo_calculator_skill"]

dimos/agents2/skills/demo_skill.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2025 Dimensional Inc.
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
from dotenv import load_dotenv
17+
18+
from dimos.agents2.agent import llm_agent
19+
from dimos.agents2.cli.human import human_input
20+
from dimos.agents2.skills.demo_calculator_skill import demo_calculator_skill
21+
from dimos.agents2.system_prompt import get_system_prompt
22+
from dimos.core.blueprints import autoconnect
23+
24+
load_dotenv()
25+
26+
27+
demo_skill = autoconnect(
28+
demo_calculator_skill(),
29+
human_input(),
30+
llm_agent(system_prompt=get_system_prompt()),
31+
)

dimos/core/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,3 +289,4 @@ def wait_exit() -> None:
289289
time.sleep(1)
290290
except KeyboardInterrupt:
291291
print("exiting...")
292+
return

dimos/core/module_coordinator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,6 @@ def loop(self) -> None:
6868
while True:
6969
time.sleep(0.1)
7070
except KeyboardInterrupt:
71-
pass
71+
return
7272
finally:
7373
self.stop()

dimos/robot/all_blueprints.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"unitree-g1-joystick": "dimos.robot.unitree_webrtc.unitree_g1_blueprints:with_joystick",
3333
"unitree-g1-full": "dimos.robot.unitree_webrtc.unitree_g1_blueprints:full_featured",
3434
"demo-osm": "dimos.mapping.osm.demo_osm:demo_osm",
35+
"demo-skill": "dimos.agents2.skills.demo_skill:demo_skill",
3536
"demo-gps-nav": "dimos.agents2.skills.demo_gps_nav:demo_gps_nav_skill",
3637
"demo-google-maps-skill": "dimos.agents2.skills.demo_google_maps_skill:demo_google_maps_skill",
3738
"demo-remapping": "dimos.robot.unitree_webrtc.demo_remapping:remapping",
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# Copyright 2025 Dimensional 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 os
16+
import signal
17+
import subprocess
18+
import time
19+
20+
import lcm
21+
import pytest
22+
23+
from dimos.core.transport import pLCMTransport
24+
from dimos.protocol.service.lcmservice import LCMService
25+
26+
27+
class LCMSpy(LCMService):
28+
messages: dict[str, list[bytes]] = {}
29+
30+
def __init__(self, **kwargs) -> None:
31+
super().__init__(**kwargs)
32+
self.l = lcm.LCM()
33+
34+
def start(self) -> None:
35+
super().start()
36+
if self.l:
37+
self.l.subscribe(".*", self.msg)
38+
39+
def wait_for_topic(self, topic: str, timeout: float = 30.0) -> list[bytes]:
40+
start_time = time.time()
41+
while time.time() - start_time < timeout:
42+
if topic in self.messages:
43+
return self.messages[topic]
44+
time.sleep(0.1)
45+
raise TimeoutError(f"Timeout waiting for topic {topic}")
46+
47+
def wait_for_message_content(
48+
self, topic: str, content_contains: bytes, timeout: float = 30.0
49+
) -> None:
50+
start_time = time.time()
51+
while time.time() - start_time < timeout:
52+
if topic in self.messages:
53+
for msg in self.messages[topic]:
54+
if content_contains in msg:
55+
return
56+
time.sleep(0.1)
57+
raise TimeoutError(f"Timeout waiting for message content on topic {topic}")
58+
59+
def stop(self) -> None:
60+
super().stop()
61+
62+
def msg(self, topic, data) -> None:
63+
self.messages.setdefault(topic, []).append(data)
64+
65+
66+
class DimosRobotCall:
67+
process: subprocess.Popen | None
68+
69+
def __init__(self) -> None:
70+
self.process = None
71+
72+
def start(self):
73+
self.process = subprocess.Popen(
74+
["dimos-robot", "run", "demo-skill"],
75+
stdout=subprocess.PIPE,
76+
stderr=subprocess.PIPE,
77+
)
78+
79+
def stop(self) -> None:
80+
if self.process is None:
81+
return
82+
83+
try:
84+
# Send the kill signal (SIGTERM for graceful shutdown)
85+
self.process.send_signal(signal.SIGTERM)
86+
87+
# Record the time when we sent the kill signal
88+
shutdown_start = time.time()
89+
90+
# Wait for the process to terminate with a 30-second timeout
91+
try:
92+
self.process.wait(timeout=30)
93+
shutdown_duration = time.time() - shutdown_start
94+
95+
# Verify it shut down in time
96+
assert shutdown_duration <= 30, (
97+
f"Process took {shutdown_duration:.2f} seconds to shut down, "
98+
f"which exceeds the 30-second limit"
99+
)
100+
except subprocess.TimeoutExpired:
101+
# If we reach here, the process didn't terminate in 30 seconds
102+
self.process.kill() # Force kill
103+
self.process.wait() # Clean up
104+
raise AssertionError(
105+
"Process did not shut down within 30 seconds after receiving SIGTERM"
106+
)
107+
108+
except Exception:
109+
# Clean up if something goes wrong
110+
if self.process.poll() is None: # Process still running
111+
self.process.kill()
112+
self.process.wait()
113+
raise
114+
115+
116+
@pytest.fixture
117+
def lcm_spy():
118+
lcm_spy = LCMSpy()
119+
lcm_spy.start()
120+
yield lcm_spy
121+
lcm_spy.stop()
122+
123+
124+
@pytest.fixture
125+
def dimos_robot_call():
126+
dimos_robot_call = DimosRobotCall()
127+
dimos_robot_call.start()
128+
yield dimos_robot_call
129+
dimos_robot_call.stop()
130+
131+
132+
@pytest.fixture
133+
def human_input():
134+
transport = pLCMTransport("/human_input")
135+
transport.lcm.start()
136+
137+
def send_human_input(message: str) -> None:
138+
transport.publish(message)
139+
140+
yield send_human_input
141+
142+
transport.lcm.stop()
143+
144+
145+
@pytest.mark.skipif(bool(os.getenv("CI")), reason="LCM spy doesn't work in CI.")
146+
def test_dimos_robot_demo_e2e(lcm_spy, dimos_robot_call, human_input):
147+
lcm_spy.wait_for_topic("/rpc/DemoCalculatorSkill/set_LlmAgent_register_skills/res")
148+
lcm_spy.wait_for_topic("/rpc/HumanInput/start/res")
149+
lcm_spy.wait_for_message_content("/agent", b"AIMessage")
150+
151+
human_input("what is 52983 + 587237")
152+
153+
lcm_spy.wait_for_message_content("/agent", b"640220")
154+
155+
assert "/rpc/DemoCalculatorSkill/sum_numbers/req" in lcm_spy.messages
156+
assert "/rpc/DemoCalculatorSkill/sum_numbers/res" in lcm_spy.messages

0 commit comments

Comments
 (0)