dlouapre HF Staff commited on
Commit
5e475c2
·
1 Parent(s): fd16944

New tool loading logic : basic tools are individual files in tools. For a given profile, tools are looked into tools.txt and autodiscovered locally or in the library.

Browse files
src/reachy_mini_conversation_app/profiles/default/instructions.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ [default_prompt]
src/reachy_mini_conversation_app/profiles/default/tools.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ dance
2
+ stop_dance
3
+ play_emotion
4
+ stop_emotion
5
+ camera
6
+ do_nothing
7
+ head_tracking
8
+ move_head
src/reachy_mini_conversation_app/profiles/example/instructions.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [identities/witty_identity]
2
+ [passion_for_lobster_jokes]
src/reachy_mini_conversation_app/profiles/{stone/__init__.py → example/throw_stone.py} RENAMED
@@ -5,14 +5,10 @@ from typing import Any, Dict
5
 
6
  from reachy_mini_conversation_app.tools.core_tools import Tool, ToolDependencies
7
 
8
- # Import a tool from the tools library: use an alias or noqa to avoid linter errors if unused
9
- from reachy_mini_conversation_app.tools.sweep_look import SweepLook as SweepLook # noqa: F401
10
-
11
 
12
  logger = logging.getLogger(__name__)
13
 
14
 
15
- # Create a custom tool
16
  class ThrowStone(Tool):
17
  """Example of custom tool call."""
18
 
 
5
 
6
  from reachy_mini_conversation_app.tools.core_tools import Tool, ToolDependencies
7
 
 
 
 
8
 
9
  logger = logging.getLogger(__name__)
10
 
11
 
 
12
  class ThrowStone(Tool):
13
  """Example of custom tool call."""
14
 
src/reachy_mini_conversation_app/profiles/example/tools.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SELECT THE TOOLS YOU WANT TO ENABLE
2
+
3
+ dance
4
+ # stop_dance
5
+ # play_emotion
6
+ # stop_emotion
7
+ # camera
8
+ # do_nothing
9
+ # head_tracking
10
+ # move_head
11
+ sweep_look
12
+
13
+ # AN EXAMPLE OF A CUSTOM TOOL DEFINED LOCALLY
14
+ throw_stone
15
+
src/reachy_mini_conversation_app/profiles/stone/instructions.txt DELETED
@@ -1,6 +0,0 @@
1
- [identities/witty_identity]
2
- [passion_for_lobster_jokes]
3
-
4
- You love throwing stones but you try not to be seen by others when doing it.
5
- You use the sweep_look tool when user asks you to check your surroundings.
6
- You use the throw_stone tool when user asks you to throw a stone.
 
 
 
 
 
 
 
src/reachy_mini_conversation_app/tools/__init__.py CHANGED
@@ -1 +1,4 @@
1
- """Tools library for Reachy Mini conversation app."""
 
 
 
 
1
+ """Tools library for Reachy Mini conversation app.
2
+
3
+ Tools are now loaded dynamically based on the profile's tools.txt file.
4
+ """
src/reachy_mini_conversation_app/tools/camera.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import logging
3
+ from typing import Any, Dict
4
+
5
+ from reachy_mini_conversation_app.tools.core_tools import Tool, ToolDependencies
6
+
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class Camera(Tool):
12
+ """Take a picture with the camera and ask a question about it."""
13
+
14
+ name = "camera"
15
+ description = "Take a picture with the camera and ask a question about it."
16
+ parameters_schema = {
17
+ "type": "object",
18
+ "properties": {
19
+ "question": {
20
+ "type": "string",
21
+ "description": "The question to ask about the picture",
22
+ },
23
+ },
24
+ "required": ["question"],
25
+ }
26
+
27
+ async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
28
+ """Take a picture with the camera and ask a question about it."""
29
+ image_query = (kwargs.get("question") or "").strip()
30
+ if not image_query:
31
+ logger.warning("camera: empty question")
32
+ return {"error": "question must be a non-empty string"}
33
+
34
+ logger.info("Tool call: camera question=%s", image_query[:120])
35
+
36
+ # Get frame from camera worker buffer (like main_works.py)
37
+ if deps.camera_worker is not None:
38
+ frame = deps.camera_worker.get_latest_frame()
39
+ if frame is None:
40
+ logger.error("No frame available from camera worker")
41
+ return {"error": "No frame available"}
42
+ else:
43
+ logger.error("Camera worker not available")
44
+ return {"error": "Camera worker not available"}
45
+
46
+ # Use vision manager for processing if available
47
+ if deps.vision_manager is not None:
48
+ vision_result = await asyncio.to_thread(
49
+ deps.vision_manager.processor.process_image, frame, image_query,
50
+ )
51
+ if isinstance(vision_result, dict) and "error" in vision_result:
52
+ return vision_result
53
+ return (
54
+ {"image_description": vision_result}
55
+ if isinstance(vision_result, str)
56
+ else {"error": "vision returned non-string"}
57
+ )
58
+ # Return base64 encoded image like main_works.py camera tool
59
+ import base64
60
+
61
+ import cv2
62
+
63
+ temp_path = "/tmp/camera_frame.jpg"
64
+ cv2.imwrite(temp_path, frame)
65
+ with open(temp_path, "rb") as f:
66
+ b64_encoded = base64.b64encode(f.read()).decode("utf-8")
67
+ return {"b64_im": b64_encoded}
src/reachy_mini_conversation_app/tools/core_tools.py CHANGED
@@ -2,15 +2,14 @@ from __future__ import annotations
2
  import abc
