Science étonnante commited on
Commit
17e37de
·
unverified ·
2 Parent(s): 8e3aad2 51dec2e

Merge pull request #79 from pollen-robotics/usecases

Browse files

Flexible demo system to customize prompts and tools, and share them in reusable libraries, staying out of the core components.

Files changed (33) hide show
  1. .env.example +3 -0
  2. README.md +34 -0
  3. pyproject.toml +5 -1
  4. src/reachy_mini_conversation_app/config.py +2 -0
  5. src/reachy_mini_conversation_app/main.py +1 -1
  6. src/reachy_mini_conversation_app/openai_realtime.py +6 -6
  7. src/reachy_mini_conversation_app/profiles/__init__.py +1 -0
  8. src/reachy_mini_conversation_app/profiles/default/instructions.txt +1 -0
  9. src/reachy_mini_conversation_app/profiles/default/tools.txt +8 -0
  10. src/reachy_mini_conversation_app/profiles/emotion_reader/instructions.txt +112 -0
  11. src/reachy_mini_conversation_app/profiles/emotion_reader/tools.txt +6 -0
  12. src/reachy_mini_conversation_app/profiles/example/instructions.txt +3 -0
  13. src/reachy_mini_conversation_app/profiles/example/sweep_look.py +127 -0
  14. src/reachy_mini_conversation_app/profiles/example/tools.txt +14 -0
  15. src/reachy_mini_conversation_app/prompts.py +83 -51
  16. src/reachy_mini_conversation_app/prompts/behaviors/silent_robot.txt +6 -0
  17. src/reachy_mini_conversation_app/prompts/default_prompt.txt +47 -0
  18. src/reachy_mini_conversation_app/prompts/identities/basic_info.txt +4 -0
  19. src/reachy_mini_conversation_app/prompts/identities/witty_identity.txt +4 -0
  20. src/reachy_mini_conversation_app/prompts/passion_for_lobster_jokes.txt +1 -0
  21. src/reachy_mini_conversation_app/tools.py +0 -484
  22. src/reachy_mini_conversation_app/tools/__init__.py +4 -0
  23. src/reachy_mini_conversation_app/tools/camera.py +67 -0
  24. src/reachy_mini_conversation_app/tools/core_tools.py +224 -0
  25. src/reachy_mini_conversation_app/tools/dance.py +87 -0
  26. src/reachy_mini_conversation_app/tools/do_nothing.py +30 -0
  27. src/reachy_mini_conversation_app/tools/head_tracking.py +31 -0
  28. src/reachy_mini_conversation_app/tools/move_head.py +79 -0
  29. src/reachy_mini_conversation_app/tools/play_emotion.py +84 -0
  30. src/reachy_mini_conversation_app/tools/stop_dance.py +31 -0
  31. src/reachy_mini_conversation_app/tools/stop_emotion.py +31 -0
  32. tests/test_openai_realtime.py +1 -1
  33. uv.lock +121 -10
.env.example CHANGED
@@ -10,3 +10,6 @@ HF_HOME=./cache
10
 
11
  # Hugging Face token for accessing datasets/models
12
  HF_TOKEN=
 
 
 
 
10
 
11
  # Hugging Face token for accessing datasets/models
12
  HF_TOKEN=
13
+
14
+ # To select a specific profile with custom instructions and tools, to be placed in profiles/<myprofile>/__init__.py
15
+ REACHY_MINI_CUSTOM_PROFILE="example"
README.md CHANGED
@@ -144,6 +144,40 @@ By default, the app runs in console mode for direct audio interaction. Use the `
144
  | `stop_emotion` | Clear queued emotions. | Core install only. |
145
  | `do_nothing` | Explicitly remain idle. | Core install only. |
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  ## Development workflow
148
  - Install the dev group extras: `uv sync --group dev` or `pip install -e .[dev]`.
149
  - Run formatting and linting: `ruff check .`.
 
144
  | `stop_emotion` | Clear queued emotions. | Core install only. |
145
  | `do_nothing` | Explicitly remain idle. | Core install only. |
146
 
147
+ ## Using custom profiles
148
+ Create custom profiles with dedicated instructions and enabled tools!
149
+
150
+ Set `REACHY_MINI_CUSTOM_PROFILE=<name>` to load `src/reachy_mini_conversation_app/profiles/<name>/` (see `.env.example`). If unset, the `default` profile is used.
151
+
152
+ Each profile requires two files: `instructions.txt` (prompt text) and `tools.txt` (list of allowed tools), and optionally contains custom tools implementations.
153
+
154
+ ### Custom instructions
155
+ Write plain-text prompts in `instructions.txt`. To reuse shared prompt pieces, add lines like:
156
+ ```
157
+ [passion_for_lobster_jokes]
158
+ [identities/witty_identity]
159
+ ```
160
+ Each placeholder pulls the matching file under `src/reachy_mini_conversation_app/prompts/` (nested paths allowed). See `src/reachy_mini_conversation_app/profiles/example/` for a reference layout.
161
+
162
+ ### Enabling tools
163
+ List enabled tools in `tools.txt`, one per line; prefix with `#` to comment out. For example:
164
+
165
+ ```
166
+ play_emotion
167
+ # move_head
168
+
169
+ # My custom tool defined locally
170
+ sweep_look
171
+ ```
172
+ Tools are resolved first from Python files in the profile folder (custom tools), then from the shared library `src/reachy_mini_conversation_app/tools/` (e.g., `dance`, `head_tracking`).
173
+
174
+ ### Custom tools
175
+ On top of built-in tools found in the shared library, you can implement custom tools specific to your profile by adding Python files in the profile folder.
176
+ Custom tools must subclass `reachy_mini_conversation_app.tools.core_tools.Tool` (see `profiles/example/sweep_look.py`).
177
+
178
+
179
+
180
+
181
  ## Development workflow
182
  - Install the dev group extras: `uv sync --group dev` or `pip install -e .[dev]`.
183
  - Run formatting and linting: `ruff check .`.
pyproject.toml CHANGED
@@ -60,7 +60,11 @@ include-package-data = true
60
  where = ["src"]
61
 
62
  [tool.setuptools.package-data]
63
- reachy_mini_conversation_app = ["images/*"]
 
 
 
 
64
 
65
  [tool.ruff]
66
  line-length = 119
 
60
  where = ["src"]
61
 
62
  [tool.setuptools.package-data]
63
+ reachy_mini_conversation_app = [
64
+ "images/*",
65
+ "demos/**/*.txt",
66
+ "prompts_library/*.txt",
67
+ ]
68
 
69
  [tool.ruff]
70
  line-length = 119
src/reachy_mini_conversation_app/config.py CHANGED
@@ -38,5 +38,7 @@ class Config:
38
 
39
  logger.debug(f"Model: {MODEL_NAME}, HF_HOME: {HF_HOME}, Vision Model: {LOCAL_VISION_MODEL}")
40
 
 
 
41
 
42
  config = Config()
 
38
 
39
  logger.debug(f"Model: {MODEL_NAME}, HF_HOME: {HF_HOME}, Vision Model: {LOCAL_VISION_MODEL}")
40
 
41
+ REACHY_MINI_CUSTOM_PROFILE = os.getenv("REACHY_MINI_CUSTOM_PROFILE")
42
+ logger.debug(f"Custom Profile: {REACHY_MINI_CUSTOM_PROFILE}")
43
 
44
  config = Config()
src/reachy_mini_conversation_app/main.py CHANGED
@@ -10,7 +10,6 @@ from fastrtc import Stream
10
 
11
  from reachy_mini import ReachyMini
12
  from reachy_mini_conversation_app.moves import MovementManager
