Merge pull request #79 from pollen-robotics/usecases
Browse filesFlexible demo system to customize prompts and tools, and share them in reusable libraries, staying out of the core components.
- .env.example +3 -0
- README.md +34 -0
- pyproject.toml +5 -1
- src/reachy_mini_conversation_app/config.py +2 -0
- src/reachy_mini_conversation_app/main.py +1 -1
- src/reachy_mini_conversation_app/openai_realtime.py +6 -6
- src/reachy_mini_conversation_app/profiles/__init__.py +1 -0
- src/reachy_mini_conversation_app/profiles/default/instructions.txt +1 -0
- src/reachy_mini_conversation_app/profiles/default/tools.txt +8 -0
- src/reachy_mini_conversation_app/profiles/emotion_reader/instructions.txt +112 -0
- src/reachy_mini_conversation_app/profiles/emotion_reader/tools.txt +6 -0
- src/reachy_mini_conversation_app/profiles/example/instructions.txt +3 -0
- src/reachy_mini_conversation_app/profiles/example/sweep_look.py +127 -0
- src/reachy_mini_conversation_app/profiles/example/tools.txt +14 -0
- src/reachy_mini_conversation_app/prompts.py +83 -51
- src/reachy_mini_conversation_app/prompts/behaviors/silent_robot.txt +6 -0
- src/reachy_mini_conversation_app/prompts/default_prompt.txt +47 -0
- src/reachy_mini_conversation_app/prompts/identities/basic_info.txt +4 -0
- src/reachy_mini_conversation_app/prompts/identities/witty_identity.txt +4 -0
- src/reachy_mini_conversation_app/prompts/passion_for_lobster_jokes.txt +1 -0
- src/reachy_mini_conversation_app/tools.py +0 -484
- src/reachy_mini_conversation_app/tools/__init__.py +4 -0
- src/reachy_mini_conversation_app/tools/camera.py +67 -0
- src/reachy_mini_conversation_app/tools/core_tools.py +224 -0
- src/reachy_mini_conversation_app/tools/dance.py +87 -0
- src/reachy_mini_conversation_app/tools/do_nothing.py +30 -0
- src/reachy_mini_conversation_app/tools/head_tracking.py +31 -0
- src/reachy_mini_conversation_app/tools/move_head.py +79 -0
- src/reachy_mini_conversation_app/tools/play_emotion.py +84 -0
- src/reachy_mini_conversation_app/tools/stop_dance.py +31 -0
- src/reachy_mini_conversation_app/tools/stop_emotion.py +31 -0
- tests/test_openai_realtime.py +1 -1
- 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 = [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 17 |
-
|
|
|
|
| 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":
|
| 109 |
"audio": {
|
| 110 |
"input": {
|
| 111 |
"format": {
|
|
@@ -129,7 +129,7 @@ class OpenaiRealtimeHandler(AsyncStreamHandler):
|
|
| 129 |
"voice": "cedar",
|
| 130 |
},
|
| 131 |
},
|
| 132 |
-
"tools":
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 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.
|
| 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"
|