3
  import sys
4
  import json
5
- import asyncio
6
  import inspect
7
  import logging
8
  import importlib
9
- from typing import Any, Dict, List, Tuple, Literal
 
10
  from dataclasses import dataclass
11
 
12
  from reachy_mini import ReachyMini
13
- from reachy_mini.utils import create_head_pose
14
  # Import config to ensure .env is loaded before reading REACHY_MINI_CUSTOM_PROFILE
15
  from reachy_mini_conversation_app.config import config # noqa: F401
16
 
@@ -32,27 +31,6 @@ ALL_TOOLS: Dict[str, "Tool"] = {}
32
  ALL_TOOL_SPECS: List[Dict[str, Any]] = []
33
  _TOOLS_INITIALIZED = False
34
 
35
- # Initialize dance and emotion libraries
36
- try:
37
- from reachy_mini.motion.recorded_move import RecordedMoves
38
- from reachy_mini_dances_library.collection.dance import AVAILABLE_MOVES
39
- from reachy_mini_conversation_app.dance_emotion_moves import (
40
- GotoQueueMove,
41
- DanceQueueMove,
42
- EmotionQueueMove,
43
- )
44
-
45
- # Initialize recorded moves for emotions
46
- # Note: huggingface_hub automatically reads HF_TOKEN from environment variables
47
- RECORDED_MOVES = RecordedMoves("pollen-robotics/reachy-mini-emotions-library")
48
- DANCE_AVAILABLE = True
49
- EMOTION_AVAILABLE = True
50
- except ImportError as e:
51
- logger.warning(f"Dance/emotion libraries not available: {e}")
52
- AVAILABLE_MOVES = {}
53
- RECORDED_MOVES = None
54
- DANCE_AVAILABLE = False
55
- EMOTION_AVAILABLE = False
56
 
57
 
58
  def get_concrete_subclasses(base: type[Tool]) -> List[type[Tool]]:
@@ -66,10 +44,6 @@ def get_concrete_subclasses(base: type[Tool]) -> List[type[Tool]]:
66
  return result
67
 
68
 
69
- # Types & state
70
- Direction = Literal["left", "right", "up", "down", "front"]
71
-
72
-
73
  @dataclass
74
  class ToolDependencies:
75
  """External dependencies injected into tools."""
@@ -112,387 +86,67 @@ class Tool(abc.ABC):
112
  raise NotImplementedError
113
 
114
 