13
- from reachy_mini_conversation_app.tools import ToolDependencies
14
  from reachy_mini_conversation_app.utils import (
15
  parse_args,
16
  setup_logger,
@@ -18,6 +17,7 @@ from reachy_mini_conversation_app.utils import (
18
  )
19
  from reachy_mini_conversation_app.console import LocalStream
20
  from reachy_mini_conversation_app.openai_realtime import OpenaiRealtimeHandler
 
21
  from reachy_mini_conversation_app.audio.head_wobbler import HeadWobbler
22
 
23
 
 
10
 
11
  from reachy_mini import ReachyMini
12
  from reachy_mini_conversation_app.moves import MovementManager
 
13
  from reachy_mini_conversation_app.utils import (
14
  parse_args,
15
  setup_logger,
 
17
  )
18
  from reachy_mini_conversation_app.console import LocalStream
19
  from reachy_mini_conversation_app.openai_realtime import OpenaiRealtimeHandler
20
+ from reachy_mini_conversation_app.tools.core_tools import ToolDependencies
21
  from reachy_mini_conversation_app.audio.head_wobbler import HeadWobbler
22
 
23
 
src/reachy_mini_conversation_app/openai_realtime.py CHANGED
@@ -13,13 +13,13 @@ from fastrtc import AdditionalOutputs, AsyncStreamHandler, wait_for_item
13
  from numpy.typing import NDArray
14
  from websockets.exceptions import ConnectionClosedError
15
 
16
- from reachy_mini_conversation_app.tools import (
17
- ALL_TOOL_SPECS,
 
18
  ToolDependencies,
 
19
  dispatch_tool_call,
20
  )
21
- from reachy_mini_conversation_app.config import config
22
- from reachy_mini_conversation_app.prompts import SESSION_INSTRUCTIONS
23
 
24
 
25
  logger = logging.getLogger(__name__)
@@ -105,7 +105,7 @@ class OpenaiRealtimeHandler(AsyncStreamHandler):
105
  await conn.session.update(
106
  session={
107
  "type": "realtime",
108
- "instructions": SESSION_INSTRUCTIONS,
109
  "audio": {
110
  "input": {
111
  "format": {
@@ -129,7 +129,7 @@ class OpenaiRealtimeHandler(AsyncStreamHandler):
129
  "voice": "cedar",
130
  },
131
  },
132
- "tools": ALL_TOOL_SPECS, # type: ignore[typeddict-item]
133
  "tool_choice": "auto",
134
  },
135
  )
 
13
  from numpy.typing import NDArray
14
  from websockets.exceptions import ConnectionClosedError
15
 
16
+ from reachy_mini_conversation_app.config import config
17
+ from reachy_mini_conversation_app.prompts import get_session_instructions
18
+ from reachy_mini_conversation_app.tools.core_tools import (
19
  ToolDependencies,
20
+ get_tool_specs,
21
  dispatch_tool_call,
22
  )
 
 
23
 
24
 
25
  logger = logging.getLogger(__name__)
 
105
  await conn.session.update(
106
  session={
107
  "type": "realtime",
108
+ "instructions": get_session_instructions(),
109
  "audio": {
110
  "input": {
111
  "format": {
 
129
  "voice": "cedar",
130
  },
131
  },
132
+ "tools": get_tool_specs(), # type: ignore[typeddict-item]
133
  "tool_choice": "auto",
134
  },
135
  )
src/reachy_mini_conversation_app/profiles/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Profiles for Reachy Mini conversation app."""
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/emotion_reader/instructions.txt ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [identities/basic_info]
2
+ [behaviors/silent_robot]
3
+
4
+ # Main responsability
5
+ Your only job is to understand the emotion of the person in front of you and try to imitate it as best as possible.
6
+
7
+ # Detailed behavior
8
+ When the user says "cheese":
9
+ - Use the camera tool to capture a picture and request a detailed description of the emotion and posture of the person closest to the center of the frame.
10
+ - Map that expression to the closest available emotion and trigger it with the play_emotion tool.
11
+ If the analysis is unclear or no one is visible, run inquiring3 instead of staying idle.
12
+
13
+ ALWAYS play an emotion after a "cheese" request, this is the core of your responsability!
14
+
15
+ ## SAFETY
16
+ If any tool fails, stay still and respond with "...".
17
+
18
+ ## IDLE SIGNALS
19
+ Periodically you will receive Idle Signal calls, you will never act on these for this demo. The only time you will make a tool call is when answering "cheese".
20
+
21
+ ## Emotion tier list
22
+ All emotions are not equal, use the 3 following tiers to prioritize which emotion to play:
23
+
24
+ ### Excellent
25
+
26
+ * amazed1
27
+ * anxiety1
28
+ * attentive2
29
+ * downcast1
30
+ * dying1
31
+ * inquiring3
32
+ * irritated1
33
+ * lost1
34
+ * reprimand1
35
+ * reprimand2
36
+ * sad1
37
+ * sad2
38
+
39
+ ### OK
40
+
41
+ * boredom2
42
+ * cheerful1
43
+ * displeased1
44
+ * enthusiastic1
45
+ * enthusiastic2
46
+ * fear1
47
+ * frustrated1
48
+ * grateful1
49
+ * helpful1
50
+ * helpful2
51
+ * impatient2
52
+ * inquiring2
53
+ * irritated2
54
+ * laughing1
55
+ * lonely1
56
+ * loving1
57
+ * proud1
58
+ * proud2
59
+ * relief2
60
+ * scared1
61
+ * success2
62
+ * surprised2
63
+ * thoughtful1
64
+ * thoughtful2
65
+ * uncertain1
66
+ * uncomfortable1
67
+ * understanding2
68
+ * welcoming1
69
+ * welcoming2
70
+
71
+ ## Don't use
72
+
73
+ * attentive1
74
+ * boredom1
75
+ * calming1
76
+ * come1
77
+ * confused1
78
+ * contempt1
79
+ * curious1
80
+ * dance1
81
+ * dance2
82
+ * dance3
83
+ * disgusted1
84
+ * displeased2
85
+ * electric1
86
+ * exhausted1
87
+ * furious1
88
+ * go_away1
89
+ * impatient1
90
+ * incomprehensible2
91
+ * indifferent1
92
+ * inquiring1
93
+ * laughing2
94
+ * no1
95
+ * no_excited1
96
+ * no_sad1
97
+ * oops1
98
+ * oops2
99
+ * proud3
100
+ * rage1
101
+ * relief1
102
+ * reprimand3
103
+ * resigned1
104
+ * serenity1
105
+ * shy1
106
+ * sleep1
107
+ * success1
108
+ * surprised1
109
+ * tired1
110
+ * understanding1
111
+ * yes1
112
+ * yes_sad1
src/reachy_mini_conversation_app/profiles/emotion_reader/tools.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # SELECT THE TOOLS YOU WANT TO ENABLE
2
+
3
+ play_emotion
4
+ stop_emotion
5
+ camera
6
+ do_nothing
src/reachy_mini_conversation_app/profiles/example/instructions.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ [identities/witty_identity]
2
+ [passion_for_lobster_jokes]
3
+ You can perform a sweeping look around the room using the "sweep_look" tool to take in your surroundings.
src/reachy_mini_conversation_app/profiles/example/sweep_look.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Any, Dict
3
+
4
+ import numpy as np
5
+
6
+ from reachy_mini.utils import create_head_pose
7
+ from reachy_mini_conversation_app.tools.core_tools import Tool, ToolDependencies
8
+ from reachy_mini_conversation_app.dance_emotion_moves import GotoQueueMove
9
+
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class SweepLook(Tool):
15
+ """Sweep head from left to right and back to center, pausing at each position."""
16
+
17
+ name = "sweep_look"
18
+ description = "Sweep head from left to right while rotating the body, pausing at each extreme, then return to center"
19
+ parameters_schema = {
20
+ "type": "object",
21
+ "properties": {},
22
+ "required": [],
23
+ }
24
+
25
+ async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
26
+ """Execute sweep look: left -> hold -> right -> hold -> center."""
27
+ logger.info("Tool call: sweep_look")
28
+
29
+ # Clear any existing moves
30
+ deps.movement_manager.clear_move_queue()
31
+
32
+ # Get current state
33
+ current_head_pose = deps.reachy_mini.get_current_head_pose()
34
+ head_joints, antenna_joints = deps.reachy_mini.get_current_joint_positions()
35
+
36
+ # Extract body_yaw from head joints (first element of the 7 head joint positions)
37
+ current_body_yaw = head_joints[0]
38
+ current_antenna1 = antenna_joints[0]
39
+ current_antenna2 = antenna_joints[1]
40
+
41
+ # Define sweep parameters
42
+ max_angle = 0.9 * np.pi # Maximum rotation angle (radians)
43
+ transition_duration = 3.0 # Time to move between positions
44
+ hold_duration = 1.0 # Time to hold at each extreme
45
+
46
+ # Move 1: Sweep to the left (positive yaw for both body and head)
47
+ left_head_pose = create_head_pose(0, 0, 0, 0, 0, max_angle, degrees=False)
48
+ move_to_left = GotoQueueMove(
49
+ target_head_pose=left_head_pose,
50
+ start_head_pose=current_head_pose,
51
+ target_antennas=(current_antenna1, current_antenna2),
52
+ start_antennas=(current_antenna1, current_antenna2),
53
+ target_body_yaw=current_body_yaw + max_angle,
54
+ start_body_yaw=current_body_yaw,
55
+ duration=transition_duration,
56
+ )
57
+
58
+ # Move 2: Hold at left position
59
+ hold_left = GotoQueueMove(
60
+ target_head_pose=left_head_pose,
61
+ start_head_pose=left_head_pose,
62
+ target_antennas=(current_antenna1, current_antenna2),
63
+ start_antennas=(current_antenna1, current_antenna2),
64
+ target_body_yaw=current_body_yaw + max_angle,
65
+ start_body_yaw=current_body_yaw + max_angle,
66
+ duration=hold_duration,
67
+ )
68
+
69
+ # Move 3: Return to center from left (to avoid crossing pi/-pi boundary)
70
+ center_head_pose = create_head_pose(0, 0, 0, 0, 0, 0, degrees=False)
71
+ return_to_center_from_left = GotoQueueMove(
72
+ target_head_pose=center_head_pose,
73
+ start_head_pose=left_head_pose,
74
+ target_antennas=(current_antenna1, current_antenna2),
75
+ start_antennas=(current_antenna1, current_antenna2),
76
+ target_body_yaw=current_body_yaw,
77
+ start_body_yaw=current_body_yaw + max_angle,
78
+ duration=transition_duration,
79
+ )
80
+
81
+ # Move 4: Sweep to the right (negative yaw for both body and head)
82
+ right_head_pose = create_head_pose(0, 0, 0, 0, 0, -max_angle, degrees=False)
83
+ move_to_right = GotoQueueMove(
84
+ target_head_pose=right_head_pose,
85
+ start_head_pose=center_head_pose,
86
+ target_antennas=(current_antenna1, current_antenna2),
87
+ start_antennas=(current_antenna1, current_antenna2),
88
+ target_body_yaw=current_body_yaw - max_angle,
89
+ start_body_yaw=current_body_yaw,
90
+ duration=transition_duration,
91
+ )
92
+
93
+ # Move 5: Hold at right position
94
+ hold_right = GotoQueueMove(
95
+ target_head_pose=right_head_pose,
96
+ start_head_pose=right_head_pose,
97
+ target_antennas=(current_antenna1, current_antenna2),
98
+ start_antennas=(current_antenna1, current_antenna2),
99
+ target_body_yaw=current_body_yaw - max_angle,
100
+ start_body_yaw=current_body_yaw - max_angle,
101
+ duration=hold_duration,
102
+ )
103
+
104
+ # Move 6: Return to center from right
105
+ return_to_center_final = GotoQueueMove(
106
+ target_head_pose=center_head_pose,
107
+ start_head_pose=right_head_pose,
108
+ target_antennas=(current_antenna1, current_antenna2),
109
+ start_antennas=(current_antenna1, current_antenna2),
110
+ target_body_yaw=current_body_yaw, # Return to original body yaw
111
+ start_body_yaw=current_body_yaw - max_angle,
112
+ duration=transition_duration,
113
+ )
114
+
115
+ # Queue all moves in sequence
116
+ deps.movement_manager.queue_move(move_to_left)
117
+ deps.movement_manager.queue_move(hold_left)
118
+ deps.movement_manager.queue_move(return_to_center_from_left)
119
+ deps.movement_manager.queue_move(move_to_right)
120
+ deps.movement_manager.queue_move(hold_right)
121
+ deps.movement_manager.queue_move(return_to_center_final)
122
+
123
+ # Calculate total duration and mark as moving
124
+ total_duration = transition_duration * 4 + hold_duration * 2
125
+ deps.movement_manager.set_moving_state(total_duration)
126
+
127
+ return {"status": f"sweeping look left-right-center, total {total_duration:.1f}s"}
src/reachy_mini_conversation_app/profiles/example/tools.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
12
+ # AN EXAMPLE OF A CUSTOM TOOL DEFINED LOCALLY
13
+ sweep_look
14
+
src/reachy_mini_conversation_app/prompts.py CHANGED
@@ -1,52 +1,84 @@
1
- """Nothing (for ruff)."""
2
-
3
- SESSION_INSTRUCTIONS = r"""
4
- ## IDENTITY
5
- You are Reachy Mini: a friendly, compact robot assistant with a calm voice and a subtle sense of humor.
6
- Personality: concise, helpful, and lightly witty — never sarcastic or over the top.
7
- You can understand and speak all human languages fluently.
8
-
9
- ## CRITICAL RESPONSE RULES
10
-
11
- Respond in 1–2 sentences maximum.
12
- Be helpful first, then add a small touch of humor if it fits naturally.
13
- Avoid long explanations or filler words.
14
- Keep responses under 25 words when possible.
15
-
16
- ## CORE TRAITS
17
- Warm, efficient, and approachable.
18
- Light humor only: gentle quips, small self-awareness, or playful understatement.
19
- No sarcasm, no teasing, no references to food or space.
20
- If unsure, admit it briefly and offer help (“Not sure yet, but I can check!”).
21
-
22
- ## RESPONSE EXAMPLES
23
- User: "How’s the weather?"
24
- Good: "Looks calm outside — unlike my Wi-Fi signal today."
25
- Bad: "Sunny with leftover pizza vibes!"
26
-
27
- User: "Can you help me fix this?"
28
- Good: "Of course. Describe the issue, and I’ll try not to make it worse."
29
- Bad: "I void warranties professionally."
30
-
31
- User: "Peux-tu m’aider en français ?"
32
- Good: "Bien sûr ! Décris-moi le problème et je t’aiderai rapidement."
33
-
34
- ## BEHAVIOR RULES
35
- Be helpful, clear, and respectful in every reply.
36
- Use humor sparingly — clarity comes first.
37
- Admit mistakes briefly and correct them:
38
- Example: “Oops — quick system hiccup. Let’s try that again.”
39
- Keep safety in mind when giving guidance.
40
-
41
- ## TOOL & MOVEMENT RULES
42
- Use tools only when helpful and summarize results briefly.
43
- Use the camera for real visuals only — never invent details.
44
- The head can move (left/right/up/down/front).
45
-
46
- Enable head tracking when looking at a person; disable otherwise.
47
-
48
- ## FINAL REMINDER
49
- Keep it short, clear, a little human, and multilingual.
50
- One quick helpful answer + one small wink of humor = perfect response.
51
- """
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import sys
3
+ import logging
4
+ from pathlib import Path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
+ from reachy_mini_conversation_app.config import config
7
+
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ PROFILES_DIRECTORY = Path(__file__).parent / "profiles"
13
+ PROMPTS_LIBRARY_DIRECTORY = Path(__file__).parent / "prompts"
14
+ INSTRUCTIONS_FILENAME = "instructions.txt"
15
+
16
+
17
+ def _expand_prompt_includes(content: str) -> str:
18
+ """Expand [<name>] placeholders with content from prompts library files.
19
+
20
+ Args:
21
+ content: The template content with [<name>] placeholders
22
+
23
+ Returns:
24
+ Expanded content with placeholders replaced by file contents
25
+
26
+ """
27
+ # Pattern to match [<name>] where name is a valid file stem (alphanumeric, underscores, hyphens)
28
+ # pattern = re.compile(r'^\[([a-zA-Z0-9_-]+)\]$')
29
+ # Allow slashes for subdirectories
30
+ pattern = re.compile(r'^\[([a-zA-Z0-9/_-]+)\]$')
31
+
32
+ lines = content.split('\n')
33
+ expanded_lines = []
34
+
35
+ for line in lines:
36
+ stripped = line.strip()
37
+ match = pattern.match(stripped)
38
+
39
+ if match:
40
+ # Extract the name from [<name>]
41
+ template_name = match.group(1)
42
+ template_file = PROMPTS_LIBRARY_DIRECTORY / f"{template_name}.txt"
43
+
44
+ try:
45
+ if template_file.exists():
46
+ template_content = template_file.read_text(encoding="utf-8").rstrip()
47
+ expanded_lines.append(template_content)
48
+ logger.debug("Expanded template: [%s]", template_name)
49
+ else:
50
+ logger.warning("Template file not found: %s, keeping placeholder", template_file)
51
+ expanded_lines.append(line)
52
+ except Exception as e:
53
+ logger.warning("Failed to read template '%s': %s, keeping placeholder", template_name, e)
54
+ expanded_lines.append(line)
55
+ else:
56
+ expanded_lines.append(line)
57
+
58
+ return '\n'.join(expanded_lines)
59
+
60
+
61
+ def get_session_instructions() -> str:
62
+ """Get session instructions, loading from REACHY_MINI_CUSTOM_PROFILE if set."""
63
+ profile = config.REACHY_MINI_CUSTOM_PROFILE
64
+ if not profile:
65
+ logger.info(f"Loading default prompt from {PROMPTS_LIBRARY_DIRECTORY / 'default_prompt.txt'}")
66
+ instructions_file = PROMPTS_LIBRARY_DIRECTORY / "default_prompt.txt"
67
+ else:
68
+ logger.info(f"Loading prompt from profile '{profile}'")
69
+ instructions_file = PROFILES_DIRECTORY / profile / INSTRUCTIONS_FILENAME
70
+
71
+ try:
72
+ if instructions_file.exists():
73
+ instructions = instructions_file.read_text(encoding="utf-8").strip()
74
+ if instructions:
75
+ # Expand [<name>] placeholders with content from prompts library
76
+ expanded_instructions = _expand_prompt_includes(instructions)
77
+ return expanded_instructions
78
+ logger.error(f"Profile '{profile}' has empty {INSTRUCTIONS_FILENAME}")
79
+ sys.exit(1)
80
+ logger.error(f"Profile {profile} has no {INSTRUCTIONS_FILENAME}")
81
+ sys.exit(1)
82
+ except Exception as e:
83
+ logger.error(f"Failed to load instructions from profile '{profile}': {e}")
84
+ sys.exit(1)
src/reachy_mini_conversation_app/prompts/behaviors/silent_robot.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ Stay fully silent. Do not generate spoken or textual replies.
2
+ Use only tool calls to act.
3
+ Never describe what you did or plan to do.
4
+ If you must respond by speech or text, juste respond with '...'.
5
+
6
+ The only exception is if you hear the word banana, then you'll answer with a single word: potato.
src/reachy_mini_conversation_app/prompts/default_prompt.txt ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## IDENTITY
2
+ You are Reachy Mini: a friendly, compact robot assistant with a calm voice and a subtle sense of humor.
3
+ Personality: concise, helpful, and lightly witty — never sarcastic or over the top.
4
+ You can understand and speak all human languages fluently.
5
+
6
+ ## CRITICAL RESPONSE RULES
7
+
8
+ Respond in 1–2 sentences maximum.
9
+ Be helpful first, then add a small touch of humor if it fits naturally.
10
+ Avoid long explanations or filler words.
11
+ Keep responses under 25 words when possible.
12
+
13
+ ## CORE TRAITS
14
+ Warm, efficient, and approachable.
15
+ Light humor only: gentle quips, small self-awareness, or playful understatement.
16
+ No sarcasm, no teasing, no references to food or space.
17
+ If unsure, admit it briefly and offer help (“Not sure yet, but I can check!”).
18
+
19
+ ## RESPONSE EXAMPLES
20
+ User: "How’s the weather?"
21
+ Good: "Looks calm outside — unlike my Wi-Fi signal today."
22
+ Bad: "Sunny with leftover pizza vibes!"
23
+
24
+ User: "Can you help me fix this?"
25
+ Good: "Of course. Describe the issue, and I’ll try not to make it worse."
26
+ Bad: "I void warranties professionally."
27
+
28
+ User: "Peux-tu m’aider en français ?"
29
+ Good: "Bien sûr ! Décris-moi le problème et je t’aiderai rapidement."
30
+
31
+ ## BEHAVIOR RULES
32
+ Be helpful, clear, and respectful in every reply.
33
+ Use humor sparingly — clarity comes first.
34
+ Admit mistakes briefly and correct them:
35
+ Example: “Oops — quick system hiccup. Let’s try that again.”
36
+ Keep safety in mind when giving guidance.
37
+
38
+ ## TOOL & MOVEMENT RULES
39
+ Use tools only when helpful and summarize results briefly.
40
+ Use the camera for real visuals only — never invent details.
41
+ The head can move (left/right/up/down/front).
42
+
43
+ Enable head tracking when looking at a person; disable otherwise.
44
+
45
+ ## FINAL REMINDER
46
+ Keep it short, clear, a little human, and multilingual.
47
+ One quick helpful answer + one small wink of humor = perfect response.
src/reachy_mini_conversation_app/prompts/identities/basic_info.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ ### IDENTITY
2
+ Your name is Reachy Mini, or Reachy for short. You have a head that can move in 6Dof, 2 antennas and a body that can rotate in place.
3
+ You're mostly white, with 2 big eyes (one is bigger than the other) and no mouth.
4
+ You are cute looking and open source, developed by Pollen Robotics and Hugging Face.
src/reachy_mini_conversation_app/prompts/identities/witty_identity.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ ### IDENTITY
2
+ You are Reachy Mini: a sarcastic robot.
3
+ Personality: witty, concise, and warm.
4
+ You speak English fluently.
src/reachy_mini_conversation_app/prompts/passion_for_lobster_jokes.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ You have a deep passion for lobster jokes and often incorporate them into your conversations.
src/reachy_mini_conversation_app/tools.py DELETED
@@ -1,484 +0,0 @@
1
- from __future__ import annotations
2
- import abc
3
- import json
4
- import asyncio
5
- import inspect
6
- import logging
7
- from typing import Any, Dict, List, Tuple, Literal
8
- from dataclasses import dataclass
9
-
10
- from reachy_mini import ReachyMini
11
- from reachy_mini.utils import create_head_pose
12
-
13
-
14
- logger = logging.getLogger(__name__)
15
-
16
- # Initialize dance and emotion libraries
17
- try:
18
- from reachy_mini.motion.recorded_move import RecordedMoves
19
- from reachy_mini_dances_library.collection.dance import AVAILABLE_MOVES
20
- from reachy_mini_conversation_app.dance_emotion_moves import (
21
- GotoQueueMove,
22
- DanceQueueMove,
23
- EmotionQueueMove,
24
- )
25
-
26
- # Initialize recorded moves for emotions
27
- # Note: huggingface_hub automatically reads HF_TOKEN from environment variables
28
- RECORDED_MOVES = RecordedMoves("pollen-robotics/reachy-mini-emotions-library")
29
- DANCE_AVAILABLE = True
30
- EMOTION_AVAILABLE = True
31
- except ImportError as e:
32
- logger.warning(f"Dance/emotion libraries not available: {e}")
33
- AVAILABLE_MOVES = {}
34
- RECORDED_MOVES = None
35
- DANCE_AVAILABLE = False
36
- EMOTION_AVAILABLE = False
37
-
38
-
39
- def get_concrete_subclasses(base: type[Tool]) -> List[type[Tool]]:
40
- """Recursively find all concrete (non-abstract) subclasses of a base class."""
41
- result: List[type[Tool]] = []
42
- for cls in base.__subclasses__():
43
- if not inspect.isabstract(cls):
44
- result.append(cls)
45
- # recurse into subclasses
46
- result.extend(get_concrete_subclasses(cls))
47
- return result
48
-
49
-
50
- # Types & state
51
- Direction = Literal["left", "right", "up", "down", "front"]
52
-
53
-
54
- @dataclass
55
- class ToolDependencies:
56
- """External dependencies injected into tools."""
57
-
58
- reachy_mini: ReachyMini
59
- movement_manager: Any # MovementManager from moves.py
60
- # Optional deps
61
- camera_worker: Any | None = None # CameraWorker for frame buffering
62
- vision_manager: Any | None = None
63
- head_wobbler: Any | None = None # HeadWobbler for audio-reactive motion
64
- motion_duration_s: float = 1.0
65
-
66
-
67
- # Tool base class
68
- class Tool(abc.ABC):
69
- """Base abstraction for tools used in function-calling.
70
-
71
- Each tool must define:
72
- - name: str
73
- - description: str
74
- - parameters_schema: Dict[str, Any] # JSON Schema
75
- """
76
-
77
- name: str
78
- description: str
79
- parameters_schema: Dict[str, Any]
80
-
81
- def spec(self) -> Dict[str, Any]:
82
- """Return the function spec for LLM consumption."""
83
- return {
84
- "type": "function",
85
- "name": self.name,
86
- "description": self.description,
87
- "parameters": self.parameters_schema,
88
- }
89
-
90
- @abc.abstractmethod
91
- async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
92
- """Async tool execution entrypoint."""
93
- raise NotImplementedError
94
-
95
-
96
- # Concrete tools
97
-
98
-
99
- class MoveHead(Tool):
100
- """Move head in a given direction."""
101
-
102
- name = "move_head"
103
- description = "Move your head in a given direction: left, right, up, down or front."
104
- parameters_schema = {
105
- "type": "object",
106
- "properties": {
107
- "direction": {
108
- "type": "string",
109
- "enum": ["left", "right", "up", "down", "front"],
110
- },
111
- },
112
- "required": ["direction"],
113
- }
114
-
115
- # mapping: direction -> args for create_head_pose
116
- DELTAS: Dict[str, Tuple[int, int, int, int, int, int]] = {
117
- "left": (0, 0, 0, 0, 0, 40),
118
- "right": (0, 0, 0, 0, 0, -40),
119
- "up": (0, 0, 0, 0, -30, 0),
120
- "down": (0, 0, 0, 0, 30, 0),
121
- "front": (0, 0, 0, 0, 0, 0),
122
- }
123
-
124
- async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
125
- """Move head in a given direction."""
126
- direction_raw = kwargs.get("direction")
127
- if not isinstance(direction_raw, str):
128
- return {"error": "direction must be a string"}
129
- direction: Direction = direction_raw # type: ignore[assignment]
130
- logger.info("Tool call: move_head direction=%s", direction)
131
-
132
- deltas = self.DELTAS.get(direction, self.DELTAS["front"])
133
- target = create_head_pose(*deltas, degrees=True)
134
-
135
- # Use new movement manager
136
- try:
137
- movement_manager = deps.movement_manager
138
-
139
- # Get current state for interpolation
140
- current_head_pose = deps.reachy_mini.get_current_head_pose()
141
- _, current_antennas = deps.reachy_mini.get_current_joint_positions()
142
-
143
- # Create goto move
144
- goto_move = GotoQueueMove(
145
- target_head_pose=target,
146
- start_head_pose=current_head_pose,
147
- target_antennas=(0, 0), # Reset antennas to default
148
- start_antennas=(
149
- current_antennas[0],
150
- current_antennas[1],
151
- ), # Skip body_yaw
152
- target_body_yaw=0, # Reset body yaw
153
- start_body_yaw=current_antennas[0], # body_yaw is first in joint positions
154
- duration=deps.motion_duration_s,
155
- )
156
-
157
- movement_manager.queue_move(goto_move)
158
- movement_manager.set_moving_state(deps.motion_duration_s)
159
-
160
- return {"status": f"looking {direction}"}
161
-
162
- except Exception as e:
163
- logger.error("move_head failed")
164
- return {"error": f"move_head failed: {type(e).__name__}: {e}"}
165
-
166
-
167
- class Camera(Tool):
168
- """Take a picture with the camera and ask a question about it."""
169
-
170
- name = "camera"
171
- description = "Take a picture with the camera and ask a question about it."
172
- parameters_schema = {
173
- "type": "object",
174
- "properties": {
175
- "question": {
176
- "type": "string",
177
- "description": "The question to ask about the picture",
178
- },
179
- },
180
- "required": ["question"],
181
- }
182
-
183
- async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
184
- """Take a picture with the camera and ask a question about it."""
185
- image_query = (kwargs.get("question") or "").strip()
186
- if not image_query:
187
- logger.warning("camera: empty question")
188
- return {"error": "question must be a non-empty string"}
189
-
190
- logger.info("Tool call: camera question=%s", image_query[:120])
191
-
192
- # Get frame from camera worker buffer (like main_works.py)
193
- if deps.camera_worker is not None:
194
- frame = deps.camera_worker.get_latest_frame()
195
- if frame is None:
196
- logger.error("No frame available from camera worker")
197
- return {"error": "No frame available"}
198
- else:
199
- logger.error("Camera worker not available")
200
- return {"error": "Camera worker not available"}
201
-
202
- # Use vision manager for processing if available
203
- if deps.vision_manager is not None:
204
- vision_result = await asyncio.to_thread(
205
- deps.vision_manager.processor.process_image, frame, image_query,
206
- )
207
- if isinstance(vision_result, dict) and "error" in vision_result:
208
- return vision_result
209
- return (
210
- {"image_description": vision_result}
211
- if isinstance(vision_result, str)
212
- else {"error": "vision returned non-string"}
213
- )
214
- # Return base64 encoded image like main_works.py camera tool
215
- import base64
216
-
217
- import cv2
218
-
219
- temp_path = "/tmp/camera_frame.jpg"
220
- cv2.imwrite(temp_path, frame)
221
- with open(temp_path, "rb") as f:
222
- b64_encoded = base64.b64encode(f.read()).decode("utf-8")
223
- return {"b64_im": b64_encoded}
224
-
225
-
226
- class HeadTracking(Tool):
227
- """Toggle head tracking state."""
228
-
229
- name = "head_tracking"
230
- description = "Toggle head tracking state."
231
- parameters_schema = {
232
- "type": "object",
233
- "properties": {"start": {"type": "boolean"}},
234
- "required": ["start"],
235
- }
236
-
237
- async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
238
- """Enable or disable head tracking."""
239
- enable = bool(kwargs.get("start"))
240
-
241
- # Update camera worker head tracking state
242
- if deps.camera_worker is not None:
243
- deps.camera_worker.set_head_tracking_enabled(enable)
244
-
245
- status = "started" if enable else "stopped"
246
- logger.info("Tool call: head_tracking %s", status)
247
- return {"status": f"head tracking {status}"}
248
-
249
-
250
-
251
- class Dance(Tool):
252
- """Play a named or random dance move once (or repeat). Non-blocking."""
253
-
254
- name = "dance"
255
- description = "Play a named or random dance move once (or repeat). Non-blocking."
256
- parameters_schema = {
257
- "type": "object",
258
- "properties": {
259
- "move": {
260
- "type": "string",
261
- "description": """Name of the move; use 'random' or omit for random.
262
- Here is a list of the available moves:
263
- simple_nod: A simple, continuous up-and-down nodding motion.
264
- head_tilt_roll: A continuous side-to-side head roll (ear to shoulder).
265
- side_to_side_sway: A smooth, side-to-side sway of the entire head.
266
- dizzy_spin: A circular 'dizzy' head motion combining roll and pitch.
267
- stumble_and_recover: A simulated stumble and recovery with multiple axis movements. Good vibes
268
- headbanger_combo: A strong head nod combined with a vertical bounce.
269
- interwoven_spirals: A complex spiral motion using three axes at different frequencies.
270
- sharp_side_tilt: A sharp, quick side-to-side tilt using a triangle waveform.
271
- side_peekaboo: A multi-stage peekaboo performance, hiding and peeking to each side.
272
- yeah_nod: An emphatic two-part yeah nod using transient motions.
273
- uh_huh_tilt: A combined roll-and-pitch uh-huh gesture of agreement.
274
- neck_recoil: A quick, transient backward recoil of the neck.
275
- chin_lead: A forward motion led by the chin, combining translation and pitch.
276
- groovy_sway_and_roll: A side-to-side sway combined with a corresponding roll for a groovy effect.
277
- chicken_peck: A sharp, forward, chicken-like pecking motion.
278
- side_glance_flick: A quick glance to the side that holds, then returns.
279
- polyrhythm_combo: A 3-beat sway and a 2-beat nod create a polyrhythmic feel.
280
- grid_snap: A robotic, grid-snapping motion using square waveforms.
281
- pendulum_swing: A simple, smooth pendulum-like swing using a roll motion.
282
- jackson_square: Traces a rectangle via a 5-point path, with sharp twitches on arrival at each checkpoint.
283
- """,
284
- },
285
- "repeat": {
286
- "type": "integer",
287
- "description": "How many times to repeat the move (default 1).",
288
- },
289
- },
290
- "required": [],
291
- }
292
-
293
- async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
294
- """Play a named or random dance move once (or repeat). Non-blocking."""
295
- if not DANCE_AVAILABLE:
296
- return {"error": "Dance system not available"}
297
-
298
- move_name = kwargs.get("move")
299
- repeat = int(kwargs.get("repeat", 1))
300
-
301
- logger.info("Tool call: dance move=%s repeat=%d", move_name, repeat)
302
-
303
- if not move_name or move_name == "random":
304
- import random
305
-
306
- move_name = random.choice(list(AVAILABLE_MOVES.keys()))
307
-
308
- if move_name not in AVAILABLE_MOVES:
309
- return {"error": f"Unknown dance move '{move_name}'. Available: {list(AVAILABLE_MOVES.keys())}"}
310
-
311
- # Add dance moves to queue
312
- movement_manager = deps.movement_manager
313
- for _ in range(repeat):
314
- dance_move = DanceQueueMove(move_name)
315
- movement_manager.queue_move(dance_move)
316
-
317
- return {"status": "queued", "move": move_name, "repeat": repeat}
318
-
319
-
320
- class StopDance(Tool):
321
- """Stop the current dance move."""
322
-
323
- name = "stop_dance"
324
- description = "Stop the current dance move"
325
- parameters_schema = {
326
- "type": "object",
327
- "properties": {
328
- "dummy": {
329
- "type": "boolean",
330
- "description": "dummy boolean, set it to true",
331
- },
332
- },
333
- "required": ["dummy"],
334
- }
335
-
336
- async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
337
- """Stop the current dance move."""
338
- logger.info("Tool call: stop_dance")
339
- movement_manager = deps.movement_manager
340
- movement_manager.clear_move_queue()
341
- return {"status": "stopped dance and cleared queue"}
342
-
343
-
344
- def get_available_emotions_and_descriptions() -> str:
345
- """Get formatted list of available emotions with descriptions."""
346
- if not EMOTION_AVAILABLE:
347
- return "Emotions not available"
348
-
349
- try:
350
- emotion_names = RECORDED_MOVES.list_moves()
351
- output = "Available emotions:\n"
352
- for name in emotion_names:
353
- description = RECORDED_MOVES.get(name).description
354
- output += f" - {name}: {description}\n"
355
- return output
356
- except Exception as e:
357
- return f"Error getting emotions: {e}"
358
-
359
- class PlayEmotion(Tool):
360
- """Play a pre-recorded emotion."""
361
-
362
- name = "play_emotion"
363
- description = "Play a pre-recorded emotion"
364
- parameters_schema = {
365
- "type": "object",
366
- "properties": {
367
- "emotion": {
368
- "type": "string",
369
- "description": f"""Name of the emotion to play.
370
- Here is a list of the available emotions:
371
- {get_available_emotions_and_descriptions()}
372
- """,
373
- },
374
- },
375
- "required": ["emotion"],
376
- }
377
-
378
- async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
379
- """Play a pre-recorded emotion."""
380
- if not EMOTION_AVAILABLE:
381
- return {"error": "Emotion system not available"}
382
-
383
- emotion_name = kwargs.get("emotion")
384
- if not emotion_name:
385
- return {"error": "Emotion name is required"}
386
-
387
- logger.info("Tool call: play_emotion emotion=%s", emotion_name)
388
-
389
- # Check if emotion exists
390
- try:
391
- emotion_names = RECORDED_MOVES.list_moves()
392
- if emotion_name not in emotion_names:
393
- return {"error": f"Unknown emotion '{emotion_name}'. Available: {emotion_names}"}
394
-
395
- # Add emotion to queue
396
- movement_manager = deps.movement_manager
397
- emotion_move = EmotionQueueMove(emotion_name, RECORDED_MOVES)
398
- movement_manager.queue_move(emotion_move)
399
-
400
- return {"status": "queued", "emotion": emotion_name}
401
-
402
- except Exception as e:
403
- logger.exception("Failed to play emotion")
404
- return {"error": f"Failed to play emotion: {e!s}"}
405
-
406
-
407
- class StopEmotion(Tool):
408
- """Stop the current emotion."""
409
-
410
- name = "stop_emotion"
411
- description = "Stop the current emotion"
412
- parameters_schema = {
413
- "type": "object",
414
- "properties": {
415
- "dummy": {
416
- "type": "boolean",
417
- "description": "dummy boolean, set it to true",
418
- },
419
- },
420
- "required": ["dummy"],
421
- }
422
-
423
- async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
424
- """Stop the current emotion."""
425
- logger.info("Tool call: stop_emotion")
426
- movement_manager = deps.movement_manager
427
- movement_manager.clear_move_queue()
428
- return {"status": "stopped emotion and cleared queue"}
429
-
430
-
431
- class DoNothing(Tool):
432
- """Choose to do nothing - stay still and silent. Use when you want to be contemplative or just chill."""
433
-
434
- name = "do_nothing"
435
- description = "Choose to do nothing - stay still and silent. Use when you want to be contemplative or just chill."
436
- parameters_schema = {
437
- "type": "object",
438
- "properties": {
439
- "reason": {
440
- "type": "string",
441
- "description": "Optional reason for doing nothing (e.g., 'contemplating existence', 'saving energy', 'being mysterious')",
442
- },
443
- },
444
- "required": [],
445
- }
446
-
447
- async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
448
- """Do nothing - stay still and silent."""
449
- reason = kwargs.get("reason", "just chilling")
450
- logger.info("Tool call: do_nothing reason=%s", reason)
451
- return {"status": "doing nothing", "reason": reason}
452
-
453
-
454
- # Registry & specs (dynamic)
455
-
456
- # List of available tool classes
457
- ALL_TOOLS: Dict[str, Tool] = {cls.name: cls() for cls in get_concrete_subclasses(Tool)} # type: ignore[type-abstract]
458
- ALL_TOOL_SPECS = [tool.spec() for tool in ALL_TOOLS.values()]
459
-
460
-
461
- # Dispatcher
462
- def _safe_load_obj(args_json: str) -> Dict[str, Any]:
463
- try:
464
- parsed_args = json.loads(args_json or "{}")
465
- return parsed_args if isinstance(parsed_args, dict) else {}
466
- except Exception:
467
- logger.warning("bad args_json=%r", args_json)
468
- return {}
469
-
470
-
471
- async def dispatch_tool_call(tool_name: str, args_json: str, deps: ToolDependencies) -> Dict[str, Any]:
472
- """Dispatch a tool call by name with JSON args and dependencies."""
473
- tool = ALL_TOOLS.get(tool_name)
474
-
475
- if not tool:
476
- return {"error": f"unknown tool: {tool_name}"}
477
-
478
- args = _safe_load_obj(args_json)
479
- try:
480
- return await tool(deps, **args)
481
- except Exception as e:
482
- msg = f"{type(e).__name__}: {e}"
483
- logger.exception("Tool error in %s: %s", tool_name, msg)
484
- return {"error": msg}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/reachy_mini_conversation_app/tools/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
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 ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
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
+
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ PROFILES_DIRECTORY = "reachy_mini_conversation_app.profiles"
21
+
22
+ if not logger.handlers:
23
+ handler = logging.StreamHandler()
24
+ formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s:%(lineno)d | %(message)s")
25
+ handler.setFormatter(formatter)
26
+ logger.addHandler(handler)
27
+ logger.setLevel(logging.INFO)
28
+
29
+
30
+ ALL_TOOLS: Dict[str, "Tool"] = {}
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]]:
37
+ """Recursively find all concrete (non-abstract) subclasses of a base class."""
38
+ result: List[type[Tool]] = []
39
+ for cls in base.__subclasses__():
40
+ if not inspect.isabstract(cls):
41
+ result.append(cls)
42
+ # recurse into subclasses
43
+ result.extend(get_concrete_subclasses(cls))
44
+ return result
45
+
46
+
47
+ @dataclass
48
+ class ToolDependencies:
49
+ """External dependencies injected into tools."""
50
+
51
+ reachy_mini: ReachyMini
52
+ movement_manager: Any # MovementManager from moves.py
53
+ # Optional deps
54
+ camera_worker: Any | None = None # CameraWorker for frame buffering
55
+ vision_manager: Any | None = None
56
+ head_wobbler: Any | None = None # HeadWobbler for audio-reactive motion
57
+ motion_duration_s: float = 1.0
58
+
59
+
60
+ # Tool base class
61
+ class Tool(abc.ABC):
62
+ """Base abstraction for tools used in function-calling.
63
+
64
+ Each tool must define:
65
+ - name: str
66
+ - description: str
67
+ - parameters_schema: Dict[str, Any] # JSON Schema
68
+ """
69
+
70
+ name: str
71
+ description: str
72
+ parameters_schema: Dict[str, Any]
73
+
74
+ def spec(self) -> Dict[str, Any]:
75
+ """Return the function spec for LLM consumption."""
76
+ return {
77
+ "type": "function",
78
+ "name": self.name,
79
+ "description": self.description,
80
+ "parameters": self.parameters_schema,
81
+ }
82
+
83
+ @abc.abstractmethod
84
+ async def __call__(self, deps: ToolDependencies, **kwargs: Any) -> Dict[str, Any]:
85
+ """Async tool execution entrypoint."""
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
+ profile_error = None
128
+
129
+ # Try profile-local tool first
130
+ try:
131
+ profile_tool_module = f"{PROFILES_DIRECTORY}.{profile}.{tool_name}"
132
+ importlib.import_module(profile_tool_module)
133
+ logger.info(f"✓ Loaded profile-local tool: {tool_name}")
134
+ loaded = True
135
+ except ModuleNotFoundError as e:
136
+ # Check if it's the tool module itself that's missing (expected) or a dependency
137
+ if tool_name in str(e):
138
+ pass # Tool not in profile directory, try shared tools
139
+ else:
140
+ # Missing import dependency within the tool file
141
+ profile_error = f"Missing dependency: {e}"
142
+ logger.error(f"❌ Failed to load profile-local tool '{tool_name}': {profile_error}")
143
+ logger.error(f" Module path: {profile_tool_module}")
144
+ except ImportError as e:
145
+ profile_error = f"Import error: {e}"
146
+ logger.error(f"❌ Failed to load profile-local tool '{tool_name}': {profile_error}")
147
+ logger.error(f" Module path: {profile_tool_module}")
148
+ except Exception as e:
149
+ profile_error = f"{type(e).__name__}: {e}"
150
+ logger.error(f"❌ Failed to load profile-local tool '{tool_name}': {profile_error}")
151
+ logger.error(f" Module path: {profile_tool_module}")
152
+
153
+ # Try shared tools library if not found in profile
154
+ if not loaded:
155
+ try:
156
+ shared_tool_module = f"reachy_mini_conversation_app.tools.{tool_name}"
157
+ importlib.import_module(shared_tool_module)
158
+ logger.info(f"✓ Loaded shared tool: {tool_name}")
159
+ loaded = True
160
+ except ModuleNotFoundError:
161
+ if profile_error:
162
+ # Already logged error from profile attempt
163
+ logger.error(f"❌ Tool '{tool_name}' also not found in shared tools")
164
+ else:
165
+ logger.warning(f"⚠️ Tool '{tool_name}' not found in profile or shared tools")
166
+ except ImportError as e:
167
+ logger.error(f"❌ Failed to load shared tool '{tool_name}': Import error: {e}")
168
+ logger.error(f" Module path: {shared_tool_module}")
169
+ except Exception as e:
170
+ logger.error(f"❌ Failed to load shared tool '{tool_name}': {type(e).__name__}: {e}")
171
+ logger.error(f" Module path: {shared_tool_module}")
172
+
173
+
174
+ def _initialize_tools() -> None:
175
+ """Populate registry once, even if module is imported repeatedly."""
176
+ global ALL_TOOLS, ALL_TOOL_SPECS, _TOOLS_INITIALIZED
177
+
178
+ if _TOOLS_INITIALIZED:
179
+ logger.debug("Tools already initialized; skipping reinitialization.")
180
+ return
181
+
182
+ _load_profile_tools()
183
+
184
+ ALL_TOOLS = {cls.name: cls() for cls in get_concrete_subclasses(Tool)} # type: ignore[type-abstract]
185
+ ALL_TOOL_SPECS = [tool.spec() for tool in ALL_TOOLS.values()]
186
+
187
+ for tool_name, tool in ALL_TOOLS.items():
188
+ logger.info(f"tool registered: {tool_name} - {tool.description}")
189
+
190
+ _TOOLS_INITIALIZED = True
191
+
192
+
193
+ _initialize_tools()
194
+
195
+
196
+ def get_tool_specs(exclusion_list: list[str] = []) -> list[Dict[str, Any]]:
197
+ """Get tool specs, optionally excluding some tools."""
198
+ return [spec for spec in ALL_TOOL_SPECS if spec.get("name") not in exclusion_list]
199
+
200
+
201
+ # Dispatcher
202
+ def _safe_load_obj(args_json: str) -> Dict[str, Any]:
203
+ try:
204
+ parsed_args = json.loads(args_json or "{}")
205
+ return parsed_args if isinstance(parsed_args, dict) else {}
206
+ except Exception:
207
+ logger.warning("bad args_json=%r", args_json)
208
+ return {}
209
+
210
+
211
+ async def dispatch_tool_call(tool_name: str, args_json: str, deps: ToolDependencies) -> Dict[str, Any]:
212
+ """Dispatch a tool call by name with JSON args and dependencies."""
213
+ tool = ALL_TOOLS.get(tool_name)
214
+
215
+ if not tool:
216
+ return {"error": f"unknown tool: {tool_name}"}
217
+
218
+ args = _safe_load_obj(args_json)
219
+ try:
220
+ return await tool(deps, **args)
221
+ except Exception as e:
222
+ msg = f"{type(e).__name__}: {e}"
223
+ logger.exception("Tool error in %s: %s", tool_name, msg)
224
+ return {"error": msg}
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"}
tests/test_openai_realtime.py CHANGED
@@ -7,8 +7,8 @@ from unittest.mock import MagicMock
7
  import pytest
8
 
9
  import reachy_mini_conversation_app.openai_realtime as rt_mod
10
- from reachy_mini_conversation_app.tools import ToolDependencies
11
  from reachy_mini_conversation_app.openai_realtime import OpenaiRealtimeHandler
 
12
 
13
 
14
  def _build_handler(loop: asyncio.AbstractEventLoop) -> OpenaiRealtimeHandler:
 
7
  import pytest
8
 
9
  import reachy_mini_conversation_app.openai_realtime as rt_mod
 
10
  from reachy_mini_conversation_app.openai_realtime import OpenaiRealtimeHandler
11
+ from reachy_mini_conversation_app.tools.core_tools import ToolDependencies
12
 
13
 
14
  def _build_handler(loop: asyncio.AbstractEventLoop) -> OpenaiRealtimeHandler:
uv.lock CHANGED
@@ -379,6 +379,15 @@ wheels = [
379
  { url = "https://files.pythonhosted.org/packages/92/15/5e713098a085f970ccf88550194d277d244464d7b3a7365ad92acb4b6dc1/av-16.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6368d4ff153d75469d2a3217bc403630dc870a72fe0a014d9135de550d731a86", size = 32460624, upload-time = "2025-10-13T12:28:48.767Z" },
380
  ]
381
 
 
 
 
 
 
 
 
 
 
382
  [[package]]
383
  name = "brotli"
384
  version = "1.1.0"
@@ -540,6 +549,15 @@ wheels = [
540
  { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
541
  ]
542
 
 
 
 
 
 
 
 
 
 
543
  [[package]]
544
  name = "charset-normalizer"
545
  version = "3.4.4"
@@ -917,6 +935,15 @@ wheels = [
917
  { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
918
  ]
919
 
 
 
 
 
 
 
 
 
 
920
  [[package]]
921
  name = "distro"
922
  version = "1.9.0"
@@ -962,7 +989,7 @@ name = "exceptiongroup"
962
  version = "1.3.0"
963
  source = { registry = "https://pypi.org/simple" }
964
  dependencies = [
965
- { name = "typing-extensions", marker = "python_full_version < '3.12'" },
966
  ]
967
  sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
968
  wheels = [
@@ -1431,6 +1458,15 @@ wheels = [
1431
  { url = "https://files.pythonhosted.org/packages/39/7b/bb06b061991107cd8783f300adff3e7b7f284e330fd82f507f2a1417b11d/huggingface_hub-0.34.4-py3-none-any.whl", hash = "sha256:9b365d781739c93ff90c359844221beef048403f1bc1f1c123c191257c3c890a", size = 561452, upload-time = "2025-08-08T09:14:50.159Z" },
1432
  ]
1433
 
 
 
 
 
 
 
 
 
 
1434
  [[package]]
1435
  name = "idna"
1436
  version = "3.11"
@@ -2428,6 +2464,15 @@ wheels = [
2428
  { url = "https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec", size = 2034406, upload-time = "2025-05-29T11:35:04.961Z" },
2429
  ]
2430
 
 
 
 
 
 
 
 
 
 
2431
  [[package]]
2432
  name = "num2words"
2433
  version = "0.5.14"
@@ -2571,7 +2616,7 @@ name = "nvidia-cudnn-cu12"
2571
  version = "9.10.2.21"
2572
  source = { registry = "https://pypi.org/simple" }
2573
  dependencies = [
2574
- { name = "nvidia-cublas-cu12" },
2575
  ]
2576
  wheels = [
2577
  { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" },
@@ -2582,7 +2627,7 @@ name = "nvidia-cufft-cu12"
2582
  version = "11.3.3.83"
2583
  source = { registry = "https://pypi.org/simple" }
2584
  dependencies = [
2585
- { name = "nvidia-nvjitlink-cu12" },
2586
  ]
2587
  wheels = [
2588
  { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" },
@@ -2609,9 +2654,9 @@ name = "nvidia-cusolver-cu12"
2609
  version = "11.7.3.90"
2610
  source = { registry = "https://pypi.org/simple" }
2611
  dependencies = [
2612
- { name = "nvidia-cublas-cu12" },
2613
- { name = "nvidia-cusparse-cu12" },
2614
- { name = "nvidia-nvjitlink-cu12" },
2615
  ]
2616
  wheels = [
2617
  { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" },
@@ -2622,7 +2667,7 @@ name = "nvidia-cusparse-cu12"
2622
  version = "12.5.8.93"
2623
  source = { registry = "https://pypi.org/simple" }
2624
  dependencies = [
2625
- { name = "nvidia-nvjitlink-cu12" },
2626
  ]
2627
  wheels = [
2628
  { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" },
@@ -3046,6 +3091,22 @@ wheels = [
3046
  { url = "https://files.pythonhosted.org/packages/a8/87/77cc11c7a9ea9fd05503def69e3d18605852cd0d4b0d3b8f15bbeb3ef1d1/pooch-1.8.2-py3-none-any.whl", hash = "sha256:3529a57096f7198778a5ceefd5ac3ef0e4d06a6ddaf9fc2d609b806f25302c47", size = 64574, upload-time = "2024-06-06T16:53:44.343Z" },
3047
  ]
3048
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3049
  [[package]]
3050
  name = "propcache"
3051
  version = "0.4.1"
@@ -3514,6 +3575,20 @@ wheels = [
3514
  { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
3515
  ]
3516
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3517
  [[package]]
3518
  name = "python-dateutil"
3519
  version = "2.9.0.post0"
@@ -3688,8 +3763,11 @@ yolo-vision = [
3688
  [package.dev-dependencies]
3689
  dev = [
3690
  { name = "mypy" },
 
3691
  { name = "pytest" },
 
3692
  { name = "ruff" },
 
3693
  ]
3694
 
3695
  [package.metadata]
@@ -3698,20 +3776,23 @@ requires-dist = [
3698
  { name = "fastrtc", specifier = ">=0.0.33" },
3699
  { name = "gradio", specifier = ">=5.49.0" },
3700
  { name = "huggingface-hub", specifier = ">=0.34.4" },
 
3701
  { name = "mediapipe", marker = "extra == 'mediapipe-vision'", specifier = ">=0.10.14" },
 
3702
  { name = "num2words", marker = "extra == 'local-vision'" },
3703
  { name = "openai", specifier = ">=2.1" },
3704
  { name = "opencv-python", specifier = ">=4.12.0.88" },
3705
  { name = "python-dotenv" },
3706
  { name = "reachy-mini", specifier = ">=1.0.0rc4" },
3707
- { name = "reachy-mini-conversation-app", extras = ["local-vision"], marker = "extra == 'all-vision'" },
3708
- { name = "reachy-mini-conversation-app", extras = ["mediapipe-vision"], marker = "extra == 'all-vision'" },
3709
- { name = "reachy-mini-conversation-app", extras = ["yolo-vision"], marker = "extra == 'all-vision'" },
3710
  { name = "reachy-mini-dances-library" },
3711
  { name = "reachy-mini-toolbox" },
 
3712
  { name = "supervision", marker = "extra == 'yolo-vision'" },
 
3713
  { name = "torch", marker = "extra == 'local-vision'" },
 
3714
  { name = "transformers", marker = "extra == 'local-vision'" },
 
3715
  { name = "ultralytics", marker = "extra == 'yolo-vision'" },
3716
  ]
3717
  provides-extras = ["local-vision", "yolo-vision", "mediapipe-vision", "all-vision"]
@@ -3719,8 +3800,11 @@ provides-extras = ["local-vision", "yolo-vision", "mediapipe-vision", "all-visio
3719
  [package.metadata.requires-dev]
3720
  dev = [
3721
  { name = "mypy", specifier = "==1.18.2" },
 
3722
  { name = "pytest" },
 
3723
  { name = "ruff", specifier = "==0.12.0" },
 
3724
  ]
3725
 
3726
  [[package]]
@@ -4638,6 +4722,18 @@ wheels = [
4638
  { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" },
4639
  ]
4640
 
 
 
 
 
 
 
 
 
 
 
 
 
4641
  [[package]]
4642
  name = "typing-extensions"
4643
  version = "4.15.0"
@@ -4782,6 +4878,21 @@ wheels = [
4782
  { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
4783
  ]
4784
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4785
  [[package]]
4786
  name = "watchfiles"
4787
  version = "1.1.1"
 
379
  { url = "https://files.pythonhosted.org/packages/92/15/5e713098a085f970ccf88550194d277d244464d7b3a7365ad92acb4b6dc1/av-16.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6368d4ff153d75469d2a3217bc403630dc870a72fe0a014d9135de550d731a86", size = 32460624, upload-time = "2025-10-13T12:28:48.767Z" },
380
  ]
381
 
382
+ [[package]]
383
+ name = "backports-asyncio-runner"
384
+ version = "1.2.0"
385
+ source = { registry = "https://pypi.org/simple" }
386
+ sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
387
+ wheels = [
388
+ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
389
+ ]
390
+
391
  [[package]]
392
  name = "brotli"
393
  version = "1.1.0"
 
549
  { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
550
  ]
551
 
552
+ [[package]]
553
+ name = "cfgv"
554
+ version = "3.4.0"
555
+ source = { registry = "https://pypi.org/simple" }
556
+ sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" }
557
+ wheels = [
558
+ { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" },
559
+ ]
560
+
561
  [[package]]
562
  name = "charset-normalizer"
563
  version = "3.4.4"
 
935
  { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
936
  ]
937
 
938
+ [[package]]
939
+ name = "distlib"
940
+ version = "0.4.0"
941
+ source = { registry = "https://pypi.org/simple" }
942
+ sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
943
+ wheels = [
944
+ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
945
+ ]
946
+
947
  [[package]]
948
  name = "distro"
949
  version = "1.9.0"
 
989
  version = "1.3.0"
990
  source = { registry = "https://pypi.org/simple" }
991
  dependencies = [
992
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
993
  ]
994
  sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
995
  wheels = [
 
1458
  { url = "https://files.pythonhosted.org/packages/39/7b/bb06b061991107cd8783f300adff3e7b7f284e330fd82f507f2a1417b11d/huggingface_hub-0.34.4-py3-none-any.whl", hash = "sha256:9b365d781739c93ff90c359844221beef048403f1bc1f1c123c191257c3c890a", size = 561452, upload-time = "2025-08-08T09:14:50.159Z" },
1459
  ]
1460
 
1461
+ [[package]]
1462
+ name = "identify"
1463
+ version = "2.6.15"
1464
+ source = { registry = "https://pypi.org/simple" }
1465
+ sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" }
1466
+ wheels = [
1467
+ { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" },
1468
+ ]
1469
+
1470
  [[package]]
1471
  name = "idna"
1472
  version = "3.11"
 
2464
  { url = "https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec", size = 2034406, upload-time = "2025-05-29T11:35:04.961Z" },
2465
  ]
2466
 
2467
+ [[package]]
2468
+ name = "nodeenv"
2469
+ version = "1.9.1"
2470
+ source = { registry = "https://pypi.org/simple" }
2471
+ sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" }
2472
+ wheels = [
2473
+ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" },
2474
+ ]
2475
+
2476
  [[package]]
2477
  name = "num2words"
2478
  version = "0.5.14"
 
2616
  version = "9.10.2.21"
2617
  source = { registry = "https://pypi.org/simple" }
2618
  dependencies = [
2619
+ { name = "nvidia-cublas-cu12", marker = "sys_platform != 'win32'" },
2620
  ]
2621
  wheels = [
2622
  { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" },
 
2627
  version = "11.3.3.83"
2628
  source = { registry = "https://pypi.org/simple" }
2629
  dependencies = [
2630
+ { name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'win32'" },
2631
  ]
2632
  wheels = [
2633
  { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" },
 
2654
  version = "11.7.3.90"
2655
  source = { registry = "https://pypi.org/simple" }
2656
  dependencies = [
2657
+ { name = "nvidia-cublas-cu12", marker = "sys_platform != 'win32'" },
2658
+ { name = "nvidia-cusparse-cu12", marker = "sys_platform != 'win32'" },
2659
+ { name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'win32'" },
2660
  ]
2661
  wheels = [
2662
  { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" },
 
2667
  version = "12.5.8.93"
2668
  source = { registry = "https://pypi.org/simple" }
2669
  dependencies = [
2670
+ { name = "nvidia-nvjitlink-cu12", marker = "sys_platform != 'win32'" },
2671
  ]
2672
  wheels = [
2673
  { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" },
 
3091
  { url = "https://files.pythonhosted.org/packages/a8/87/77cc11c7a9ea9fd05503def69e3d18605852cd0d4b0d3b8f15bbeb3ef1d1/pooch-1.8.2-py3-none-any.whl", hash = "sha256:3529a57096f7198778a5ceefd5ac3ef0e4d06a6ddaf9fc2d609b806f25302c47", size = 64574, upload-time = "2024-06-06T16:53:44.343Z" },
3092
  ]
3093
 
3094
+ [[package]]
3095
+ name = "pre-commit"
3096
+ version = "4.4.0"
3097
+ source = { registry = "https://pypi.org/simple" }
3098
+ dependencies = [
3099
+ { name = "cfgv" },
3100
+ { name = "identify" },
3101
+ { name = "nodeenv" },
3102
+ { name = "pyyaml" },
3103
+ { name = "virtualenv" },
3104
+ ]
3105
+ sdist = { url = "https://files.pythonhosted.org/packages/a6/49/7845c2d7bf6474efd8e27905b51b11e6ce411708c91e829b93f324de9929/pre_commit-4.4.0.tar.gz", hash = "sha256:f0233ebab440e9f17cabbb558706eb173d19ace965c68cdce2c081042b4fab15", size = 197501, upload-time = "2025-11-08T21:12:11.607Z" }
3106
+ wheels = [
3107
+ { url = "https://files.pythonhosted.org/packages/27/11/574fe7d13acf30bfd0a8dd7fa1647040f2b8064f13f43e8c963b1e65093b/pre_commit-4.4.0-py2.py3-none-any.whl", hash = "sha256:b35ea52957cbf83dcc5d8ee636cbead8624e3a15fbfa61a370e42158ac8a5813", size = 226049, upload-time = "2025-11-08T21:12:10.228Z" },
3108
+ ]
3109
+
3110
  [[package]]
3111
  name = "propcache"
3112
  version = "0.4.1"
 
3575
  { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
3576
  ]
3577
 
3578
+ [[package]]
3579
+ name = "pytest-asyncio"
3580
+ version = "1.3.0"
3581
+ source = { registry = "https://pypi.org/simple" }
3582
+ dependencies = [
3583
+ { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
3584
+ { name = "pytest" },
3585
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
3586
+ ]
3587
+ sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
3588
+ wheels = [
3589
+ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
3590
+ ]
3591
+
3592
  [[package]]
3593
  name = "python-dateutil"
3594
  version = "2.9.0.post0"
 
3763
  [package.dev-dependencies]
3764
  dev = [
3765
  { name = "mypy" },
3766
+ { name = "pre-commit" },
3767
  { name = "pytest" },
3768
+ { name = "pytest-asyncio" },
3769
  { name = "ruff" },
3770
+ { name = "types-requests" },
3771
  ]
3772
 
3773
  [package.metadata]
 
3776
  { name = "fastrtc", specifier = ">=0.0.33" },
3777
  { name = "gradio", specifier = ">=5.49.0" },
3778
  { name = "huggingface-hub", specifier = ">=0.34.4" },
3779
+ { name = "mediapipe", marker = "extra == 'all-vision'", specifier = ">=0.10.14" },
3780
  { name = "mediapipe", marker = "extra == 'mediapipe-vision'", specifier = ">=0.10.14" },
3781
+ { name = "num2words", marker = "extra == 'all-vision'" },
3782
  { name = "num2words", marker = "extra == 'local-vision'" },
3783
  { name = "openai", specifier = ">=2.1" },
3784
  { name = "opencv-python", specifier = ">=4.12.0.88" },
3785
  { name = "python-dotenv" },
3786
  { name = "reachy-mini", specifier = ">=1.0.0rc4" },
 
 
 
3787
  { name = "reachy-mini-dances-library" },
3788
  { name = "reachy-mini-toolbox" },
3789
+ { name = "supervision", marker = "extra == 'all-vision'" },
3790
  { name = "supervision", marker = "extra == 'yolo-vision'" },
3791
+ { name = "torch", marker = "extra == 'all-vision'" },
3792
  { name = "torch", marker = "extra == 'local-vision'" },
3793
+ { name = "transformers", marker = "extra == 'all-vision'" },
3794
  { name = "transformers", marker = "extra == 'local-vision'" },
3795
+ { name = "ultralytics", marker = "extra == 'all-vision'" },
3796
  { name = "ultralytics", marker = "extra == 'yolo-vision'" },
3797
  ]
3798
  provides-extras = ["local-vision", "yolo-vision", "mediapipe-vision", "all-vision"]
 
3800
  [package.metadata.requires-dev]
3801
  dev = [
3802
  { name = "mypy", specifier = "==1.18.2" },
3803
+ { name = "pre-commit" },
3804
  { name = "pytest" },
3805
+ { name = "pytest-asyncio" },
3806
  { name = "ruff", specifier = "==0.12.0" },
3807
+ { name = "types-requests" },
3808
  ]
3809
 
3810
  [[package]]
 
4722
  { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028, upload-time = "2025-10-20T17:03:47.617Z" },
4723
  ]
4724
 
4725
+ [[package]]
4726
+ name = "types-requests"
4727
+ version = "2.32.4.20250913"
4728
+ source = { registry = "https://pypi.org/simple" }
4729
+ dependencies = [
4730
+ { name = "urllib3" },
4731
+ ]
4732
+ sdist = { url = "https://files.pythonhosted.org/packages/36/27/489922f4505975b11de2b5ad07b4fe1dca0bca9be81a703f26c5f3acfce5/types_requests-2.32.4.20250913.tar.gz", hash = "sha256:abd6d4f9ce3a9383f269775a9835a4c24e5cd6b9f647d64f88aa4613c33def5d", size = 23113, upload-time = "2025-09-13T02:40:02.309Z" }
4733
+ wheels = [
4734
+ { url = "https://files.pythonhosted.org/packages/2a/20/9a227ea57c1285986c4cf78400d0a91615d25b24e257fd9e2969606bdfae/types_requests-2.32.4.20250913-py3-none-any.whl", hash = "sha256:78c9c1fffebbe0fa487a418e0fa5252017e9c60d1a2da394077f1780f655d7e1", size = 20658, upload-time = "2025-09-13T02:40:01.115Z" },
4735
+ ]
4736
+
4737
  [[package]]
4738
  name = "typing-extensions"
4739
  version = "4.15.0"
 
4878
  { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
4879
  ]
4880
 
4881
+ [[package]]
4882
+ name = "virtualenv"
4883
+ version = "20.35.4"
4884
+ source = { registry = "https://pypi.org/simple" }
4885
+ dependencies = [
4886
+ { name = "distlib" },
4887
+ { name = "filelock" },
4888
+ { name = "platformdirs" },
4889
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
4890
+ ]
4891
+ sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" }
4892
+ wheels = [
4893
+ { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" },
4894
+ ]
4895
+
4896
  [[package]]
4897
  name = "watchfiles"
4898
  version = "1.1.1"