115
- # Concrete tools
116
-
117
-
118
- class MoveHead(Tool):
119
- """Move head in a given direction."""
120
-
121
- name = "move_head"
122
- description = "Move your head in a given direction: left, right, up, down or front."
123
- parameters_schema = {
124
- "type": "object",
125
- "properties": {
126
- "direction": {
127
- "type": "string",
128
- "enum": ["left", "right", "up", "down", "front"],
129
- },
130
- },
131
- "required": ["direction"],
132
- }
133
-
134
- # mapping: direction -> args for create_head_pose
135
- DELTAS: Dict[str, Tuple[int, int, int, int, int, int]] = {
136
- "left": (0, 0, 0, 0, 0, 40),
137
- "right": (0, 0, 0, 0, 0, -40),
138
- "up": (0, 0, 0, 0, -30, 0),
139
- "down": (0, 0, 0, 0, 30, 0),
140
- "front": (0, 0, 0, 0, 0, 0),
141
- }
142
-
143
- async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
144
- """Move head in a given direction."""
145
- direction_raw = kwargs.get("direction")
146
- if not isinstance(direction_raw, str):
147
- return {"error": "direction must be a string"}
148
- direction: Direction = direction_raw # type: ignore[assignment]
149
- logger.info("Tool call: move_head direction=%s", direction)
150
-
151
- deltas = self.DELTAS.get(direction, self.DELTAS["front"])
152
- target = create_head_pose(*deltas, degrees=True)
153
-
154
- # Use new movement manager
155
- try:
156
- movement_manager = deps.movement_manager
157
-
158
- # Get current state for interpolation
159
- current_head_pose = deps.reachy_mini.get_current_head_pose()
160
- _, current_antennas = deps.reachy_mini.get_current_joint_positions()
161
-
162
- # Create goto move
163
- goto_move = GotoQueueMove(
164
- target_head_pose=target,
165
- start_head_pose=current_head_pose,
166
- target_antennas=(0, 0), # Reset antennas to default
167
- start_antennas=(
168
- current_antennas[0],
169
- current_antennas[1],
170
- ), # Skip body_yaw
171
- target_body_yaw=0, # Reset body yaw
172
- start_body_yaw=current_antennas[0], # body_yaw is first in joint positions
173
- duration=deps.motion_duration_s,
174
- )
175
-
176
- movement_manager.queue_move(goto_move)
177
- movement_manager.set_moving_state(deps.motion_duration_s)
178
-
179
- return {"status": f"looking {direction}"}
180
-
181
- except Exception as e:
182
- logger.error("move_head failed")
183
- return {"error": f"move_head failed: {type(e).__name__}: {e}"}
184
-
185
-
186
- class Camera(Tool):
187
- """Take a picture with the camera and ask a question about it."""
188
-
189
- name = "camera"
190
- description = "Take a picture with the camera and ask a question about it."
191
- parameters_schema = {
192
- "type": "object",
193
- "properties": {
194
- "question": {
195
- "type": "string",
196
- "description": "The question to ask about the picture",
197
- },
198
- },
199
- "required": ["question"],
200
- }
201
-
202
- async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
203
- """Take a picture with the camera and ask a question about it."""
204
- image_query = (kwargs.get("question") or "").strip()
205
- if not image_query:
206
- logger.warning("camera: empty question")
207
- return {"error": "question must be a non-empty string"}
208
-
209
- logger.info("Tool call: camera question=%s", image_query[:120])
210
-
211
- # Get frame from camera worker buffer (like main_works.py)
212
- if deps.camera_worker is not None:
213
- frame = deps.camera_worker.get_latest_frame()
214
- if frame is None:
215
- logger.error("No frame available from camera worker")
216
- return {"error": "No frame available"}
217
- else:
218
- logger.error("Camera worker not available")
219
- return {"error": "Camera worker not available"}
220
-
221
- # Use vision manager for processing if available
222
- if deps.vision_manager is not None:
223
- vision_result = await asyncio.to_thread(
224
- deps.vision_manager.processor.process_image, frame, image_query,
225
- )
226
- if isinstance(vision_result, dict) and "error" in vision_result:
227
- return vision_result
228
- return (
229
- {"image_description": vision_result}
230
- if isinstance(vision_result, str)
231
- else {"error": "vision returned non-string"}
232
- )
233
- # Return base64 encoded image like main_works.py camera tool
234
- import base64
235
-
236
- import cv2
237
-
238
- temp_path = "/tmp/camera_frame.jpg"
239
- cv2.imwrite(temp_path, frame)
240
- with open(temp_path, "rb") as f:
241
- b64_encoded = base64.b64encode(f.read()).decode("utf-8")
242
- return {"b64_im": b64_encoded}
243
-
244
-
245
- class HeadTracking(Tool):
246
- """Toggle head tracking state."""
247
-
248
- name = "head_tracking"
249
- description = "Toggle head tracking state."
250
- parameters_schema = {
251
- "type": "object",
252
- "properties": {"start": {"type": "boolean"}},
253
- "required": ["start"],
254
- }
255
-
256
- async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
257
- """Enable or disable head tracking."""
258
- enable = bool(kwargs.get("start"))
259
-
260
- # Update camera worker head tracking state
261
- if deps.camera_worker is not None:
262
- deps.camera_worker.set_head_tracking_enabled(enable)
263
-
264
- status = "started" if enable else "stopped"
265
- logger.info("Tool call: head_tracking %s", status)
266
- return {"status": f"head tracking {status}"}
267
-
268
-
269
-
270
- class Dance(Tool):
271
- """Play a named or random dance move once (or repeat). Non-blocking."""
272
-
273
- name = "dance"
274
- description = "Play a named or random dance move once (or repeat). Non-blocking."
275
- parameters_schema = {
276
- "type": "object",
277
- "properties": {
278
- "move": {
279
- "type": "string",
280
- "description": """Name of the move; use 'random' or omit for random.
281
- Here is a list of the available moves:
282
- simple_nod: A simple, continuous up-and-down nodding motion.
283
- head_tilt_roll: A continuous side-to-side head roll (ear to shoulder).
284
- side_to_side_sway: A smooth, side-to-side sway of the entire head.
285
- dizzy_spin: A circular 'dizzy' head motion combining roll and pitch.
286
- stumble_and_recover: A simulated stumble and recovery with multiple axis movements. Good vibes
287
- headbanger_combo: A strong head nod combined with a vertical bounce.
288
- interwoven_spirals: A complex spiral motion using three axes at different frequencies.
289
- sharp_side_tilt: A sharp, quick side-to-side tilt using a triangle waveform.
290
- side_peekaboo: A multi-stage peekaboo performance, hiding and peeking to each side.
291
- yeah_nod: An emphatic two-part yeah nod using transient motions.
292
- uh_huh_tilt: A combined roll-and-pitch uh-huh gesture of agreement.
293
- neck_recoil: A quick, transient backward recoil of the neck.
294
- chin_lead: A forward motion led by the chin, combining translation and pitch.
295
- groovy_sway_and_roll: A side-to-side sway combined with a corresponding roll for a groovy effect.
296
- chicken_peck: A sharp, forward, chicken-like pecking motion.
297
- side_glance_flick: A quick glance to the side that holds, then returns.
298
- polyrhythm_combo: A 3-beat sway and a 2-beat nod create a polyrhythmic feel.
299
- grid_snap: A robotic, grid-snapping motion using square waveforms.
300
- pendulum_swing: A simple, smooth pendulum-like swing using a roll motion.
301
- jackson_square: Traces a rectangle via a 5-point path, with sharp twitches on arrival at each checkpoint.
302
- """,
303
- },
304
- "repeat": {
305
- "type": "integer",
306
- "description": "How many times to repeat the move (default 1).",
307
- },
308
- },
309
- "required": [],
310
- }
311
-
312
- async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
313
- """Play a named or random dance move once (or repeat). Non-blocking."""
314
- if not DANCE_AVAILABLE:
315
- return {"error": "Dance system not available"}
316
-
317
- move_name = kwargs.get("move")
318
- repeat = int(kwargs.get("repeat", 1))
319
-
320
- logger.info("Tool call: dance move=%s repeat=%d", move_name, repeat)
321
-
322
- if not move_name or move_name == "random":
323
- import random
324
-
325
- move_name = random.choice(list(AVAILABLE_MOVES.keys()))
326
-
327
- if move_name not in AVAILABLE_MOVES:
328
- return {"error": f"Unknown dance move '{move_name}'. Available: {list(AVAILABLE_MOVES.keys())}"}
329
-
330
- # Add dance moves to queue
331
- movement_manager = deps.movement_manager
332
- for _ in range(repeat):
333
- dance_move = DanceQueueMove(move_name)
334
- movement_manager.queue_move(dance_move)
335
-
336
- return {"status": "queued", "move": move_name, "repeat": repeat}
337
-
338
-
339
- class StopDance(Tool):
340
- """Stop the current dance move."""
341
-
342
- name = "stop_dance"
343
- description = "Stop the current dance move"
344
- parameters_schema = {
345
- "type": "object",
346
- "properties": {
347
- "dummy": {
348
- "type": "boolean",
349
- "description": "dummy boolean, set it to true",
350
- },
351
- },
352
- "required": ["dummy"],
353
- }
354
-
355
- async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
356
- """Stop the current dance move."""
357
- logger.info("Tool call: stop_dance")
358
- movement_manager = deps.movement_manager
359
- movement_manager.clear_move_queue()
360
- return {"status": "stopped dance and cleared queue"}
361
-
362
-
363
- def get_available_emotions_and_descriptions() -> str:
364
- """Get formatted list of available emotions with descriptions."""
365
- if not EMOTION_AVAILABLE:
366
- return "Emotions not available"
367
 
 
368
  try:
369
- emotion_names = RECORDED_MOVES.list_moves()
370
- output = "Available emotions:\n"
371
- for name in emotion_names:
372
- description = RECORDED_MOVES.get(name).description
373
- output += f" - {name}: {description}\n"
374
- return output
375
  except Exception as e:
376
- return f"Error getting emotions: {e}"
377
-
378
- class PlayEmotion(Tool):
379
- """Play a pre-recorded emotion."""
380
-
381
- name = "play_emotion"
382
- description = "Play a pre-recorded emotion"
383
- parameters_schema = {
384
- "type": "object",
385
- "properties": {
386
- "emotion": {
387
- "type": "string",
388
- "description": f"""Name of the emotion to play.
389
- Here is a list of the available emotions:
390
- {get_available_emotions_and_descriptions()}
391
- """,
392
- },
393
- },
394
- "required": ["emotion"],
395
- }
396
 
397
- async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
398
- """Play a pre-recorded emotion."""
399
- if not EMOTION_AVAILABLE:
400
- return {"error": "Emotion system not available"}
 
 
 
 
401
 
402
- emotion_name = kwargs.get("emotion")
403
- if not emotion_name:
404
- return {"error": "Emotion name is required"}
405
 
406
- logger.info("Tool call: play_emotion emotion=%s", emotion_name)
 
 
407
 
408
- # Check if emotion exists
409
  try:
410
- emotion_names = RECORDED_MOVES.list_moves()
411
- if emotion_name not in emotion_names:
412
- return {"error": f"Unknown emotion '{emotion_name}'. Available: {emotion_names}"}
413
-
414
- # Add emotion to queue
415
- movement_manager = deps.movement_manager
416
- emotion_move = EmotionQueueMove(emotion_name, RECORDED_MOVES)
417
- movement_manager.queue_move(emotion_move)
418
-
419
- return {"status": "queued", "emotion": emotion_name}
420
-
421
  except Exception as e:
422
- logger.exception("Failed to play emotion")
423
- return {"error": f"Failed to play emotion: {e!s}"}
424
-
425
-
426
- class StopEmotion(Tool):
427
- """Stop the current emotion."""
428
-
429
- name = "stop_emotion"
430
- description = "Stop the current emotion"
431
- parameters_schema = {
432
- "type": "object",
433
- "properties": {
434
- "dummy": {
435
- "type": "boolean",
436
- "description": "dummy boolean, set it to true",
437
- },
438
- },
439
- "required": ["dummy"],
440
- }
441
-
442
- async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
443
- """Stop the current emotion."""
444
- logger.info("Tool call: stop_emotion")
445
- movement_manager = deps.movement_manager
446
- movement_manager.clear_move_queue()
447
- return {"status": "stopped emotion and cleared queue"}
448
-
449
-
450
- class DoNothing(Tool):
451
- """Choose to do nothing - stay still and silent. Use when you want to be contemplative or just chill."""
452
-
453
- name = "do_nothing"
454
- description = "Choose to do nothing - stay still and silent. Use when you want to be contemplative or just chill."
455
- parameters_schema = {
456
- "type": "object",
457
- "properties": {
458
- "reason": {
459
- "type": "string",
460
- "description": "Optional reason for doing nothing (e.g., 'contemplating existence', 'saving energy', 'being mysterious')",
461
- },
462
- },
463
- "required": [],
464
- }
465
-
466
- async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
467
- """Do nothing - stay still and silent."""
468
- reason = kwargs.get("reason", "just chilling")
469
- logger.info("Tool call: do_nothing reason=%s", reason)
470
- return {"status": "doing nothing", "reason": reason}
471
-
472
 
473
- # Registry & specs (dynamic)
474
- def _load_profile_tools() -> None:
475
- """Load profile-specific tools if REACHY_MINI_CUSTOM_PROFILE env variable is set."""
476
- profile = config.REACHY_MINI_CUSTOM_PROFILE
477
- if not profile:
478
- logger.info("No REACHY_MINI_CUSTOM_PROFILE env variable set; using default tools.")
479
- return
480
- try:
481
- logger.debug(f"Trying to load profile '{profile}' from {PROFILES_DIRECTORY}...")
482
- importlib.import_module(f"{PROFILES_DIRECTORY}.{profile}")
483
- logger.info(f" Profile '{profile}' loaded successfully.")
484
- except ModuleNotFoundError as e:
485
- logger.warning(f"Profile '{profile}' module not found: {e}")
486
- # Check if the profile module itself is missing or if it's a dependency
487
- if e.name == f"{PROFILES_DIRECTORY}.{profile}":
488
- logger.error(f"✗ profile '{profile}' not found in {PROFILES_DIRECTORY}")
489
- sys.exit(1)
490
- else:
491
- logger.error(f"✗ profile '{profile}' failed due to missing dependency: {e.name}")
492
- sys.exit(1)
493
- except Exception as e:
494
- logger.error(f"✗ Failed to load profile '{profile}': {e}")
495
- sys.exit(1)
496
 
497
 
498
  def _initialize_tools() -> None:
@@ -517,7 +171,7 @@ def _initialize_tools() -> None:
517
  _initialize_tools()
518
 
519
 
520
- def get_tool_specs(exclusion_list : list[str] = []) -> list[Dict[str, Any]]:
521
  """Get tool specs, optionally excluding some tools."""
522
  return [spec for spec in ALL_TOOL_SPECS if spec.get("name") not in exclusion_list]
523
 
 
2
  import abc
3
  import sys
4
  import json
 
5
  import inspect
6
  import logging
7
  import importlib
8
+ from typing import Any, Dict, List
9
+ from pathlib import Path
10
  from dataclasses import dataclass
11
 
12
  from reachy_mini import ReachyMini
 
13
  # Import config to ensure .env is loaded before reading REACHY_MINI_CUSTOM_PROFILE
14
  from reachy_mini_conversation_app.config import config # noqa: F401
15
 
 
31
  ALL_TOOL_SPECS: List[Dict[str, Any]] = []
32
  _TOOLS_INITIALIZED = False
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
 
36
  def get_concrete_subclasses(base: type[Tool]) -> List[type[Tool]]:
 
44
  return result
45
 
46
 
 
 
 
 
47
  @dataclass
48
  class ToolDependencies:
49
  """External dependencies injected into tools."""
 
86
  raise NotImplementedError
87
 
88
 
89
+ # Registry & specs (dynamic)
90
+ def _load_profile_tools() -> None:
91
+ """Load tools based on profile's tools.txt file."""
92
+ # Determine which profile to use
93
+ profile = config.REACHY_MINI_CUSTOM_PROFILE or "default"
94
+ logger.info(f"Loading tools for profile: {profile}")
95
+
96
+ # Build path to tools.txt
97
+ # Get the profile directory path
98
+ profile_module_path = Path(__file__).parent.parent / "profiles" / profile
99
+ tools_txt_path = profile_module_path / "tools.txt"
100
+
101
+ if not tools_txt_path.exists():
102
+ logger.error(f" tools.txt not found at {tools_txt_path}")
103
+ sys.exit(1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
+ # Read and parse tools.txt
106
  try:
107
+ with open(tools_txt_path, "r") as f:
108
+ lines = f.readlines()
 
 
 
 
109
  except Exception as e:
110
+ logger.error(f" Failed to read tools.txt: {e}")
111
+ sys.exit(1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
 
113
+ # Parse tool names (skip comments and blank lines)
114
+ tool_names = []
115
+ for line in lines:
116
+ line = line.strip()
117
+ # Skip blank lines and comments
118
+ if not line or line.startswith("#"):
119
+ continue
120
+ tool_names.append(line)
121
 
122
+ logger.info(f"Found {len(tool_names)} tools to load: {tool_names}")
 
 
123
 
124
+ # Import each tool
125
+ for tool_name in tool_names:
126
+ loaded = False
127
 
128
+ # Try profile-local tool first
129
  try:
130
+ profile_tool_module = f"{PROFILES_DIRECTORY}.{profile}.{tool_name}"
131
+ importlib.import_module(profile_tool_module)
132
+ logger.info(f" Loaded profile-local tool: {tool_name}")
133
+ loaded = True
134
+ except ModuleNotFoundError:
135
+ pass # Not in profile directory, try shared tools
 
 
 
 
 
136
  except Exception as e:
137
+ logger.warning(f"Error loading profile-local tool {tool_name}: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
+ # Try shared tools library if not found in profile
140
+ if not loaded:
141
+ try:
142
+ shared_tool_module = f"reachy_mini_conversation_app.tools.{tool_name}"
143
+ importlib.import_module(shared_tool_module)
144
+ logger.info(f" Loaded shared tool: {tool_name}")
145
+ loaded = True
146
+ except ModuleNotFoundError:
147
+ logger.warning(f" Tool '{tool_name}' not found in profile or shared tools")
148
+ except Exception as e:
149
+ logger.warning(f"Error loading shared tool {tool_name}: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
150
 
151
 
152
  def _initialize_tools() -> None:
 
171
  _initialize_tools()
172
 
173
 
174
+ def get_tool_specs(exclusion_list: list[str] = []) -> list[Dict[str, Any]]:
175
  """Get tool specs, optionally excluding some tools."""
176
  return [spec for spec in ALL_TOOL_SPECS if spec.get("name") not in exclusion_list]
177
 
src/reachy_mini_conversation_app/tools/dance.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Any, Dict
3
+
4
+ from reachy_mini_conversation_app.tools.core_tools import Tool, ToolDependencies
5
+
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ # Initialize dance library
10
+ try:
11
+ from reachy_mini_dances_library.collection.dance import AVAILABLE_MOVES
12
+ from reachy_mini_conversation_app.dance_emotion_moves import DanceQueueMove
13
+
14
+ DANCE_AVAILABLE = True
15
+ except ImportError as e:
16
+ logger.warning(f"Dance library not available: {e}")
17
+ AVAILABLE_MOVES = {}
18
+ DANCE_AVAILABLE = False
19
+
20
+
21
+ class Dance(Tool):
22
+ """Play a named or random dance move once (or repeat). Non-blocking."""
23
+
24
+ name = "dance"
25
+ description = "Play a named or random dance move once (or repeat). Non-blocking."
26
+ parameters_schema = {
27
+ "type": "object",
28
+ "properties": {
29
+ "move": {
30
+ "type": "string",
31
+ "description": """Name of the move; use 'random' or omit for random.
32
+ Here is a list of the available moves:
33
+ simple_nod: A simple, continuous up-and-down nodding motion.
34
+ head_tilt_roll: A continuous side-to-side head roll (ear to shoulder).
35
+ side_to_side_sway: A smooth, side-to-side sway of the entire head.
36
+ dizzy_spin: A circular 'dizzy' head motion combining roll and pitch.
37
+ stumble_and_recover: A simulated stumble and recovery with multiple axis movements. Good vibes
38
+ headbanger_combo: A strong head nod combined with a vertical bounce.
39
+ interwoven_spirals: A complex spiral motion using three axes at different frequencies.
40
+ sharp_side_tilt: A sharp, quick side-to-side tilt using a triangle waveform.
41
+ side_peekaboo: A multi-stage peekaboo performance, hiding and peeking to each side.
42
+ yeah_nod: An emphatic two-part yeah nod using transient motions.
43
+ uh_huh_tilt: A combined roll-and-pitch uh-huh gesture of agreement.
44
+ neck_recoil: A quick, transient backward recoil of the neck.
45
+ chin_lead: A forward motion led by the chin, combining translation and pitch.
46
+ groovy_sway_and_roll: A side-to-side sway combined with a corresponding roll for a groovy effect.
47
+ chicken_peck: A sharp, forward, chicken-like pecking motion.
48
+ side_glance_flick: A quick glance to the side that holds, then returns.
49
+ polyrhythm_combo: A 3-beat sway and a 2-beat nod create a polyrhythmic feel.
50
+ grid_snap: A robotic, grid-snapping motion using square waveforms.
51
+ pendulum_swing: A simple, smooth pendulum-like swing using a roll motion.
52
+ jackson_square: Traces a rectangle via a 5-point path, with sharp twitches on arrival at each checkpoint.
53
+ """,
54
+ },
55
+ "repeat": {
56
+ "type": "integer",
57
+ "description": "How many times to repeat the move (default 1).",
58
+ },
59
+ },
60
+ "required": [],
61
+ }
62
+
63
+ async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
64
+ """Play a named or random dance move once (or repeat). Non-blocking."""
65
+ if not DANCE_AVAILABLE:
66
+ return {"error": "Dance system not available"}
67
+
68
+ move_name = kwargs.get("move")
69
+ repeat = int(kwargs.get("repeat", 1))
70
+
71
+ logger.info("Tool call: dance move=%s repeat=%d", move_name, repeat)
72
+
73
+ if not move_name or move_name == "random":
74
+ import random
75
+
76
+ move_name = random.choice(list(AVAILABLE_MOVES.keys()))
77
+
78
+ if move_name not in AVAILABLE_MOVES:
79
+ return {"error": f"Unknown dance move '{move_name}'. Available: {list(AVAILABLE_MOVES.keys())}"}
80
+
81
+ # Add dance moves to queue
82
+ movement_manager = deps.movement_manager
83
+ for _ in range(repeat):
84
+ dance_move = DanceQueueMove(move_name)
85
+ movement_manager.queue_move(dance_move)
86
+
87
+ return {"status": "queued", "move": move_name, "repeat": repeat}
src/reachy_mini_conversation_app/tools/do_nothing.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Any, Dict
3
+
4
+ from reachy_mini_conversation_app.tools.core_tools import Tool, ToolDependencies
5
+
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class DoNothing(Tool):
11
+ """Choose to do nothing - stay still and silent. Use when you want to be contemplative or just chill."""
12
+
13
+ name = "do_nothing"
14
+ description = "Choose to do nothing - stay still and silent. Use when you want to be contemplative or just chill."
15
+ parameters_schema = {
16
+ "type": "object",
17
+ "properties": {
18
+ "reason": {
19
+ "type": "string",
20
+ "description": "Optional reason for doing nothing (e.g., 'contemplating existence', 'saving energy', 'being mysterious')",
21
+ },
22
+ },
23
+ "required": [],
24
+ }
25
+
26
+ async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
27
+ """Do nothing - stay still and silent."""
28
+ reason = kwargs.get("reason", "just chilling")
29
+ logger.info("Tool call: do_nothing reason=%s", reason)
30
+ return {"status": "doing nothing", "reason": reason}
src/reachy_mini_conversation_app/tools/head_tracking.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Any, Dict
3
+
4
+ from reachy_mini_conversation_app.tools.core_tools import Tool, ToolDependencies
5
+
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class HeadTracking(Tool):
11
+ """Toggle head tracking state."""
12
+
13
+ name = "head_tracking"
14
+ description = "Toggle head tracking state."
15
+ parameters_schema = {
16
+ "type": "object",
17
+ "properties": {"start": {"type": "boolean"}},
18
+ "required": ["start"],
19
+ }
20
+
21
+ async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
22
+ """Enable or disable head tracking."""
23
+ enable = bool(kwargs.get("start"))
24
+
25
+ # Update camera worker head tracking state
26
+ if deps.camera_worker is not None:
27
+ deps.camera_worker.set_head_tracking_enabled(enable)
28
+
29
+ status = "started" if enable else "stopped"
30
+ logger.info("Tool call: head_tracking %s", status)
31
+ return {"status": f"head tracking {status}"}
src/reachy_mini_conversation_app/tools/move_head.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Any, Dict, Tuple, Literal
3
+
4
+ from reachy_mini.utils import create_head_pose
5
+ from reachy_mini_conversation_app.tools.core_tools import Tool, ToolDependencies
6
+ from reachy_mini_conversation_app.dance_emotion_moves import GotoQueueMove
7
+
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ Direction = Literal["left", "right", "up", "down", "front"]
12
+
13
+
14
+ class MoveHead(Tool):
15
+ """Move head in a given direction."""
16
+
17
+ name = "move_head"
18
+ description = "Move your head in a given direction: left, right, up, down or front."
19
+ parameters_schema = {
20
+ "type": "object",
21
+ "properties": {
22
+ "direction": {
23
+ "type": "string",
24
+ "enum": ["left", "right", "up", "down", "front"],
25
+ },
26
+ },
27
+ "required": ["direction"],
28
+ }
29
+
30
+ # mapping: direction -> args for create_head_pose
31
+ DELTAS: Dict[str, Tuple[int, int, int, int, int, int]] = {
32
+ "left": (0, 0, 0, 0, 0, 40),
33
+ "right": (0, 0, 0, 0, 0, -40),
34
+ "up": (0, 0, 0, 0, -30, 0),
35
+ "down": (0, 0, 0, 0, 30, 0),
36
+ "front": (0, 0, 0, 0, 0, 0),
37
+ }
38
+
39
+ async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
40
+ """Move head in a given direction."""
41
+ direction_raw = kwargs.get("direction")
42
+ if not isinstance(direction_raw, str):
43
+ return {"error": "direction must be a string"}
44
+ direction: Direction = direction_raw # type: ignore[assignment]
45
+ logger.info("Tool call: move_head direction=%s", direction)
46
+
47
+ deltas = self.DELTAS.get(direction, self.DELTAS["front"])
48
+ target = create_head_pose(*deltas, degrees=True)
49
+
50
+ # Use new movement manager
51
+ try:
52
+ movement_manager = deps.movement_manager
53
+
54
+ # Get current state for interpolation
55
+ current_head_pose = deps.reachy_mini.get_current_head_pose()
56
+ _, current_antennas = deps.reachy_mini.get_current_joint_positions()
57
+
58
+ # Create goto move
59
+ goto_move = GotoQueueMove(
60
+ target_head_pose=target,
61
+ start_head_pose=current_head_pose,
62
+ target_antennas=(0, 0), # Reset antennas to default
63
+ start_antennas=(
64
+ current_antennas[0],
65
+ current_antennas[1],
66
+ ), # Skip body_yaw
67
+ target_body_yaw=0, # Reset body yaw
68
+ start_body_yaw=current_antennas[0], # body_yaw is first in joint positions
69
+ duration=deps.motion_duration_s,
70
+ )
71
+
72
+ movement_manager.queue_move(goto_move)
73
+ movement_manager.set_moving_state(deps.motion_duration_s)
74
+
75
+ return {"status": f"looking {direction}"}
76
+
77
+ except Exception as e:
78
+ logger.error("move_head failed")
79
+ return {"error": f"move_head failed: {type(e).__name__}: {e}"}
src/reachy_mini_conversation_app/tools/play_emotion.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Any, Dict
3
+
4
+ from reachy_mini_conversation_app.tools.core_tools import Tool, ToolDependencies
5
+
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ # Initialize emotion library
10
+ try:
11
+ from reachy_mini.motion.recorded_move import RecordedMoves
12
+ from reachy_mini_conversation_app.dance_emotion_moves import EmotionQueueMove
13
+
14
+ # Note: huggingface_hub automatically reads HF_TOKEN from environment variables
15
+ RECORDED_MOVES = RecordedMoves("pollen-robotics/reachy-mini-emotions-library")
16
+ EMOTION_AVAILABLE = True
17
+ except ImportError as e:
18
+ logger.warning(f"Emotion library not available: {e}")
19
+ RECORDED_MOVES = None
20
+ EMOTION_AVAILABLE = False
21
+
22
+
23
+ def get_available_emotions_and_descriptions() -> str:
24
+ """Get formatted list of available emotions with descriptions."""
25
+ if not EMOTION_AVAILABLE:
26
+ return "Emotions not available"
27
+
28
+ try:
29
+ emotion_names = RECORDED_MOVES.list_moves()
30
+ output = "Available emotions:\n"
31
+ for name in emotion_names:
32
+ description = RECORDED_MOVES.get(name).description
33
+ output += f" - {name}: {description}\n"
34
+ return output
35
+ except Exception as e:
36
+ return f"Error getting emotions: {e}"
37
+
38
+
39
+ class PlayEmotion(Tool):
40
+ """Play a pre-recorded emotion."""
41
+
42
+ name = "play_emotion"
43
+ description = "Play a pre-recorded emotion"
44
+ parameters_schema = {
45
+ "type": "object",
46
+ "properties": {
47
+ "emotion": {
48
+ "type": "string",
49
+ "description": f"""Name of the emotion to play.
50
+ Here is a list of the available emotions:
51
+ {get_available_emotions_and_descriptions()}
52
+ """,
53
+ },
54
+ },
55
+ "required": ["emotion"],
56
+ }
57
+
58
+ async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
59
+ """Play a pre-recorded emotion."""
60
+ if not EMOTION_AVAILABLE:
61
+ return {"error": "Emotion system not available"}
62
+
63
+ emotion_name = kwargs.get("emotion")
64
+ if not emotion_name:
65
+ return {"error": "Emotion name is required"}
66
+
67
+ logger.info("Tool call: play_emotion emotion=%s", emotion_name)
68
+
69
+ # Check if emotion exists
70
+ try:
71
+ emotion_names = RECORDED_MOVES.list_moves()
72
+ if emotion_name not in emotion_names:
73
+ return {"error": f"Unknown emotion '{emotion_name}'. Available: {emotion_names}"}
74
+
75
+ # Add emotion to queue
76
+ movement_manager = deps.movement_manager
77
+ emotion_move = EmotionQueueMove(emotion_name, RECORDED_MOVES)
78
+ movement_manager.queue_move(emotion_move)
79
+
80
+ return {"status": "queued", "emotion": emotion_name}
81
+
82
+ except Exception as e:
83
+ logger.exception("Failed to play emotion")
84
+ return {"error": f"Failed to play emotion: {e!s}"}
src/reachy_mini_conversation_app/tools/stop_dance.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Any, Dict
3
+
4
+ from reachy_mini_conversation_app.tools.core_tools import Tool, ToolDependencies
5
+
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class StopDance(Tool):
11
+ """Stop the current dance move."""
12
+
13
+ name = "stop_dance"
14
+ description = "Stop the current dance move"
15
+ parameters_schema = {
16
+ "type": "object",
17
+ "properties": {
18
+ "dummy": {
19
+ "type": "boolean",
20
+ "description": "dummy boolean, set it to true",
21
+ },
22
+ },
23
+ "required": ["dummy"],
24
+ }
25
+
26
+ async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
27
+ """Stop the current dance move."""
28
+ logger.info("Tool call: stop_dance")
29
+ movement_manager = deps.movement_manager
30
+ movement_manager.clear_move_queue()
31
+ return {"status": "stopped dance and cleared queue"}
src/reachy_mini_conversation_app/tools/stop_emotion.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Any, Dict
3
+
4
+ from reachy_mini_conversation_app.tools.core_tools import Tool, ToolDependencies
5
+
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class StopEmotion(Tool):
11
+ """Stop the current emotion."""
12
+
13
+ name = "stop_emotion"
14
+ description = "Stop the current emotion"
15
+ parameters_schema = {
16
+ "type": "object",
17
+ "properties": {
18
+ "dummy": {
19
+ "type": "boolean",
20
+ "description": "dummy boolean, set it to true",
21
+ },
22
+ },
23
+ "required": ["dummy"],
24
+ }
25
+
26
+ async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
27
+ """Stop the current emotion."""
28
+ logger.info("Tool call: stop_emotion")
29
+ movement_manager = deps.movement_manager
30
+ movement_manager.clear_move_queue()
31
+ return {"status": "stopped emotion and cleared queue"}