From 66d7240668c426549ed65284a407e425516c6188 Mon Sep 17 00:00:00 2001 From: Patrick Kearney Date: Thu, 30 Jul 2026 20:05:38 -0400 Subject: [PATCH 1/9] Add Raspberry Pi deployment compatibility --- .env.example | 1 + docs/lumabot_pi_setup.md | 273 ++++++++++++++++++++++++++++++++++++ lumakit.py | 6 +- requirements.txt | 4 +- tools/code_intel/parsers.py | 39 +++--- 5 files changed, 301 insertions(+), 22 deletions(-) create mode 100644 docs/lumabot_pi_setup.md diff --git a/.env.example b/.env.example index 054a829..fd29892 100644 --- a/.env.example +++ b/.env.example @@ -64,6 +64,7 @@ TELEGRAM_ALLOWED_IDS="your_id,spouse_id,kid_id" LUMIKIT_WHISPER_DIR=".vendor/whisper.cpp" LUMIKIT_WHISPER_BIN=".vendor/whisper.cpp/build/bin/whisper-cli" LUMIKIT_WHISPER_MODEL=".vendor/whisper.cpp/models/ggml-base.en.bin" +LUMIKIT_EDGE_TTS_PYTHON=".venv/bin/python" LUMIKIT_TTS_VOICE="en-US-AvaNeural" LUMIKIT_TTS_FORMAT="mp3" diff --git a/docs/lumabot_pi_setup.md b/docs/lumabot_pi_setup.md new file mode 100644 index 0000000..a2d53b8 --- /dev/null +++ b/docs/lumabot_pi_setup.md @@ -0,0 +1,273 @@ +# LumaKit on LumaBot + +This is the reproducible factory and developer setup for the open-source +LumaBot agent. It targets a Raspberry Pi 5 with 2 GB RAM and a hosted LLM. + +Tested on: + +- Debian 13 (Trixie), ARM64 +- Python 3.13 +- LumaKit upstream base `a439e264429ca49ac81f307d193606f68e852b58` +- whisper.cpp `4523d0ce373ee4b2176b3251fff29fd4864fcf38` +- edge-tts `7.2.8` +- xAI with LumaKit's default `grok-4` model + +## Deployment profile + +Use the main LumaKit repository with the LumaBot compatibility changes. Do +not create a separate "LumaKit Light" repository. + +Install the core package and, when Telegram voice is wanted, only the +`speech` extra. Do not install Ollama, Playwright, Chromium, the `browser` +extra, the `desktop` extra, or the `all` extra. + +Expected layout: + +```text +/home/lumabot21/ +├── lumabot/ +└── lumakit/ +``` + +## Factory installation + +Clone LumaKit and create an isolated environment: + +```bash +cd /home/lumabot21 +git clone https://github.com/patmakesapps/LumaKit.git lumakit +cd lumakit +python3 -m venv .venv +.venv/bin/python -m pip install -e . +``` + +For a developer image, install the test runner and run the shipped tests: + +```bash +.venv/bin/python -m pip install pytest +.venv/bin/python -m pytest -q +``` + +Confirm that optional desktop and browser packages were not installed: + +```bash +.venv/bin/python -m pip show playwright +.venv/bin/python -m pip show pyautogui +``` + +Both commands should report that the package was not found. + +## Two-way Telegram voice + +The tested low-memory voice stack is: + +- Incoming voice notes: local `whisper.cpp` with the English-only + `tiny.en` model. +- Outgoing voice replies: Edge TTS using `en-US-AvaNeural`. + +Whisper runs only while a voice note is being transcribed; it is not a +resident model server. Edge TTS also does not remain loaded, but it does +require an internet connection to synthesize each reply. + +Install only the speech extra and native build tools: + +```bash +sudo apt-get update +sudo apt-get install -y build-essential cmake ffmpeg +cd /home/lumabot21/lumakit +.venv/bin/python -m pip install -e '.[speech]' +``` + +Build the tested whisper.cpp revision with two jobs to control build-time +memory: + +```bash +mkdir -p .vendor +git clone https://github.com/ggml-org/whisper.cpp.git .vendor/whisper.cpp +git -C .vendor/whisper.cpp checkout 4523d0ce373ee4b2176b3251fff29fd4864fcf38 +cmake -S .vendor/whisper.cpp \ + -B .vendor/whisper.cpp/build \ + -DCMAKE_BUILD_TYPE=Release +cmake --build .vendor/whisper.cpp/build -j2 +.vendor/whisper.cpp/models/download-ggml-model.sh tiny.en +``` + +Add these non-secret settings to `/home/lumabot21/.lumakit/config.env`: + +```dotenv +LUMIKIT_WHISPER_DIR="/home/lumabot21/lumakit/.vendor/whisper.cpp" +LUMIKIT_WHISPER_BIN="/home/lumabot21/lumakit/.vendor/whisper.cpp/build/bin/whisper-cli" +LUMIKIT_WHISPER_MODEL="/home/lumabot21/lumakit/.vendor/whisper.cpp/models/ggml-tiny.en.bin" +LUMIKIT_EDGE_TTS_PYTHON="/home/lumabot21/lumakit/.venv/bin/python" +LUMIKIT_TTS_VOICE="en-US-AvaNeural" +LUMIKIT_TTS_FORMAT="mp3" +``` + +After LumaKit is running, the owner can enable replies and choose the voice +from Telegram: + +```text +/voice on +/voice set ava +/voice status +``` + +Voice notes are still transcribed when outgoing voice replies are off. +The initial round-trip test synthesized a short MP3 in 1.77 seconds and +transcribed it in 2.12 seconds, with a measured peak child-process RSS of +about 175 MB. The `tiny.en` model file is about 75 MB on disk. + +## Python 3.13 ARM compatibility + +The original upstream pin `tree-sitter-languages==1.10.2` has no compatible +Python 3.13 ARM64 distribution. The tested LumaBot branch replaces it with: + +```text +tree-sitter==0.26.0 +tree-sitter-language-pack==1.13.2 +``` + +The parser uses the modern `Query` and `QueryCursor` API. Keep this +compatibility change when updating from upstream until upstream adopts an +equivalent fix. + +## Developer configuration + +Never put real credentials in the repository. Store them in: + +```text +/home/lumabot21/.lumakit/config.env +``` + +The minimal hosted-xAI and Telegram configuration is: + +```dotenv +LLM_PROVIDER="xai" +LLM_API_KEY="replace-with-developer-key" +TELEGRAM_BOT_TOKEN="replace-with-bot-token" +TELEGRAM_ALLOWED_IDS="replace-with-owner-chat-id" +``` + +The first Telegram ID is the owner. Keep LumaKit safe mode enabled. Do not +send API keys or bot tokens through Telegram messages. + +Protect the configuration: + +```bash +chmod 600 /home/lumabot21/.lumakit/config.env +``` + +On a shipped device, collect these values through a local first-run setup +page rather than asking the developer to edit a file. + +## Provider validation + +Start LumaKit temporarily: + +```bash +cd /home/lumabot21/lumakit +.venv/bin/lumakit serve +``` + +Confirm that startup reports: + +```text +Web UI: http://localhost:7865 +Telegram: enabled +Telegram bridge running. 1 authorized user(s). +``` + +Send the bot a short message and confirm that it replies through the hosted +provider. Stop the foreground process with `Ctrl+C` before installing the +service. + +## Always-on service + +Generate a unit tied to the LumaKit virtual environment and private config: + +```bash +cd /home/lumabot21/lumakit +.venv/bin/lumakit service install --force \ + --env-file /home/lumabot21/.lumakit/config.env +``` + +Before installation, verify that `lumakit.service` contains: + +```text +ExecStart=/home/lumabot21/lumakit/.venv/bin/python -m lumakit serve +``` + +Install and start it: + +```bash +sudo cp lumakit.service /etc/systemd/system/lumakit.service +sudo systemctl daemon-reload +sudo systemctl enable --now lumakit.service +``` + +Validate the service: + +```bash +.venv/bin/lumakit status +sudo systemctl status lumakit.service +sudo journalctl -u lumakit.service -n 50 --no-pager +``` + +Expected status includes `running`, `telegram: configured`, and the selected +hosted model. + +## Resource check + +Record the steady-state process and system memory: + +```bash +pid=$(systemctl show -p MainPID --value lumakit.service) +ps -o pid,rss,vsz,%mem,etime,cmd -p "$pid" +free -h +swapon --show +``` + +On the initial 2 GB LumaBot, LumaKit used about 69 MB RSS while idle with +the voice stack configured. Whisper is transient, so it does not increase +idle RSS. Recheck idle and transcription-time memory after camera support +and `lumabotd` are running together. + +## Updating another LumaBot + +Before an update, stop the service and confirm the worktree is clean: + +```bash +sudo systemctl stop lumakit.service +cd /home/lumabot21/lumakit +git status +git fetch origin +``` + +Integrate upstream changes without dropping the LumaBot compatibility patch, +then reinstall and retest: + +```bash +.venv/bin/python -m pip install -e '.[speech]' +.venv/bin/python -m pytest -q +sudo systemctl restart lumakit.service +.venv/bin/lumakit status +``` + +Do not use an unreviewed update directly on a moving robot. + +## Recovery and secret rotation + +If startup fails: + +```bash +sudo journalctl -u lumakit.service -n 100 --no-pager +sudo systemctl restart lumakit.service +``` + +If a developer key, Telegram bot token, robot, or SD card is compromised, +revoke the affected credential at the provider, replace it in `config.env`, +restore mode `600`, and restart LumaKit. + +Factory reset must remove developer credentials, Telegram ownership, Wi-Fi +credentials, conversation history, and device-specific runtime data before +the robot changes owners. diff --git a/lumakit.py b/lumakit.py index 77b462d..b49aa70 100644 --- a/lumakit.py +++ b/lumakit.py @@ -595,7 +595,11 @@ def command_service_install(args) -> int: target = _service_install_target(args) working_dir = Path(args.working_dir).expanduser().resolve() if args.working_dir else REPO_ROOT env_file = Path(args.env_file).expanduser().resolve(strict=False) if args.env_file else working_dir / ".env" - python_executable = Path(args.python).expanduser().resolve() if args.python else Path(sys.executable).resolve() + python_executable = ( + Path(args.python).expanduser().absolute() + if args.python + else Path(sys.executable).absolute() + ) user = args.user or getpass.getuser() service_text = _render_systemd_service( user=user, diff --git a/requirements.txt b/requirements.txt index 557f7b5..25070b1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,8 +7,8 @@ httpx==0.28.1 aiohttp==3.13.5 beautifulsoup4==4.14.3 Pillow==12.2.0 -tree-sitter==0.21.3 -tree-sitter-languages==1.10.2 +tree-sitter==0.26.0 +tree-sitter-language-pack==1.13.2 PyYAML==6.0.3 regex==2026.4.4 fastapi==0.136.1 diff --git a/tools/code_intel/parsers.py b/tools/code_intel/parsers.py index 1375bb1..cdf6317 100644 --- a/tools/code_intel/parsers.py +++ b/tools/code_intel/parsers.py @@ -3,7 +3,8 @@ warnings.filterwarnings("ignore", category=FutureWarning, module="tree_sitter") -import tree_sitter_languages as tsl +from tree_sitter import Query, QueryCursor +import tree_sitter_language_pack as tsl from tools.code_intel.symbol_table import Reference, Symbol, SymbolTable @@ -89,6 +90,15 @@ def _get_language(language: str): return tsl.get_language(language) +def _query_captures(language, query: str, root_node): + captures = QueryCursor(Query(language, query)).captures(root_node) + return [ + (node, capture_name) + for capture_name, nodes in captures.items() + for node in nodes + ] + + def _extract_params_python(node) -> list[str]: """Extract parameter names from a Python function_definition node.""" for child in node.children: @@ -257,8 +267,7 @@ def parse_file(file_path: str, source: str | None = None) -> tuple[list[Symbol], # --- Extract classes --- if "classes" in queries: - q = lang.query(queries["classes"]) - captures = q.captures(tree.root_node) + captures = _query_captures(lang, queries["classes"], tree.root_node) for node, capture_name in captures: if capture_name != "definition": continue @@ -286,8 +295,7 @@ def parse_file(file_path: str, source: str | None = None) -> tuple[list[Symbol], for query_key, sym_kind in [("structs", "class"), ("interfaces", "class"), ("enums", "class"), ("traits", "class")]: if query_key in queries: - q = lang.query(queries[query_key]) - captures = q.captures(tree.root_node) + captures = _query_captures(lang, queries[query_key], tree.root_node) for node, capture_name in captures: if capture_name != "definition": continue @@ -315,8 +323,7 @@ def parse_file(file_path: str, source: str | None = None) -> tuple[list[Symbol], # --- Extract functions --- if "functions" in queries: - q = lang.query(queries["functions"]) - captures = q.captures(tree.root_node) + captures = _query_captures(lang, queries["functions"], tree.root_node) for node, capture_name in captures: if capture_name != "definition": continue @@ -352,8 +359,7 @@ def parse_file(file_path: str, source: str | None = None) -> tuple[list[Symbol], # --- Extract Go methods (with receiver) --- if "go_methods" in queries: - q = lang.query(queries["go_methods"]) - captures = q.captures(tree.root_node) + captures = _query_captures(lang, queries["go_methods"], tree.root_node) for node, capture_name in captures: if capture_name != "definition": continue @@ -383,8 +389,7 @@ def parse_file(file_path: str, source: str | None = None) -> tuple[list[Symbol], # --- Extract methods (JS/TS) --- if "methods" in queries: - q = lang.query(queries["methods"]) - captures = q.captures(tree.root_node) + captures = _query_captures(lang, queries["methods"], tree.root_node) for node, capture_name in captures: if capture_name != "definition": continue @@ -414,8 +419,7 @@ def parse_file(file_path: str, source: str | None = None) -> tuple[list[Symbol], # --- Extract arrow functions (JS/TS) --- if "arrows" in queries: - q = lang.query(queries["arrows"]) - captures = q.captures(tree.root_node) + captures = _query_captures(lang, queries["arrows"], tree.root_node) for node, capture_name in captures: if capture_name == "name": symbols.append(Symbol( @@ -433,8 +437,7 @@ def parse_file(file_path: str, source: str | None = None) -> tuple[list[Symbol], if isinstance(import_queries, str): import_queries = [import_queries] for iq in import_queries: - q = lang.query(iq) - captures = q.captures(tree.root_node) + captures = _query_captures(lang, iq, tree.root_node) for node, capture_name in captures: line_num = node.start_point[0] + 1 text = node.text.decode("utf-8").strip() @@ -447,8 +450,7 @@ def parse_file(file_path: str, source: str | None = None) -> tuple[list[Symbol], # --- Extract top-level assignments (variables/constants) --- if "assignments" in queries: - q = lang.query(queries["assignments"]) - captures = q.captures(tree.root_node) + captures = _query_captures(lang, queries["assignments"], tree.root_node) for node, capture_name in captures: if capture_name == "name": # Only top-level assignments (parent is module/program) @@ -471,8 +473,7 @@ def parse_file(file_path: str, source: str | None = None) -> tuple[list[Symbol], # --- Extract Go/Rust vars, consts, statics --- for query_key in ("go_vars", "go_consts", "rust_consts", "rust_statics"): if query_key in queries: - q = lang.query(queries[query_key]) - captures = q.captures(tree.root_node) + captures = _query_captures(lang, queries[query_key], tree.root_node) for node, capture_name in captures: if capture_name == "name": var_name = node.text.decode("utf-8") From d3e04921049e42115a2d757ac23173e2369fc632 Mon Sep 17 00:00:00 2001 From: Patrick Kearney Date: Thu, 30 Jul 2026 21:08:12 -0400 Subject: [PATCH 2/9] Add LLM-driven LumaBot control tools --- .env.example | 3 + core/approval_policy.py | 4 + docs/lumabot_pi_setup.md | 33 +++++ tests/test_approval_policy.py | 4 + tests/test_lumabot_tools.py | 126 ++++++++++++++++ tools/lumabot/__init__.py | 1 + tools/lumabot/client.py | 45 ++++++ tools/lumabot/motion.py | 264 ++++++++++++++++++++++++++++++++++ tools/lumabot/status.py | 19 +++ 9 files changed, 499 insertions(+) create mode 100644 tests/test_lumabot_tools.py create mode 100644 tools/lumabot/__init__.py create mode 100644 tools/lumabot/client.py create mode 100644 tools/lumabot/motion.py create mode 100644 tools/lumabot/status.py diff --git a/.env.example b/.env.example index fd29892..a0fef81 100644 --- a/.env.example +++ b/.env.example @@ -68,6 +68,9 @@ LUMIKIT_EDGE_TTS_PYTHON=".venv/bin/python" LUMIKIT_TTS_VOICE="en-US-AvaNeural" LUMIKIT_TTS_FORMAT="mp3" +# LumaBot hardware daemon (local when LumaKit runs on the robot). +LUMABOT_URL="http://127.0.0.1:8971" + # Lumi's own email account (Gmail recommended — use an App Password) # Generate one at https://myaccount.google.com/apppasswords # Email tools are owner-only (only the first TELEGRAM_ALLOWED_IDS can trigger them) diff --git a/core/approval_policy.py b/core/approval_policy.py index db4a1ba..31703ad 100644 --- a/core/approval_policy.py +++ b/core/approval_policy.py @@ -139,6 +139,10 @@ def tool_always_requires_approval(tool_name: str, tool_inputs: dict) -> bool: "lumalok_get_secret", "lumalok_add_secret", "lumalok_update_secret", + # physical robot control + "lumabot_drive", + "lumabot_sequence", + "lumabot_stop", # tasks run autonomously with broader powers — creating/deleting them is # an escalation path for non-owners "create_task", diff --git a/docs/lumabot_pi_setup.md b/docs/lumabot_pi_setup.md index a2d53b8..8000150 100644 --- a/docs/lumabot_pi_setup.md +++ b/docs/lumabot_pi_setup.md @@ -29,6 +29,39 @@ Expected layout: └── lumakit/ ``` +## LumaBot hardware daemon and agent tools + +Clone both repositories into the layout above. In the LumaBot checkout, +create its virtual environment and install only its Pi hardware dependencies. +Do not install Playwright or Ollama. + +Install and start the supplied hardware service: + +```bash +cd /home/lumabot21/lumabot +sudo cp lumabot.service /etc/systemd/system/lumabot.service +sudo systemctl daemon-reload +sudo systemctl enable --now lumabot.service +curl -fsS http://127.0.0.1:8971/status +``` + +The unit enables the Adafruit Motor Bonnet and X1200 battery gauge. Verify the +robot is raised on a stand before testing movement. The present mapping is +Motor 1 = left (software-inverted) and Motor 4 = right. + +The LumaKit `lumabot_status`, `lumabot_drive`, `lumabot_sequence`, and +`lumabot_stop` tools call this service through `LUMABOT_URL`. Movement tools +are owner-only. Natural-language intent and the final acknowledgement remain +part of LumaKit's normal LLM tool-result cycle; there is no phrase parser. + +After updating either checkout: + +```bash +sudo systemctl restart lumabot.service +sudo systemctl restart lumakit.service +sudo systemctl is-active lumabot.service lumakit.service +``` + ## Factory installation Clone LumaKit and create an isolated environment: diff --git a/tests/test_approval_policy.py b/tests/test_approval_policy.py index 703d38e..4b24998 100644 --- a/tests/test_approval_policy.py +++ b/tests/test_approval_policy.py @@ -68,6 +68,10 @@ def test_telegram_role_scoping(): assert surface_tool_denial("execute_shell") is not None assert surface_tool_denial("write_file") is not None assert surface_tool_denial("create_task") is not None + assert surface_tool_denial("lumabot_drive") is not None + assert surface_tool_denial("lumabot_sequence") is not None + assert surface_tool_denial("lumabot_stop") is not None + assert surface_tool_denial("lumabot_status") is None assert surface_tool_denial("read_file") is None # web/CLI are single-user owner surfaces — unrestricted here diff --git a/tests/test_lumabot_tools.py b/tests/test_lumabot_tools.py new file mode 100644 index 0000000..29fc008 --- /dev/null +++ b/tests/test_lumabot_tools.py @@ -0,0 +1,126 @@ +"""LumaBot tools use structured schemas and the normal tool-result cycle.""" + +import time + +import pytest + +from tool_registry import ToolRegistry +from tools.lumabot import client +from tools.lumabot.motion import ( + SCHEDULER, + get_lumabot_drive_tool, + get_lumabot_sequence_tool, + get_lumabot_stop_tool, +) +from tools.lumabot.status import get_lumabot_status_tool + + +@pytest.fixture() +def registry(): + result = ToolRegistry() + for tool in ( + get_lumabot_drive_tool(), + get_lumabot_sequence_tool(), + get_lumabot_stop_tool(), + get_lumabot_status_tool(), + ): + result.register(tool, group="lumabot") + return result + + +def test_drive_schema_guides_llm_without_phrase_parsing(registry): + tool = registry.get("lumabot_drive") + direction = tool["inputSchema"]["properties"]["direction"] + duration = tool["inputSchema"]["properties"]["duration_s"] + assert direction["enum"] == ["forward", "backward", "left", "right"] + assert duration["maximum"] == 30.0 + assert "structured" in tool["description"].lower() + assert "renewed in the background" in tool["description"].lower() + assert "playful" in tool["description"].lower() + + +@pytest.mark.parametrize( + "inputs", + [ + {"direction": "sideways"}, + {"direction": "forward", "duration_s": 31}, + ], +) +def test_invalid_motion_never_reaches_daemon(registry, monkeypatch, inputs): + monkeypatch.setattr(client, "drive", lambda *args: pytest.fail("HTTP called")) + result = registry.execute("lumabot_drive", inputs) + assert not result["success"] + + +def test_offline_daemon_surfaces_as_tool_failure(registry, monkeypatch): + monkeypatch.setattr( + client, + "drive", + lambda *args: {"error": "LumaBot is offline — is the LumaBot daemon running?"}, + ) + result = registry.execute("lumabot_drive", {"direction": "forward"}) + assert not result["success"] + assert "offline" in result["error"] + + +def test_drive_returns_immediately_with_requested_duration(registry, monkeypatch): + calls = [] + monkeypatch.setattr( + client, + "drive", + lambda direction, speed, duration: calls.append((direction, speed, duration)) + or { + "accepted": True, + "direction": direction, + "speed": speed, + "duration_s": duration, + "watchdog_active": True, + "obstacle_safety_active": False, + }, + ) + monkeypatch.setattr(client, "stop", lambda: {"stopped": True}) + result = registry.execute( + "lumabot_drive", + {"direction": "left", "speed": 0.2, "duration_s": 7}, + ) + assert result["success"] + assert result["data"]["scheduled"] is True + assert result["data"]["direction"] == "left" + assert result["data"]["requested_duration_s"] == 7 + assert "under 12 words" in result["data"]["response_guidance"] + assert calls[0] == ("left", 0.2, 3.0) + SCHEDULER.cancel() + + +def test_sequence_runs_each_step_once_and_in_order(registry, monkeypatch): + calls = [] + monkeypatch.setattr( + client, + "drive", + lambda direction, speed, duration: calls.append((direction, duration)) + or {"accepted": True, "direction": direction}, + ) + result = registry.execute( + "lumabot_sequence", + { + "steps": [ + {"direction": "forward", "speed": 0.2, "duration_s": 0.1}, + {"direction": "left", "speed": 0.2, "duration_s": 0.1}, + {"direction": "backward", "speed": 0.2, "duration_s": 0.1}, + ] + }, + ) + assert result["success"] + assert result["data"]["entire_request_scheduled"] is True + assert result["data"]["step_count"] == 3 + time.sleep(0.35) + assert calls == [("forward", 0.1), ("left", 0.1), ("backward", 0.1)] + SCHEDULER.cancel() + + +def test_stop_and_status_return_daemon_results(registry, monkeypatch): + monkeypatch.setattr(client, "stop", lambda: {"stopped": True}) + monkeypatch.setattr(client, "get_status", lambda: {"battery_pct": 75.0}) + assert registry.execute("lumabot_stop", {})["data"]["stopped"] is True + assert registry.execute("lumabot_status", {})["data"]["battery_pct"] == 75.0 + assert "human-friendly" in registry.get("lumabot_status")["description"] diff --git a/tools/lumabot/__init__.py b/tools/lumabot/__init__.py new file mode 100644 index 0000000..0f1ef8b --- /dev/null +++ b/tools/lumabot/__init__.py @@ -0,0 +1 @@ +"""LumaBot HTTP-client tools.""" diff --git a/tools/lumabot/client.py b/tools/lumabot/client.py new file mode 100644 index 0000000..17a3649 --- /dev/null +++ b/tools/lumabot/client.py @@ -0,0 +1,45 @@ +"""Thin HTTP client for the local LumaBot hardware daemon.""" + +from __future__ import annotations + +import os +from typing import Any + +import requests + + +DEFAULT_URL = "http://127.0.0.1:8971" + + +def _request(method: str, path: str, body: dict[str, Any] | None = None) -> dict: + base_url = os.getenv("LUMABOT_URL", DEFAULT_URL).rstrip("/") + try: + response = requests.request( + method, + f"{base_url}{path}", + json=body, + timeout=2, + ) + payload = response.json() + except (requests.RequestException, ValueError): + return {"error": "LumaBot is offline — is the LumaBot daemon running?"} + + if response.status_code >= 400: + return {"error": payload.get("error") or f"LumaBot returned HTTP {response.status_code}"} + return payload + + +def get_status() -> dict: + return _request("GET", "/status") + + +def drive(direction: str, speed: float, duration_s: float) -> dict: + return _request( + "POST", + "/drive", + {"direction": direction, "speed": speed, "duration_s": duration_s}, + ) + + +def stop() -> dict: + return _request("POST", "/stop") diff --git a/tools/lumabot/motion.py b/tools/lumabot/motion.py new file mode 100644 index 0000000..29300c8 --- /dev/null +++ b/tools/lumabot/motion.py @@ -0,0 +1,264 @@ +"""LLM-selectable LumaBot movement tools.""" + +from __future__ import annotations + +import threading +import time + +from tools.lumabot import client + + +WATCHDOG_LEASE_S = 3.0 +RENEW_MARGIN_S = 0.25 + + +class MotionScheduler: + def __init__(self): + self._lock = threading.Lock() + self._cancel_event: threading.Event | None = None + + def start(self, direction: str, speed: float, duration_s: float) -> dict: + step = {"direction": direction, "speed": speed, "duration_s": duration_s} + return self.start_sequence([step], single_drive=True) + + def start_sequence(self, steps: list[dict], single_drive: bool = False) -> dict: + cancel_event = threading.Event() + with self._lock: + if self._cancel_event: + self._cancel_event.set() + self._cancel_event = cancel_event + + first = steps[0] + started_at = time.monotonic() + first_lease = min(WATCHDOG_LEASE_S, first["duration_s"]) + result = client.drive(first["direction"], first["speed"], first_lease) + if result.get("error"): + cancel_event.set() + return result + + if len(steps) > 1 or first["duration_s"] > first_lease: + worker = threading.Thread( + target=self._run_sequence, + args=(cancel_event, steps, started_at, first_lease), + daemon=True, + name="lumabot-motion-sequence", + ) + worker.start() + + return { + **result, + "requested_duration_s": sum(step["duration_s"] for step in steps), + "watchdog_lease_s": first_lease, + "scheduled": True, + "entire_request_scheduled": True, + "step_count": len(steps), + "steps": steps, + "single_drive": single_drive, + "response_guidance": ( + "Reply now with one playful sentence under 12 words. " + "Do not list movement parameters or repeat any movement tool." + ), + } + + def _run_sequence( + self, + cancel_event: threading.Event, + steps: list[dict], + started_at: float, + first_lease: float, + ) -> None: + first = steps[0] + if not self._finish_step( + cancel_event, + first, + started_at, + first_lease, + ): + return + + for step in steps[1:]: + if cancel_event.is_set(): + return + step_started = time.monotonic() + first_lease = min(WATCHDOG_LEASE_S, step["duration_s"]) + result = client.drive(step["direction"], step["speed"], first_lease) + if result.get("error"): + return + if not self._finish_step(cancel_event, step, step_started, first_lease): + return + + def _finish_step( + self, + cancel_event: threading.Event, + step: dict, + started_at: float, + current_lease: float, + ) -> bool: + deadline = started_at + step["duration_s"] + lease_deadline = started_at + current_lease + while True: + now = time.monotonic() + remaining = deadline - now + if remaining <= 0: + return True + if lease_deadline >= deadline: + return not cancel_event.wait(remaining) + + renew_in = max(0.0, lease_deadline - now - RENEW_MARGIN_S) + if cancel_event.wait(renew_in): + return False + + remaining = deadline - time.monotonic() + if remaining <= 0: + return True + next_lease = min(WATCHDOG_LEASE_S, remaining) + result = client.drive(step["direction"], step["speed"], next_lease) + if result.get("error"): + return False + lease_deadline = time.monotonic() + next_lease + + def cancel(self) -> None: + with self._lock: + if self._cancel_event: + self._cancel_event.set() + self._cancel_event = None + + def stop(self) -> dict: + self.cancel() + return client.stop() + + +SCHEDULER = MotionScheduler() + + +def _drive(inputs: dict) -> dict: + return SCHEDULER.start( + inputs["direction"], + inputs.get("speed", 0.3), + inputs.get("duration_s", 1.0), + ) + + +def _sequence(inputs: dict) -> dict: + raw_steps = inputs["steps"] + if not isinstance(raw_steps, list) or not 1 <= len(raw_steps) <= 10: + raise ValueError("steps must contain between 1 and 10 movement steps") + + steps = [] + for index, raw in enumerate(raw_steps): + if not isinstance(raw, dict): + raise ValueError(f"steps[{index}] must be an object") + direction = raw.get("direction") + if direction not in {"forward", "backward", "left", "right"}: + raise ValueError(f"steps[{index}].direction is invalid") + try: + speed = float(raw.get("speed", 0.3)) + duration_s = float(raw.get("duration_s", 1.0)) + except (TypeError, ValueError) as error: + raise ValueError(f"steps[{index}] speed and duration must be numbers") from error + if not 0.1 <= speed <= 1.0: + raise ValueError(f"steps[{index}].speed must be between 0.1 and 1.0") + if not 0.1 <= duration_s <= 30.0: + raise ValueError(f"steps[{index}].duration_s must be between 0.1 and 30") + steps.append({"direction": direction, "speed": speed, "duration_s": duration_s}) + + if sum(step["duration_s"] for step in steps) > 30.0: + raise ValueError("total sequence duration must not exceed 30 seconds") + return SCHEDULER.start_sequence(steps) + + +def get_lumabot_drive_tool(): + return { + "name": "lumabot_drive", + "description": ( + "Drive or rotate the physical LumaBot using structured direction, speed, and time. " + "Use forward/backward for translation and left/right to spin in place. Positive " + "direction is already calibrated to the robot; do not compensate for motor wiring. " + "The duration is the user's total requested movement time. The tool returns as soon " + "as movement is accepted, while short watchdog leases are renewed in the background " + "until that total duration is reached. Use this tool exactly once for one continuous " + "movement. For an ordered request containing then/after/multiple movements, use " + "lumabot_sequence exactly once instead. A new drive replaces an active drive. Never " + "repeat a movement after a result says entire_request_scheduled=true. The result " + "states whether obstacle safety was active; do not claim it was active when false. " + "After success, reply in one playful sentence under 12 words; do not mechanically " + "recite direction, speed, or duration unless the user asks." + ), + "inputSchema": { + "type": "object", + "properties": { + "direction": { + "type": "string", + "enum": ["forward", "backward", "left", "right"], + "description": "Physical movement direction. Left/right rotate in place.", + }, + "speed": { + "type": "number", + "minimum": 0.1, + "maximum": 1.0, + "description": "Normalized motor speed. Default 0.3; prefer low speeds indoors.", + }, + "duration_s": { + "type": "number", + "minimum": 0.1, + "maximum": 30.0, + "description": "Total requested movement time in seconds. Default 1, maximum 30.", + }, + }, + "required": ["direction"], + }, + "execute": _drive, + } + + +def get_lumabot_sequence_tool(): + return { + "name": "lumabot_sequence", + "description": ( + "Schedule one ordered physical LumaBot movement plan from a multi-step user request. " + "Use this once when movements must happen in order, such as drive, then turn, then " + "drive another direction. Each step fully runs before the next begins. The scheduler " + "returns immediately, renews short watchdog leases in the background, and executes " + "the supplied steps exactly once. Do not also call lumabot_drive for the same request " + "and never repeat the sequence after entire_request_scheduled=true. Left/right spin " + "in place; a 180-degree turn is approximate because the robot has no wheel encoders. " + "After acceptance, reply in one playful sentence under 12 words rather than " + "mechanically listing every step." + ), + "inputSchema": { + "type": "object", + "properties": { + "steps": { + "type": "array", + "description": "Ordered movement steps; maximum 10 and 30 seconds total.", + "items": { + "type": "object", + "properties": { + "direction": { + "type": "string", + "enum": ["forward", "backward", "left", "right"], + }, + "speed": {"type": "number", "minimum": 0.1, "maximum": 1.0}, + "duration_s": {"type": "number", "minimum": 0.1, "maximum": 30.0}, + }, + "required": ["direction"], + }, + } + }, + "required": ["steps"], + }, + "execute": _sequence, + } + + +def get_lumabot_stop_tool(): + return { + "name": "lumabot_stop", + "description": ( + "Immediately stop LumaBot and release both motors. Use whenever the user asks the " + "robot not to move, to stop now, or to cancel current movement. Confirm the stop " + "briefly and naturally without reciting internal motor values." + ), + "inputSchema": {"type": "object", "properties": {}}, + "execute": lambda inputs: SCHEDULER.stop(), + } diff --git a/tools/lumabot/status.py b/tools/lumabot/status.py new file mode 100644 index 0000000..22ac467 --- /dev/null +++ b/tools/lumabot/status.py @@ -0,0 +1,19 @@ +"""Read-only LumaBot status tool.""" + +from tools.lumabot import client + + +def get_lumabot_status_tool(): + return { + "name": "lumabot_status", + "description": ( + "Read LumaBot's live distance, movement mode, motor outputs, motor readiness, " + "battery percentage and voltage, camera availability, and daemon uptime. Use this " + "for questions about battery life, distance, whether the robot is moving, or " + "hardware readiness. After reading status, answer in concise, human-friendly " + "language using only the fields relevant to the user's question; do not dump raw " + "JSON. Clearly say when a requested reading is unavailable." + ), + "inputSchema": {"type": "object", "properties": {}}, + "execute": lambda inputs: client.get_status(), + } From 80851b49176bc081d9a3481d10b773acd2b7b5a1 Mon Sep 17 00:00:00 2001 From: Patrick Kearney Date: Thu, 30 Jul 2026 21:23:25 -0400 Subject: [PATCH 3/9] Add focused LumaBot mode across interfaces --- agent.py | 57 +++++++++++++-- core/chat_store.py | 36 ++++++++++ core/commands.py | 37 +++++++++- core/runtime_config.py | 8 ++- core/telegram_commands.py | 29 +++++++- docs/lumabot_pi_setup.md | 14 ++++ surfaces/cli.py | 5 ++ surfaces/web.py | 30 ++++++++ tests/test_lumabot_mode.py | 138 ++++++++++++++++++++++++++++++++++++ tests/test_lumabot_tools.py | 1 + tools/lumabot/motion.py | 5 +- web/css/style.css | 50 +++++++++++++ web/index.html | 5 ++ web/js/app.js | 33 ++++++++- 14 files changed, 436 insertions(+), 12 deletions(-) create mode 100644 tests/test_lumabot_mode.py diff --git a/agent.py b/agent.py index baa64b2..56b369a 100644 --- a/agent.py +++ b/agent.py @@ -132,6 +132,8 @@ def __init__(self, verbose=False, status_callback=None, check_interrupt=None, di self._tools_schema_cache = {} self._system_prompt_cache = {} self._system_message_cache = {} + self.runtime_profile = None + self._active_tool_groups = None # Initialize the LLM provider client (Ollama/Anthropic/OpenAI/xAI). # Kept on `self.ollama` for backwards compatibility — every client @@ -516,15 +518,51 @@ def _apply_pending_guidance(self) -> None: }) ) + def set_runtime_profile(self, profile=None): + """Select a focused prompt/tool profile for the next turn.""" + if profile not in {None, "lumabot"}: + raise ValueError(f"Unknown runtime profile: {profile}") + if self.runtime_profile == profile: + return + self.runtime_profile = profile + self._active_tool_groups = ("lumabot",) if profile == "lumabot" else None + self._system_prompt_cache.clear() + self._system_message_cache.clear() + + def _lumabot_system_prompt(self): + tool_names = ", ".join( + sorted(tool["name"] for tool in self.registry.list(groups={"lumabot"})) + ) + return ( + "You are Lumi operating the owner's physical LumaBot.\n" + f"Your tools: {tool_names}\n" + "ONLY use the listed LumaBot tools. Never invent tool names.\n\n" + "Interpret the user's natural-language intent yourself and call the appropriate " + "structured tool; there is no phrase parser. Use lumabot_drive once for one " + "continuous movement, lumabot_sequence once for an ordered multi-step request, " + "lumabot_stop to stop, and lumabot_status for hardware or battery questions. " + "Never repeat movement after a result says entire_request_scheduled=true. " + "Treat returned safety and readiness fields as authoritative and never claim " + "obstacle protection is active when it is not. Autonomous patrol is unavailable " + "until a patrol tool is exposed and the distance sensor is ready; never imitate " + "patrol with an indefinite drive command. After every tool result, give a " + "brief natural response. Successful movement replies should be one playful " + "sentence under 12 words unless the user asks for details." + ) + def build_system_prompt(self, extra_instructions=None, context_instructions=None): extra = (extra_instructions or "").strip() context = (context_instructions or "").strip() - cache_key = (extra, context) + cache_key = (self.runtime_profile, extra, context) cached = self._system_prompt_cache.get(cache_key) if cached is not None: return cached - prompt = self._system_prompt_prefix + prompt = ( + self._lumabot_system_prompt() + if self.runtime_profile == "lumabot" + else self._system_prompt_prefix + ) if extra: prompt += ( "\n\nPersonality override for this Telegram user:\n" @@ -545,7 +583,7 @@ def build_system_prompt(self, extra_instructions=None, context_instructions=None def build_system_message(self, extra_instructions=None, context_instructions=None): extra = (extra_instructions or "").strip() context = (context_instructions or "").strip() - cache_key = (extra, context) + cache_key = (self.runtime_profile, extra, context) cached = self._system_message_cache.get(cache_key) if cached is not None: return dict(cached) @@ -606,20 +644,29 @@ def get_available_tools(self): return self.registry.list() def execute_tool(self, tool_name, inputs): + if self._active_tool_groups: + tool = self.registry.get(tool_name) + if not tool or tool.get("group") not in self._active_tool_groups: + return { + "success": False, + "error": f"{tool_name} is unavailable in {self.runtime_profile} mode", + "toolName": tool_name, + } return self.registry.execute(tool_name, inputs) def get_code_index_status(self): return self.code_index.status() def get_tools_for_llm(self, groups=None): - group_key = tuple(sorted(groups or [])) + effective_groups = self._active_tool_groups if groups is None else groups + group_key = tuple(sorted(effective_groups or [])) cache_key = (self.registry.version, group_key) cached = self._tools_schema_cache.get(cache_key) if cached is not None: return self._filter_role_denied_tools(cached) result = [] - group_filter = set(groups or []) + group_filter = set(effective_groups or []) for tool_name in self.registry.tools.keys(): tool = self.registry.get(tool_name) if not tool.get("llm_exposed", True): diff --git a/core/chat_store.py b/core/chat_store.py index 4deaeb3..1db9a7e 100644 --- a/core/chat_store.py +++ b/core/chat_store.py @@ -49,6 +49,13 @@ def _connect(): updated_at TEXT NOT NULL ) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS chat_runtime_modes ( + chat_id TEXT PRIMARY KEY, + lumabot_enabled INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL + ) + """) columns = {row["name"] for row in conn.execute("PRAGMA table_info(conversations)")} if "owner_id" not in columns: conn.execute("ALTER TABLE conversations ADD COLUMN owner_id TEXT") @@ -214,6 +221,7 @@ def delete_chat(chat_id: str, owner_id: str | None = None) -> bool: ) if cursor.rowcount > 0: conn.execute("DELETE FROM chat_workspaces WHERE chat_id = ?", (chat_id,)) + conn.execute("DELETE FROM chat_runtime_modes WHERE chat_id = ?", (chat_id,)) conn.commit() conn.close() return cursor.rowcount > 0 @@ -251,6 +259,34 @@ def get_chat_workspace(chat_id: str, owner_id: str | None = None) -> str | None: return row["workspace_path"] if row else None +def set_chat_lumabot_mode(chat_id: str, enabled: bool) -> None: + """Persist the focused LumaBot profile for one conversation.""" + if not chat_id: + return + conn = _connect() + conn.execute( + "INSERT INTO chat_runtime_modes (chat_id, lumabot_enabled, updated_at) " + "VALUES (?, ?, ?) ON CONFLICT(chat_id) DO UPDATE SET " + "lumabot_enabled = excluded.lumabot_enabled, updated_at = excluded.updated_at", + (str(chat_id), int(bool(enabled)), datetime.now().isoformat()), + ) + conn.commit() + conn.close() + + +def get_chat_lumabot_mode(chat_id: str | None) -> bool: + """Return whether this conversation uses the focused LumaBot profile.""" + if not chat_id: + return False + conn = _connect() + row = conn.execute( + "SELECT lumabot_enabled FROM chat_runtime_modes WHERE chat_id = ?", + (str(chat_id),), + ).fetchone() + conn.close() + return bool(row["lumabot_enabled"]) if row else False + + def list_known_workspaces(limit: int = 10) -> list[str]: """Distinct workspace paths any chat has used, most recently used first.""" conn = _connect() diff --git a/core/commands.py b/core/commands.py index 838de54..f6e3f64 100644 --- a/core/commands.py +++ b/core/commands.py @@ -5,9 +5,20 @@ import sys from pathlib import Path -from core.chat_store import delete_chat, list_chats, load_chat, make_title, new_chat_id, save_chat, set_active_chat +from core.chat_store import ( + delete_chat, + get_chat_lumabot_mode, + list_chats, + load_chat, + make_title, + new_chat_id, + save_chat, + set_active_chat, + set_chat_lumabot_mode, +) from core.app_runtime_config import get_app_runtime_config, save_app_runtime_config from core.identity import CLI_USER_ID +from core.runtime_config import apply_user_runtime from core.cli import BOLD, CYAN, DIM, GREEN, RED, RESET, YELLOW, _c, render_storage_meter from core.menu import select_menu @@ -25,6 +36,7 @@ def handle_command(command: str, agent, session: dict) -> bool: "/status": cmd_status, "/config": cmd_config, "/clear": cmd_clear, + "/lumabot": cmd_lumabot, } handler = handlers.get(cmd) @@ -52,6 +64,7 @@ def cmd_help(args: str, agent, session: dict): {_c(CYAN, '/config')} View current configuration {_c(CYAN, '/config set ')} Update a config value {_c(CYAN, '/clear')} Clear the screen + {_c(CYAN, '/lumabot on|off')} Toggle focused robot-control mode """) @@ -96,7 +109,7 @@ def _chats_resume(chat_id: str, agent, session: dict): _auto_save(agent, session) # Load the resumed conversation - agent.messages = agent.apply_runtime_overrides(messages=chat["messages"]) + agent.messages = chat["messages"] session["chat_id"] = chat["id"] session["title"] = chat["title"] session["first_message_sent"] = True @@ -105,6 +118,7 @@ def _chats_resume(chat_id: str, agent, session: dict): session["chat_id"], scope=session.get("active_chat_scope"), ) + apply_user_runtime(agent, session, CLI_USER_ID, surface="cli") print(_c(GREEN, f" Resumed: {chat['title']}")) print(_c(DIM, f" {len(chat['messages'])} messages loaded.\n")) @@ -135,6 +149,7 @@ def cmd_new(args: str, agent, session: dict): session["chat_id"], scope=session.get("active_chat_scope"), ) + apply_user_runtime(agent, session, CLI_USER_ID, surface="cli") print(_c(GREEN, " New conversation started.\n")) @@ -270,6 +285,24 @@ def cmd_clear(args: str, agent, session: dict): os.system("cls" if sys.platform == "win32" else "clear") +def cmd_lumabot(args: str, agent, session: dict): + """Toggle the focused LumaBot profile for this CLI conversation.""" + value = args.strip().lower() + current = get_chat_lumabot_mode(session.get("chat_id")) + if not value or value == "status": + print(_c(CYAN, f" LumaBot mode: {'ON' if current else 'OFF'}\n")) + return + if value not in {"on", "off"}: + print(_c(RED, " Usage: /lumabot on|off|status")) + return + + enabled = value == "on" + set_chat_lumabot_mode(session.get("chat_id"), enabled) + apply_user_runtime(agent, session, CLI_USER_ID, surface="cli") + state = "ON — only robot tools are available." if enabled else "OFF — full LumaKit restored." + print(_c(GREEN, f" LumaBot mode {state}\n")) + + def _auto_save(agent, session: dict): """Save the current conversation if it has content.""" if not session.get("first_message_sent"): diff --git a/core/runtime_config.py b/core/runtime_config.py index 88c39de..f57debe 100644 --- a/core/runtime_config.py +++ b/core/runtime_config.py @@ -5,6 +5,7 @@ import os from core.app_runtime_config import get_app_runtime_config +from core.chat_store import get_chat_lumabot_mode from core.telegram_state import OWNER_CONFIG, OWNER_ID, _get_user_config @@ -68,13 +69,17 @@ def apply_user_runtime(agent, session, user_id, surface=None): refresh_client() user_cfg = _get_user_config(user_id) personality_prompt = user_cfg.get("personality_prompt") or None - context_instructions = _surface_instructions(surface) + lumabot_enabled = get_chat_lumabot_mode(session.get("chat_id")) + context_instructions = "" if lumabot_enabled else _surface_instructions(surface) config = get_effective_config_for_user( user_id=user_id, default_model=agent.default_model, default_fallback=agent.default_fallback_model, local_model=agent.local_model, ) + set_profile = getattr(agent, "set_runtime_profile", None) + if callable(set_profile): + set_profile("lumabot" if lumabot_enabled else None) agent.apply_runtime_overrides( messages=agent.messages, @@ -85,6 +90,7 @@ def apply_user_runtime(agent, session, user_id, surface=None): ) session["messages"] = agent.messages + session["lumabot_mode"] = lumabot_enabled def _surface_instructions(surface): diff --git a/core/telegram_commands.py b/core/telegram_commands.py index 7c72283..acc4bed 100644 --- a/core/telegram_commands.py +++ b/core/telegram_commands.py @@ -5,6 +5,7 @@ from pathlib import Path from core.chat_store import ( + get_chat_lumabot_mode, list_chats, list_known_workspaces, load_chat, @@ -12,6 +13,7 @@ new_chat_id, save_chat, set_active_chat, + set_chat_lumabot_mode, ) from core.app_runtime_config import get_app_runtime_config, save_app_runtime_config from core.identity import chat_owner_id @@ -295,6 +297,7 @@ def handle_telegram_command(text, agent, session, chat_id, speech_client): lines.append("/model - choose the owner's Telegram model settings") lines.append("/workspace - pick or set the working directory (alias /dir)") lines.append("/safemode - toggle full machine access (approvals + file sandbox)") + lines.append("/lumabot - toggle focused robot-control mode") lines.append("/users - list authorized users") lines.append("/personality - view or change your Telegram personality override") send_message("\n".join(lines)) @@ -339,6 +342,7 @@ def handle_telegram_command(text, agent, session, chat_id, speech_client): agent.messages = [system_msg] if system_msg else [] session["messages"] = agent.messages set_active_chat(owner_id, session["chat_id"]) + apply_chat_runtime(agent, session, chat_id) send_message("New conversation started.") return True @@ -364,6 +368,7 @@ def handle_telegram_command(text, agent, session, chat_id, speech_client): f"\nLocal model: {owner_cfg['local_model'] or 'not set'}" f"\nWorkspace: {agent.workspace_root}" f"\nSafe mode: {'on' if get_app_runtime_config().get('safe_mode', True) else 'off'}" + f"\nLumaBot mode: {'on' if get_chat_lumabot_mode(session.get('chat_id')) else 'off'}" ) user_cfg = _get_user_config(chat_id) send_message( @@ -385,10 +390,32 @@ def handle_telegram_command(text, agent, session, chat_id, speech_client): ) return True - if cmd in {"/adduser", "/removeuser", "/users", "/model", "/role", "/approve", "/deny", "/workspace", "/dir", "/safemode"} and str(chat_id) != str(OWNER_ID): + if cmd in {"/adduser", "/removeuser", "/users", "/model", "/role", "/approve", "/deny", "/workspace", "/dir", "/safemode", "/lumabot"} and str(chat_id) != str(OWNER_ID): send_message("This command is owner-only.") return True + if cmd == "/lumabot" and str(chat_id) == str(OWNER_ID): + value = args.strip().lower() + current = get_chat_lumabot_mode(session.get("chat_id")) + if not value or value == "status": + send_message( + f"LumaBot mode is {'ON' if current else 'OFF'}.\n" + "Use /lumabot on or /lumabot off." + ) + return True + if value not in {"on", "off"}: + send_message("Usage: /lumabot on|off|status") + return True + enabled = value == "on" + set_chat_lumabot_mode(session.get("chat_id"), enabled) + apply_chat_runtime(agent, session, chat_id) + send_message( + "LumaBot mode ON. Only robot tools are available." + if enabled + else "LumaBot mode OFF. Full LumaKit is restored." + ) + return True + if cmd == "/safemode" and str(chat_id) == str(OWNER_ID): cfg = get_app_runtime_config().copy() current = bool(cfg.get("safe_mode", True)) diff --git a/docs/lumabot_pi_setup.md b/docs/lumabot_pi_setup.md index 8000150..c844534 100644 --- a/docs/lumabot_pi_setup.md +++ b/docs/lumabot_pi_setup.md @@ -54,6 +54,20 @@ The LumaKit `lumabot_status`, `lumabot_drive`, `lumabot_sequence`, and are owner-only. Natural-language intent and the final acknowledgement remain part of LumaKit's normal LLM tool-result cycle; there is no phrase parser. +Enable focused robot control for an individual conversation: + +```text +Telegram: /lumabot on +CLI: /lumabot on +Web: click the LumaBot toggle in the top bar +``` + +This replaces the full agent prompt and 98-tool catalog with a compact robot +prompt and only the four LumaBot tools. The setting follows that saved +conversation and `/lumabot off` restores full LumaKit. “Park” currently stops +scheduled movement and coasts both motors. Autonomous patrol remains +unavailable until the distance sensor is connected and verified. + After updating either checkout: ```bash diff --git a/surfaces/cli.py b/surfaces/cli.py index f295c66..9d08075 100644 --- a/surfaces/cli.py +++ b/surfaces/cli.py @@ -14,7 +14,9 @@ from core.cli import render_storage_meter from core.commands import handle_command from core.identity import CLI_USER_ID +from core.interface_context import set_interface from core.paths import get_repo_root +from core.runtime_config import apply_user_runtime from core.service import LumaKitService, Surface from tools.memory.memory_tools import set_active_user as set_memory_active_user @@ -121,6 +123,8 @@ def main(argv: list[str] | None = None): "active_chat_scope": workspace_scope, } set_active_chat(CLI_USER_ID, session["chat_id"], scope=workspace_scope) + set_interface("cli", CLI_USER_ID) + apply_user_runtime(agent, session, CLI_USER_ID, surface="cli") print("\n=== LumaKit CLI ===") health = agent.storage.check_health() @@ -193,6 +197,7 @@ def main(argv: list[str] | None = None): continue try: + apply_user_runtime(agent, session, CLI_USER_ID, surface="cli") response = agent.ask_llm(user_input) content = response.get("message", {}).get("content", "") if content: diff --git a/surfaces/web.py b/surfaces/web.py index 76045e9..4b0334e 100644 --- a/surfaces/web.py +++ b/surfaces/web.py @@ -44,6 +44,7 @@ new_chat_id, save_chat, set_active_chat, + set_chat_lumabot_mode, set_chat_workspace, ) from core import notifications as notification_log @@ -1222,11 +1223,13 @@ def send_sync(msg: dict): "chat_id": session["chat_id"], "title": session["title"], "messages": session["display_messages"], + "lumabot_mode": bool(session.get("lumabot_mode")), **_workspace_payload(session["workspace_path"]), }) else: await ws.send_json({ "type": "workspace_updated", + "lumabot_mode": bool(session.get("lumabot_mode")), **_workspace_payload(session["workspace_path"]), }) @@ -1287,6 +1290,7 @@ async def run_agent_request(text: str, image_data: bytes | None = None): "run_error": run_error, "streamed": bool(response.get("streamed")), "messages": session["display_messages"], + "lumabot_mode": bool(session.get("lumabot_mode")), **_workspace_payload(session["workspace_path"]), }) except Exception as e: @@ -1383,6 +1387,30 @@ async def run_agent_request(text: str, image_data: bytes | None = None): }) continue + if msg_type == "lumabot_mode": + if agent_task and not agent_task.done(): + await ws.send_json({ + "type": "error", + "text": "Finish or stop the current run before changing LumaBot mode.", + }) + continue + enabled = data.get("enabled") + if not isinstance(enabled, bool): + await ws.send_json({"type": "error", "text": "Invalid LumaBot mode value."}) + continue + set_chat_lumabot_mode(session["chat_id"], enabled) + _prepare_web_turn(agent, session) + await ws.send_json({ + "type": "lumabot_mode", + "enabled": enabled, + "text": ( + "LumaBot mode ON. Only robot tools are available." + if enabled + else "LumaBot mode OFF. Full LumaKit is restored." + ), + }) + continue + # Load a specific chat if msg_type == "load_chat": if agent_task and not agent_task.done(): @@ -1408,6 +1436,7 @@ async def run_agent_request(text: str, image_data: bytes | None = None): "chat_id": session["chat_id"], "title": session["title"], "messages": session["display_messages"], + "lumabot_mode": bool(session.get("lumabot_mode")), **_workspace_payload(session["workspace_path"]), }) else: @@ -1435,6 +1464,7 @@ async def run_agent_request(text: str, image_data: bytes | None = None): "chat_id": session["chat_id"], "title": "", "messages": [], + "lumabot_mode": bool(session.get("lumabot_mode")), **_workspace_payload(session["workspace_path"]), }) continue diff --git a/tests/test_lumabot_mode.py b/tests/test_lumabot_mode.py new file mode 100644 index 0000000..9716c58 --- /dev/null +++ b/tests/test_lumabot_mode.py @@ -0,0 +1,138 @@ +"""Focused LumaBot mode stays LLM-driven and scoped to one conversation.""" + +from agent import Agent +from tool_registry import ToolRegistry + + +def _minimal_agent(): + agent = Agent.__new__(Agent) + agent.registry = ToolRegistry() + agent.registry.register( + { + "name": "lumabot_drive", + "description": "Drive the robot.", + "inputSchema": {"type": "object", "properties": {}}, + "execute": lambda inputs: {"accepted": True}, + }, + group="lumabot", + ) + agent.registry.register( + { + "name": "read_file", + "description": "Read a file.", + "inputSchema": {"type": "object", "properties": {}}, + "execute": lambda inputs: {"content": ""}, + }, + group="repo", + ) + agent.runtime_profile = None + agent._active_tool_groups = None + agent._system_prompt_prefix = "FULL LUMAKIT PROMPT" + agent._system_prompt_cache = {} + agent._system_message_cache = {} + agent._tools_schema_cache = {} + agent._tools_schema_cache_version = None + return agent + + +def test_mode_persists_per_conversation(tmp_path, monkeypatch): + from core import chat_store + + monkeypatch.setattr(chat_store, "DB_PATH", tmp_path / "memory.db") + assert chat_store.get_chat_lumabot_mode("chat-a") is False + + chat_store.set_chat_lumabot_mode("chat-a", True) + assert chat_store.get_chat_lumabot_mode("chat-a") is True + assert chat_store.get_chat_lumabot_mode("chat-b") is False + + chat_store.set_chat_lumabot_mode("chat-a", False) + assert chat_store.get_chat_lumabot_mode("chat-a") is False + + +def test_mode_exposes_only_lumabot_tools_with_compact_prompt(): + agent = _minimal_agent() + agent.set_runtime_profile("lumabot") + + names = [tool["function"]["name"] for tool in agent.get_tools_for_llm()] + assert names == ["lumabot_drive"] + prompt = agent.build_system_prompt() + assert "physical LumaBot" in prompt + assert "natural-language intent yourself" in prompt + assert "FULL LUMAKIT PROMPT" not in prompt + + +def test_mode_blocks_hidden_tool_execution(): + agent = _minimal_agent() + agent.set_runtime_profile("lumabot") + + result = agent.execute_tool("read_file", {}) + assert result["success"] is False + assert "unavailable in lumabot mode" in result["error"] + + +def test_turning_mode_off_restores_full_tool_catalog(): + agent = _minimal_agent() + agent.set_runtime_profile("lumabot") + agent.set_runtime_profile(None) + + names = {tool["function"]["name"] for tool in agent.get_tools_for_llm()} + assert names == {"lumabot_drive", "read_file"} + assert agent.build_system_prompt() == "FULL LUMAKIT PROMPT" + + +def test_cli_toggle_uses_shared_conversation_mode(monkeypatch, capsys): + from core import commands + + saved = [] + refreshed = [] + monkeypatch.setattr(commands, "get_chat_lumabot_mode", lambda chat_id: False) + monkeypatch.setattr( + commands, + "set_chat_lumabot_mode", + lambda chat_id, enabled: saved.append((chat_id, enabled)), + ) + monkeypatch.setattr( + commands, + "apply_user_runtime", + lambda agent, session, user_id, surface=None: refreshed.append(surface), + ) + + commands.cmd_lumabot("on", object(), {"chat_id": "cli-chat"}) + assert saved == [("cli-chat", True)] + assert refreshed == ["cli"] + assert "LumaBot mode ON" in capsys.readouterr().out + + +def test_telegram_toggle_is_owner_only_and_uses_shared_mode(monkeypatch): + from core import telegram_commands + + sent = [] + saved = [] + refreshed = [] + monkeypatch.setattr(telegram_commands, "OWNER_ID", "owner-chat") + monkeypatch.setattr(telegram_commands, "send_message", sent.append) + monkeypatch.setattr(telegram_commands, "get_chat_lumabot_mode", lambda chat_id: False) + monkeypatch.setattr( + telegram_commands, + "set_chat_lumabot_mode", + lambda chat_id, enabled: saved.append((chat_id, enabled)), + ) + monkeypatch.setattr( + telegram_commands, + "apply_chat_runtime", + lambda agent, session, chat_id: refreshed.append(chat_id), + ) + + session = {"chat_id": "shared-chat"} + handled = telegram_commands.handle_telegram_command( + "/lumabot on", object(), session, "owner-chat", None + ) + assert handled is True + assert saved == [("shared-chat", True)] + assert refreshed == ["owner-chat"] + assert sent[-1].startswith("LumaBot mode ON") + + telegram_commands.handle_telegram_command( + "/lumabot on", object(), session, "someone-else", None + ) + assert sent[-1] == "This command is owner-only." diff --git a/tests/test_lumabot_tools.py b/tests/test_lumabot_tools.py index 29fc008..50fd370 100644 --- a/tests/test_lumabot_tools.py +++ b/tests/test_lumabot_tools.py @@ -123,4 +123,5 @@ def test_stop_and_status_return_daemon_results(registry, monkeypatch): monkeypatch.setattr(client, "get_status", lambda: {"battery_pct": 75.0}) assert registry.execute("lumabot_stop", {})["data"]["stopped"] is True assert registry.execute("lumabot_status", {})["data"]["battery_pct"] == 75.0 + assert "park" in registry.get("lumabot_stop")["description"].lower() assert "human-friendly" in registry.get("lumabot_status")["description"] diff --git a/tools/lumabot/motion.py b/tools/lumabot/motion.py index 29300c8..3e79b2b 100644 --- a/tools/lumabot/motion.py +++ b/tools/lumabot/motion.py @@ -256,8 +256,9 @@ def get_lumabot_stop_tool(): "name": "lumabot_stop", "description": ( "Immediately stop LumaBot and release both motors. Use whenever the user asks the " - "robot not to move, to stop now, or to cancel current movement. Confirm the stop " - "briefly and naturally without reciting internal motor values." + "robot not to move, to stop now, to park, or to cancel current movement. Parking " + "currently means stopping, cancelling scheduled motion, and coasting both motors. " + "Confirm the stop briefly and naturally without reciting internal motor values." ), "inputSchema": {"type": "object", "properties": {}}, "execute": lambda inputs: SCHEDULER.stop(), diff --git a/web/css/style.css b/web/css/style.css index ba82fc4..c54b111 100644 --- a/web/css/style.css +++ b/web/css/style.css @@ -340,6 +340,46 @@ body { min-width: 0; } +.lumabot-mode-btn { + display: inline-flex; + align-items: center; + gap: 7px; + height: 28px; + padding: 0 11px; + border: 1px solid rgba(176, 124, 216, 0.28); + border-radius: 999px; + background: rgba(26, 20, 40, 0.55); + color: var(--text-secondary); + font-size: 11px; + font-weight: 700; + cursor: pointer; + transition: 0.15s ease; +} + +.lumabot-mode-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--text-muted); +} + +.lumabot-mode-btn.active { + color: #fff; + border-color: rgba(74, 222, 128, 0.65); + background: rgba(34, 197, 94, 0.18); + box-shadow: 0 0 14px rgba(74, 222, 128, 0.18); +} + +.lumabot-mode-btn.active .lumabot-mode-dot { + background: var(--success); + box-shadow: 0 0 8px rgba(74, 222, 128, 0.8); +} + +.lumabot-mode-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + .workspace-form { display: flex; align-items: center; @@ -2926,6 +2966,16 @@ body { display: none; } + .lumabot-mode-btn { + width: 28px; + padding: 0; + justify-content: center; + } + + .lumabot-mode-text { + display: none; + } + .message.user .bubble { max-width: 90%; } diff --git a/web/index.html b/web/index.html index a059eb3..4c238b8 100644 --- a/web/index.html +++ b/web/index.html @@ -44,6 +44,11 @@ New Chat
+
diff --git a/web/js/app.js b/web/js/app.js index 390981b..ba59f77 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -22,6 +22,7 @@ const $modelBadge = document.getElementById('model-badge'); const $modelBadgeText = $modelBadge?.querySelector('.model-badge-text') || $modelBadge; const $statusLabel = document.getElementById('status-label'); const $statusDot = document.getElementById('status-dot'); +const $lumabotModeBtn = document.getElementById('lumabot-mode-btn'); const $workspaceForm = document.getElementById('workspace-form'); const $workspaceInput = document.getElementById('workspace-input'); const $workspaceBrowse = document.getElementById('workspace-browse'); @@ -50,6 +51,7 @@ let isWorking = false; let currentView = 'chat'; let currentChatId = null; let currentWorkspacePath = ''; +let lumabotMode = false; let statusEl = null; let activityCardEl = null; let activityTitleEl = null; @@ -229,6 +231,7 @@ function setWorking(working) { if ($workspaceInput) $workspaceInput.disabled = working; if ($workspaceBrowse) $workspaceBrowse.disabled = working; if ($photoBtn) $photoBtn.disabled = working; + if ($lumabotModeBtn) $lumabotModeBtn.disabled = working; // Type /stop to interrupt — no UI toggle needed } @@ -249,6 +252,22 @@ function setWorkspace(path, displayPath) { } } +function setLumabotMode(enabled) { + lumabotMode = !!enabled; + $lumabotModeBtn?.classList.toggle('active', lumabotMode); + $lumabotModeBtn?.setAttribute('aria-pressed', String(lumabotMode)); + if ($lumabotModeBtn) { + $lumabotModeBtn.title = lumabotMode + ? 'LumaBot mode is on — click to restore full LumaKit' + : 'Toggle focused LumaBot control mode'; + } + if (!requiresModelSetup) { + $input.placeholder = lumabotMode + ? 'Tell LumaBot what to do...' + : 'Message Lumi... (type /stop to interrupt)'; + } +} + function showWorkspaceError(message) { if ($workspaceInput) { $workspaceInput.classList.add('error'); @@ -275,7 +294,7 @@ function applySetupState() { switchView('settings'); } } else { - $input.placeholder = 'Message Lumi... (type /stop to interrupt)'; + setLumabotMode(lumabotMode); $setupOverlay.classList.add('hidden'); } } @@ -2330,6 +2349,12 @@ const ws = new WS({ workspace_updated(data) { setWorkspace(data.workspace_path, data.workspace_display); + if (typeof data.lumabot_mode === 'boolean') setLumabotMode(data.lumabot_mode); + }, + + lumabot_mode(data) { + setLumabotMode(data.enabled); + if (data.text) showStatus(data.text); }, workspace_error(data) { @@ -2349,6 +2374,7 @@ const ws = new WS({ }, chat_loaded(data) { + if (typeof data.lumabot_mode === 'boolean') setLumabotMode(data.lumabot_mode); const previousChatId = currentChatId; if (data.chat_id === previousChatId && isWorking) { currentChatId = data.chat_id; @@ -2609,6 +2635,11 @@ function sendMessage() { $sendBtn.onclick = sendMessage; +$lumabotModeBtn?.addEventListener('click', () => { + if (isWorking) return; + ws.send({ type: 'lumabot_mode', enabled: !lumabotMode }); +}); + document.querySelectorAll('.suggestion-card').forEach(card => { card.addEventListener('click', () => { const prompt = card.getAttribute('data-prompt') || ''; From 2c85b3d4114ea6ed4e426ebc59f4ee50731a4302 Mon Sep 17 00:00:00 2001 From: Patrick Kearney Date: Thu, 30 Jul 2026 21:45:24 -0400 Subject: [PATCH 4/9] Add instant no-LLM LumaBot remote control --- agent.py | 23 +++++- core/chat_store.py | 58 ++++++++++++--- core/commands.py | 47 ++++++++---- core/runtime_config.py | 16 +++-- core/telegram_api.py | 11 ++- core/telegram_commands.py | 95 +++++++++++++++++++----- core/telegram_io.py | 4 +- docs/lumabot_pi_setup.md | 38 +++++++--- surfaces/cli.py | 20 +++++- surfaces/telegram.py | 97 ++++++++++++++++++++++++- surfaces/web.py | 64 +++++++++++++---- tests/test_lumabot_mode.py | 144 +++++++++++++++++++++++++++++++------ tools/lumabot/remote.py | 131 +++++++++++++++++++++++++++++++++ web/css/style.css | 138 ++++++++++++++++++++++++++++------- web/index.html | 42 +++++++++-- web/js/app.js | 76 +++++++++++++++----- 16 files changed, 854 insertions(+), 150 deletions(-) create mode 100644 tools/lumabot/remote.py diff --git a/agent.py b/agent.py index 56b369a..c16b070 100644 --- a/agent.py +++ b/agent.py @@ -520,12 +520,17 @@ def _apply_pending_guidance(self) -> None: def set_runtime_profile(self, profile=None): """Select a focused prompt/tool profile for the next turn.""" - if profile not in {None, "lumabot"}: + if profile not in {None, "lumabot", "lumabot_remote"}: raise ValueError(f"Unknown runtime profile: {profile}") if self.runtime_profile == profile: return self.runtime_profile = profile - self._active_tool_groups = ("lumabot",) if profile == "lumabot" else None + if profile == "lumabot": + self._active_tool_groups = ("lumabot",) + elif profile == "lumabot_remote": + self._active_tool_groups = ("__remote_direct_only__",) + else: + self._active_tool_groups = None self._system_prompt_cache.clear() self._system_message_cache.clear() @@ -550,6 +555,14 @@ def _lumabot_system_prompt(self): "sentence under 12 words unless the user asks for details." ) + @staticmethod + def _lumabot_remote_system_prompt(): + return ( + "LumaBot Remote mode is active. Direct structured controls are handled " + "outside the language model. No tools are available in this profile. " + "Tell the user to use the visible controls or /lumabot help." + ) + def build_system_prompt(self, extra_instructions=None, context_instructions=None): extra = (extra_instructions or "").strip() context = (context_instructions or "").strip() @@ -561,7 +574,11 @@ def build_system_prompt(self, extra_instructions=None, context_instructions=None prompt = ( self._lumabot_system_prompt() if self.runtime_profile == "lumabot" - else self._system_prompt_prefix + else ( + self._lumabot_remote_system_prompt() + if self.runtime_profile == "lumabot_remote" + else self._system_prompt_prefix + ) ) if extra: prompt += ( diff --git a/core/chat_store.py b/core/chat_store.py index 1db9a7e..3b85d02 100644 --- a/core/chat_store.py +++ b/core/chat_store.py @@ -53,9 +53,22 @@ def _connect(): CREATE TABLE IF NOT EXISTS chat_runtime_modes ( chat_id TEXT PRIMARY KEY, lumabot_enabled INTEGER NOT NULL DEFAULT 0, + profile TEXT NOT NULL DEFAULT 'off', updated_at TEXT NOT NULL ) """) + mode_columns = { + row["name"] for row in conn.execute("PRAGMA table_info(chat_runtime_modes)") + } + if "profile" not in mode_columns: + conn.execute( + "ALTER TABLE chat_runtime_modes " + "ADD COLUMN profile TEXT NOT NULL DEFAULT 'off'" + ) + conn.execute( + "UPDATE chat_runtime_modes SET profile = " + "CASE WHEN lumabot_enabled = 1 THEN 'agent' ELSE 'off' END" + ) columns = {row["name"] for row in conn.execute("PRAGMA table_info(conversations)")} if "owner_id" not in columns: conn.execute("ALTER TABLE conversations ADD COLUMN owner_id TEXT") @@ -260,31 +273,56 @@ def get_chat_workspace(chat_id: str, owner_id: str | None = None) -> str | None: def set_chat_lumabot_mode(chat_id: str, enabled: bool) -> None: - """Persist the focused LumaBot profile for one conversation.""" + """Backward-compatible agent/off toggle.""" + set_chat_lumabot_profile(chat_id, "agent" if enabled else "off") + + +def get_chat_lumabot_mode(chat_id: str | None) -> bool: + """Backward-compatible check for Agent mode.""" + return get_chat_lumabot_profile(chat_id) == "agent" + + +def set_chat_lumabot_profile(chat_id: str, profile: str) -> None: + """Persist off, agent, or direct remote control for one conversation.""" if not chat_id: return + profile = str(profile or "").lower() + if profile not in {"off", "agent", "remote"}: + raise ValueError("LumaBot profile must be off, agent, or remote") conn = _connect() conn.execute( - "INSERT INTO chat_runtime_modes (chat_id, lumabot_enabled, updated_at) " - "VALUES (?, ?, ?) ON CONFLICT(chat_id) DO UPDATE SET " - "lumabot_enabled = excluded.lumabot_enabled, updated_at = excluded.updated_at", - (str(chat_id), int(bool(enabled)), datetime.now().isoformat()), + "INSERT INTO chat_runtime_modes " + "(chat_id, lumabot_enabled, profile, updated_at) VALUES (?, ?, ?, ?) " + "ON CONFLICT(chat_id) DO UPDATE SET " + "lumabot_enabled = excluded.lumabot_enabled, " + "profile = excluded.profile, updated_at = excluded.updated_at", + ( + str(chat_id), + int(profile == "agent"), + profile, + datetime.now().isoformat(), + ), ) conn.commit() conn.close() -def get_chat_lumabot_mode(chat_id: str | None) -> bool: - """Return whether this conversation uses the focused LumaBot profile.""" +def get_chat_lumabot_profile(chat_id: str | None) -> str: + """Return off, agent, or remote for this conversation.""" if not chat_id: - return False + return "off" conn = _connect() row = conn.execute( - "SELECT lumabot_enabled FROM chat_runtime_modes WHERE chat_id = ?", + "SELECT profile, lumabot_enabled FROM chat_runtime_modes WHERE chat_id = ?", (str(chat_id),), ).fetchone() conn.close() - return bool(row["lumabot_enabled"]) if row else False + if not row: + return "off" + profile = str(row["profile"] or "").lower() + if profile in {"off", "agent", "remote"}: + return profile + return "agent" if row["lumabot_enabled"] else "off" def list_known_workspaces(limit: int = 10) -> list[str]: diff --git a/core/commands.py b/core/commands.py index f6e3f64..46cad0f 100644 --- a/core/commands.py +++ b/core/commands.py @@ -7,20 +7,21 @@ from core.chat_store import ( delete_chat, - get_chat_lumabot_mode, + get_chat_lumabot_profile, list_chats, load_chat, make_title, new_chat_id, save_chat, set_active_chat, - set_chat_lumabot_mode, + set_chat_lumabot_profile, ) from core.app_runtime_config import get_app_runtime_config, save_app_runtime_config from core.identity import CLI_USER_ID from core.runtime_config import apply_user_runtime from core.cli import BOLD, CYAN, DIM, GREEN, RED, RESET, YELLOW, _c, render_storage_meter from core.menu import select_menu +from tools.lumabot.remote import REMOTE_HELP, execute_remote_command def handle_command(command: str, agent, session: dict) -> bool: @@ -64,7 +65,9 @@ def cmd_help(args: str, agent, session: dict): {_c(CYAN, '/config')} View current configuration {_c(CYAN, '/config set ')} Update a config value {_c(CYAN, '/clear')} Clear the screen - {_c(CYAN, '/lumabot on|off')} Toggle focused robot-control mode + {_c(CYAN, '/lumabot agent')} Natural-language robot control + {_c(CYAN, '/lumabot remote')} Instant structured robot controls + {_c(CYAN, '/lumabot off')} Restore full LumaKit """) @@ -286,21 +289,35 @@ def cmd_clear(args: str, agent, session: dict): def cmd_lumabot(args: str, agent, session: dict): - """Toggle the focused LumaBot profile for this CLI conversation.""" - value = args.strip().lower() - current = get_chat_lumabot_mode(session.get("chat_id")) - if not value or value == "status": - print(_c(CYAN, f" LumaBot mode: {'ON' if current else 'OFF'}\n")) + """Switch profiles or execute one deterministic remote command.""" + parts = args.strip().split(maxsplit=1) + action = parts[0].lower() if parts else "" + current = get_chat_lumabot_profile(session.get("chat_id")) + if not action: + print(_c(CYAN, f" LumaBot mode: {current.upper()}\n\n{REMOTE_HELP}\n")) return - if value not in {"on", "off"}: - print(_c(RED, " Usage: /lumabot on|off|status")) + + if action in {"on", "agent", "remote", "off"}: + profile = "agent" if action == "on" else action + set_chat_lumabot_profile(session.get("chat_id"), profile) + apply_user_runtime(agent, session, CLI_USER_ID, surface="cli") + labels = { + "agent": "AGENT — natural language uses the configured LLM.", + "remote": "REMOTE — structured commands bypass the LLM.", + "off": "OFF — full LumaKit restored.", + } + print(_c(GREEN, f" LumaBot mode {labels[profile]}\n")) + if profile == "remote": + print(f"{REMOTE_HELP}\n") return - enabled = value == "on" - set_chat_lumabot_mode(session.get("chat_id"), enabled) - apply_user_runtime(agent, session, CLI_USER_ID, surface="cli") - state = "ON — only robot tools are available." if enabled else "OFF — full LumaKit restored." - print(_c(GREEN, f" LumaBot mode {state}\n")) + if action not in {"stop", "park", "status", "help"} and current != "remote": + print(_c(RED, " Switch to Remote mode first with /lumabot remote.\n")) + return + result = execute_remote_command(args) + prefix = f"LumaBot mode: {current.upper()}\n" if action == "status" else "" + color = GREEN if result.get("ok") else RED + print(_c(color, f" {prefix}{result['text']}\n")) def _auto_save(agent, session: dict): diff --git a/core/runtime_config.py b/core/runtime_config.py index f57debe..e0685b2 100644 --- a/core/runtime_config.py +++ b/core/runtime_config.py @@ -5,7 +5,7 @@ import os from core.app_runtime_config import get_app_runtime_config -from core.chat_store import get_chat_lumabot_mode +from core.chat_store import get_chat_lumabot_profile from core.telegram_state import OWNER_CONFIG, OWNER_ID, _get_user_config @@ -69,8 +69,10 @@ def apply_user_runtime(agent, session, user_id, surface=None): refresh_client() user_cfg = _get_user_config(user_id) personality_prompt = user_cfg.get("personality_prompt") or None - lumabot_enabled = get_chat_lumabot_mode(session.get("chat_id")) - context_instructions = "" if lumabot_enabled else _surface_instructions(surface) + lumabot_profile = get_chat_lumabot_profile(session.get("chat_id")) + context_instructions = ( + "" if lumabot_profile != "off" else _surface_instructions(surface) + ) config = get_effective_config_for_user( user_id=user_id, default_model=agent.default_model, @@ -79,7 +81,11 @@ def apply_user_runtime(agent, session, user_id, surface=None): ) set_profile = getattr(agent, "set_runtime_profile", None) if callable(set_profile): - set_profile("lumabot" if lumabot_enabled else None) + runtime_profile = { + "agent": "lumabot", + "remote": "lumabot_remote", + }.get(lumabot_profile) + set_profile(runtime_profile) agent.apply_runtime_overrides( messages=agent.messages, @@ -90,7 +96,7 @@ def apply_user_runtime(agent, session, user_id, surface=None): ) session["messages"] = agent.messages - session["lumabot_mode"] = lumabot_enabled + session["lumabot_mode"] = lumabot_profile def _surface_instructions(surface): diff --git a/core/telegram_api.py b/core/telegram_api.py index aea5168..6320193 100644 --- a/core/telegram_api.py +++ b/core/telegram_api.py @@ -27,12 +27,19 @@ def telegram_api(method: str, params: dict[str, Any] | None = None) -> dict[str, return payload -def send_message(text: str, chat_id: str | int): +def send_message( + text: str, + chat_id: str | int, + reply_markup: dict[str, Any] | None = None, +): """Send a text message, splitting it into Telegram-sized chunks.""" first_payload = None while text: chunk, text = text[:4096], text[4096:] - payload = telegram_api("sendMessage", {"chat_id": chat_id, "text": chunk}) + params = {"chat_id": chat_id, "text": chunk} + if reply_markup is not None and first_payload is None: + params["reply_markup"] = reply_markup + payload = telegram_api("sendMessage", params) if first_payload is None: first_payload = payload return first_payload diff --git a/core/telegram_commands.py b/core/telegram_commands.py index acc4bed..9c89470 100644 --- a/core/telegram_commands.py +++ b/core/telegram_commands.py @@ -5,7 +5,7 @@ from pathlib import Path from core.chat_store import ( - get_chat_lumabot_mode, + get_chat_lumabot_profile, list_chats, list_known_workspaces, load_chat, @@ -13,7 +13,7 @@ new_chat_id, save_chat, set_active_chat, - set_chat_lumabot_mode, + set_chat_lumabot_profile, ) from core.app_runtime_config import get_app_runtime_config, save_app_runtime_config from core.identity import chat_owner_id @@ -32,6 +32,52 @@ _sessions, _show_tools, ) +from tools.lumabot.remote import ( + REMOTE_HELP, + execute_remote_action, + execute_remote_command, +) + + +def lumabot_remote_keyboard(): + """Inline buttons with structured callback payloads, not language.""" + return { + "inline_keyboard": [ + [{"text": "▲ Forward", "callback_data": "lbot:drive:forward"}], + [ + {"text": "↶ Left", "callback_data": "lbot:turn:left"}, + {"text": "STOP", "callback_data": "lbot:stop"}, + {"text": "Right ↷", "callback_data": "lbot:turn:right"}, + ], + [{"text": "▼ Reverse", "callback_data": "lbot:drive:backward"}], + [ + {"text": "Turn 180°", "callback_data": "lbot:turn_around"}, + {"text": "Park", "callback_data": "lbot:park"}, + {"text": "Status", "callback_data": "lbot:status"}, + ], + ] + } + + +def handle_lumabot_callback(data: str, session: dict) -> dict: + """Execute one structured Telegram button callback without an LLM.""" + parts = str(data or "").split(":") + if not parts or parts[0] != "lbot": + return {"ok": False, "text": "Unknown LumaBot control."} + profile = get_chat_lumabot_profile(session.get("chat_id")) + action = parts[1] if len(parts) > 1 else "" + if profile != "remote" and action not in {"stop", "park"}: + return {"ok": False, "text": "LumaBot Remote mode is off."} + try: + if action == "drive" and len(parts) == 3: + return execute_remote_action("drive", direction=parts[2]) + if action == "turn" and len(parts) == 3: + return execute_remote_action("turn", direction=parts[2]) + if action in {"turn_around", "stop", "park", "status"} and len(parts) == 2: + return execute_remote_action(action) + except ValueError as error: + return {"ok": False, "text": str(error)} + return {"ok": False, "text": "Unknown LumaBot control."} # --------------------------------------------------------------------------- @@ -297,7 +343,7 @@ def handle_telegram_command(text, agent, session, chat_id, speech_client): lines.append("/model - choose the owner's Telegram model settings") lines.append("/workspace - pick or set the working directory (alias /dir)") lines.append("/safemode - toggle full machine access (approvals + file sandbox)") - lines.append("/lumabot - toggle focused robot-control mode") + lines.append("/lumabot - agent or instant remote control") lines.append("/users - list authorized users") lines.append("/personality - view or change your Telegram personality override") send_message("\n".join(lines)) @@ -368,7 +414,7 @@ def handle_telegram_command(text, agent, session, chat_id, speech_client): f"\nLocal model: {owner_cfg['local_model'] or 'not set'}" f"\nWorkspace: {agent.workspace_root}" f"\nSafe mode: {'on' if get_app_runtime_config().get('safe_mode', True) else 'off'}" - f"\nLumaBot mode: {'on' if get_chat_lumabot_mode(session.get('chat_id')) else 'off'}" + f"\nLumaBot mode: {get_chat_lumabot_profile(session.get('chat_id'))}" ) user_cfg = _get_user_config(chat_id) send_message( @@ -395,24 +441,39 @@ def handle_telegram_command(text, agent, session, chat_id, speech_client): return True if cmd == "/lumabot" and str(chat_id) == str(OWNER_ID): - value = args.strip().lower() - current = get_chat_lumabot_mode(session.get("chat_id")) - if not value or value == "status": + command_parts = args.strip().split(maxsplit=1) + action = command_parts[0].lower() if command_parts else "" + current = get_chat_lumabot_profile(session.get("chat_id")) + if not action: send_message( - f"LumaBot mode is {'ON' if current else 'OFF'}.\n" - "Use /lumabot on or /lumabot off." + f"LumaBot mode: {current.upper()}\n\n{REMOTE_HELP}", + reply_markup=lumabot_remote_keyboard() if current == "remote" else None, ) return True - if value not in {"on", "off"}: - send_message("Usage: /lumabot on|off|status") + + if action in {"on", "agent", "remote", "off"}: + profile = "agent" if action == "on" else action + set_chat_lumabot_profile(session.get("chat_id"), profile) + apply_chat_runtime(agent, session, chat_id) + if profile == "agent": + send_message("LumaBot Agent mode ON. Natural language uses the configured LLM.") + elif profile == "remote": + send_message( + "LumaBot Remote mode ON. These controls bypass the LLM.", + reply_markup=lumabot_remote_keyboard(), + ) + else: + send_message("LumaBot mode OFF. Full LumaKit is restored.") return True - enabled = value == "on" - set_chat_lumabot_mode(session.get("chat_id"), enabled) - apply_chat_runtime(agent, session, chat_id) + + if action not in {"stop", "park", "status", "help"} and current != "remote": + send_message("Switch to Remote mode first with /lumabot remote.") + return True + result = execute_remote_command(args) + prefix = f"LumaBot mode: {current.upper()}\n" if action == "status" else "" send_message( - "LumaBot mode ON. Only robot tools are available." - if enabled - else "LumaBot mode OFF. Full LumaKit is restored." + prefix + result["text"], + reply_markup=lumabot_remote_keyboard() if current == "remote" else None, ) return True diff --git a/core/telegram_io.py b/core/telegram_io.py index 7e22834..bb66a41 100644 --- a/core/telegram_io.py +++ b/core/telegram_io.py @@ -42,9 +42,9 @@ def _strip_emojis(text: str) -> str: # Outbound # --------------------------------------------------------------------------- -def send_message(text, chat_id=None): +def send_message(text, chat_id=None, reply_markup=None): chat_id = chat_id or _active_chat_id["value"] - return telegram_send_message(text, chat_id) + return telegram_send_message(text, chat_id, reply_markup=reply_markup) def edit_message_text(text, chat_id, message_id): diff --git a/docs/lumabot_pi_setup.md b/docs/lumabot_pi_setup.md index c844534..891cf84 100644 --- a/docs/lumabot_pi_setup.md +++ b/docs/lumabot_pi_setup.md @@ -54,19 +54,39 @@ The LumaKit `lumabot_status`, `lumabot_drive`, `lumabot_sequence`, and are owner-only. Natural-language intent and the final acknowledgement remain part of LumaKit's normal LLM tool-result cycle; there is no phrase parser. -Enable focused robot control for an individual conversation: +Choose a robot-control profile for an individual conversation: ```text -Telegram: /lumabot on -CLI: /lumabot on -Web: click the LumaBot toggle in the top bar +Telegram/CLI: /lumabot agent +Telegram/CLI: /lumabot remote +Telegram/CLI: /lumabot off +Web: choose Off, Agent, or Remote in the top bar ``` -This replaces the full agent prompt and 98-tool catalog with a compact robot -prompt and only the four LumaBot tools. The setting follows that saved -conversation and `/lumabot off` restores full LumaKit. “Park” currently stops -scheduled movement and coasts both motors. Autonomous patrol remains -unavailable until the distance sensor is connected and verified. +Agent mode replaces the full agent prompt and 98-tool catalog with a compact +robot prompt and only the four LumaBot tools. Natural-language and voice +requests still use the configured hosted model. + +Remote mode makes no LLM calls. The web UI shows a D-pad, Telegram shows +inline buttons, and CLI/Telegram accept explicit commands such as: + +```text +/lumabot drive forward 2 0.3 +/lumabot turn around +/lumabot stop +/lumabot park +/lumabot status +``` + +The setting follows the saved conversation. Direct movement uses the same +three-second hardware watchdog as Agent mode. The web STOP button and +`/lumabot stop` bypass the LLM and interrupt an active Agent turn before it +can issue another movement. Free-form text, photos, and voice are deliberately +not interpreted in Remote mode. “Park” cancels scheduled movement and coasts +both motors. The 180-degree control scales the one-second full-speed bench +calibration for the selected throttle, but remains approximate without wheel +encoders. Autonomous patrol remains unavailable until the distance sensor is +connected and verified. After updating either checkout: diff --git a/surfaces/cli.py b/surfaces/cli.py index 9d08075..3327159 100644 --- a/surfaces/cli.py +++ b/surfaces/cli.py @@ -10,7 +10,15 @@ import tempfile from agent import Agent -from core.chat_store import get_active_chat, load_chat, make_title, new_chat_id, save_chat, set_active_chat +from core.chat_store import ( + get_active_chat, + get_chat_lumabot_profile, + load_chat, + make_title, + new_chat_id, + save_chat, + set_active_chat, +) from core.cli import render_storage_meter from core.commands import handle_command from core.identity import CLI_USER_ID @@ -19,6 +27,7 @@ from core.runtime_config import apply_user_runtime from core.service import LumaKitService, Surface from tools.memory.memory_tools import set_active_user as set_memory_active_user +from tools.lumabot.remote import REMOTE_HELP def _workspace_scope() -> str: @@ -152,6 +161,11 @@ def main(argv: list[str] | None = None): if not user_input: continue + remote_mode = get_chat_lumabot_profile(session["chat_id"]) == "remote" + if remote_mode and user_input.lower().startswith(("/p", "/image")): + print(f"\n{REMOTE_HELP}\n") + continue + if user_input.startswith("/"): if user_input.lower().startswith("/p"): parts = user_input.split(maxsplit=1) @@ -196,6 +210,10 @@ def main(argv: list[str] | None = None): handle_command(user_input, agent, session) continue + if remote_mode: + print(f"\n{REMOTE_HELP}\n") + continue + try: apply_user_runtime(agent, session, CLI_USER_ID, surface="cli") response = agent.ask_llm(user_input) diff --git a/surfaces/telegram.py b/surfaces/telegram.py index 7c5209e..c06ce4a 100644 --- a/surfaces/telegram.py +++ b/surfaces/telegram.py @@ -37,7 +37,13 @@ send_chat_action, telegram_api, ) -from core.telegram_commands import apply_chat_runtime, handle_telegram_command, swap_in +from core.telegram_commands import ( + apply_chat_runtime, + handle_lumabot_callback, + handle_telegram_command, + lumabot_remote_keyboard, + swap_in, +) from core.telegram_io import edit_message_text, send_message, send_tts_reply, telegram_confirm from core.telegram_speech import SpeechClient from core.telegram_state import ( @@ -53,6 +59,7 @@ _sessions, ) from tools.comms.react import set_react_context +from tools.lumabot.remote import execute_remote_command from tools.memory.memory_tools import set_active_user # --------------------------------------------------------------------------- @@ -307,6 +314,29 @@ def _poll_active_run_messages(agent: Agent) -> bool: for update in updates: _poll_offset["value"] = update["update_id"] + 1 + callback = update.get("callback_query", {}) + callback_data = callback.get("data", "") + callback_chat_id = str( + callback.get("message", {}).get("chat", {}).get("id", "") + ) + if callback_data.startswith("lbot:") and callback_chat_id == str(chat_id): + callback_session = _get_session(callback_chat_id) + result = handle_lumabot_callback(callback_data, callback_session) + try: + telegram_api( + "answerCallbackQuery", + { + "callback_query_id": callback.get("id"), + "text": result["text"][:200], + "show_alert": not result.get("ok", False), + }, + ) + except Exception: + pass + if callback_data in {"lbot:stop", "lbot:park"}: + agent.request_stop("LumaBot emergency stop requested.") + continue + msg = update.get("message", {}) msg_chat_id = str(msg.get("chat", {}).get("id", "")) text = msg.get("text", "").strip() @@ -320,6 +350,11 @@ def _poll_active_run_messages(agent: Agent) -> bool: # Slash commands are still handled by the command dispatcher. if text.startswith("/"): + if text.strip().lower() in {"/lumabot stop", "/lumabot park"}: + result = execute_remote_command(text.split(maxsplit=1)[1]) + send_message(result["text"], chat_id=msg_chat_id) + agent.request_stop("LumaBot emergency stop requested.") + continue _pending_updates.append(update) continue @@ -492,6 +527,43 @@ def _handle_pending_draft(text, chat_id): if _poll_offset["value"] is None or new_offset > _poll_offset["value"]: _poll_offset["value"] = new_offset + callback = update.get("callback_query", {}) + if callback: + callback_id = callback.get("id") + callback_data = callback.get("data", "") + callback_chat_id = str( + callback.get("message", {}).get("chat", {}).get("id", "") + ) + if callback_data.startswith("lbot:") and callback_chat_id: + if callback_chat_id != str(OWNER_ID): + result = {"ok": False, "text": "Owner-only control."} + else: + _active_chat_id["value"] = callback_chat_id + set_active_user(callback_chat_id) + auth.set_active_user(callback_chat_id) + set_interface("telegram", callback_chat_id) + service.notify_activity() + callback_session = _get_session(callback_chat_id) + swap_in(agent, callback_session) + apply_chat_runtime(agent, callback_session, callback_chat_id) + result = handle_lumabot_callback( + callback_data, + callback_session, + ) + try: + telegram_api( + "answerCallbackQuery", + { + "callback_query_id": callback_id, + "text": result["text"][:200], + "show_alert": not result.get("ok", False), + }, + ) + except Exception: + pass + print(f"[LumaBot remote] {callback_data}: {result['text']}") + continue + msg = update.get("message", {}) chat_id = str(msg.get("chat", {}).get("id", "")) text = msg.get("text", "").strip() @@ -529,9 +601,17 @@ def _handle_pending_draft(text, chat_id): session = _get_session(chat_id) swap_in(agent, session) apply_chat_runtime(agent, session, chat_id) + remote_mode = session.get("lumabot_mode") == "remote" # Photo if has_photo: + if remote_mode: + send_message( + "Remote mode does not send photos to an LLM. Use the controls " + "below or switch with /lumabot agent.", + reply_markup=lumabot_remote_keyboard(), + ) + continue print(f"[{user_name}] [photo] {caption or '(no caption)'}") file_id = photo_list[-1]["file_id"] image_data = download_telegram_photo(file_id) @@ -563,6 +643,13 @@ def _handle_pending_draft(text, chat_id): # Voice / audio if has_audio: + if remote_mode: + send_message( + "Remote mode does not transcribe voice commands. Use the controls " + "below or switch with /lumabot agent.", + reply_markup=lumabot_remote_keyboard(), + ) + continue label = "[voice]" if voice else "[audio]" print(f"[{user_name}] {label} {caption or '(no caption)'}") media = voice or audio or {} @@ -614,6 +701,14 @@ def _handle_pending_draft(text, chat_id): if handle_telegram_command(text, agent, session, chat_id, speech_client): continue + if remote_mode: + send_message( + "Remote mode only accepts the structured controls below. " + "Use /lumabot agent for natural language.", + reply_markup=lumabot_remote_keyboard(), + ) + continue + try: try: send_chat_action(chat_id, "typing") diff --git a/surfaces/web.py b/surfaces/web.py index 4b0334e..8b31823 100644 --- a/surfaces/web.py +++ b/surfaces/web.py @@ -37,6 +37,7 @@ from core.chat_store import ( delete_chat, get_active_chat, + get_chat_lumabot_profile, get_chat_workspace, list_chats, load_chat, @@ -44,7 +45,7 @@ new_chat_id, save_chat, set_active_chat, - set_chat_lumabot_mode, + set_chat_lumabot_profile, set_chat_workspace, ) from core import notifications as notification_log @@ -60,6 +61,7 @@ from ollama_client import OllamaClient from tools.comms.email import send_preapproved from tools.comms.react import set_react_context +from tools.lumabot.remote import REMOTE_HELP, execute_remote_action from tools.memory.memory_tools import set_active_user as set_memory_active_user PORT = int(os.getenv("LUMAKIT_WEB_PORT", "7865")) @@ -1223,13 +1225,13 @@ def send_sync(msg: dict): "chat_id": session["chat_id"], "title": session["title"], "messages": session["display_messages"], - "lumabot_mode": bool(session.get("lumabot_mode")), + "lumabot_mode": session.get("lumabot_mode", "off"), **_workspace_payload(session["workspace_path"]), }) else: await ws.send_json({ "type": "workspace_updated", - "lumabot_mode": bool(session.get("lumabot_mode")), + "lumabot_mode": session.get("lumabot_mode", "off"), **_workspace_payload(session["workspace_path"]), }) @@ -1290,7 +1292,7 @@ async def run_agent_request(text: str, image_data: bytes | None = None): "run_error": run_error, "streamed": bool(response.get("streamed")), "messages": session["display_messages"], - "lumabot_mode": bool(session.get("lumabot_mode")), + "lumabot_mode": session.get("lumabot_mode", "off"), **_workspace_payload(session["workspace_path"]), }) except Exception as e: @@ -1394,23 +1396,47 @@ async def run_agent_request(text: str, image_data: bytes | None = None): "text": "Finish or stop the current run before changing LumaBot mode.", }) continue - enabled = data.get("enabled") - if not isinstance(enabled, bool): + mode = str(data.get("mode", "")).lower() + if mode not in {"off", "agent", "remote"}: await ws.send_json({"type": "error", "text": "Invalid LumaBot mode value."}) continue - set_chat_lumabot_mode(session["chat_id"], enabled) + set_chat_lumabot_profile(session["chat_id"], mode) _prepare_web_turn(agent, session) await ws.send_json({ "type": "lumabot_mode", - "enabled": enabled, - "text": ( - "LumaBot mode ON. Only robot tools are available." - if enabled - else "LumaBot mode OFF. Full LumaKit is restored." - ), + "mode": mode, + "text": { + "agent": "LumaBot Agent mode ON. Natural language uses the configured LLM.", + "remote": "LumaBot Remote mode ON. Controls now bypass the LLM.", + "off": "LumaBot mode OFF. Full LumaKit is restored.", + }[mode], }) continue + if msg_type == "lumabot_control": + action = str(data.get("action", "")).lower() + profile = get_chat_lumabot_profile(session["chat_id"]) + if action not in {"stop", "park", "status"} and profile != "remote": + await ws.send_json({ + "type": "lumabot_control", + "ok": False, + "text": "Switch to LumaBot Remote mode first.", + }) + continue + try: + result = execute_remote_action( + action, + direction=data.get("direction"), + duration_s=data.get("duration_s", 1.0), + speed=data.get("speed", 0.3), + ) + except ValueError as error: + result = {"ok": False, "text": str(error)} + if action in {"stop", "park"} and agent_task and not agent_task.done(): + agent.request_stop("LumaBot emergency stop requested.") + await ws.send_json({"type": "lumabot_control", **result}) + continue + # Load a specific chat if msg_type == "load_chat": if agent_task and not agent_task.done(): @@ -1436,7 +1462,7 @@ async def run_agent_request(text: str, image_data: bytes | None = None): "chat_id": session["chat_id"], "title": session["title"], "messages": session["display_messages"], - "lumabot_mode": bool(session.get("lumabot_mode")), + "lumabot_mode": session.get("lumabot_mode", "off"), **_workspace_payload(session["workspace_path"]), }) else: @@ -1464,7 +1490,7 @@ async def run_agent_request(text: str, image_data: bytes | None = None): "chat_id": session["chat_id"], "title": "", "messages": [], - "lumabot_mode": bool(session.get("lumabot_mode")), + "lumabot_mode": session.get("lumabot_mode", "off"), **_workspace_payload(session["workspace_path"]), }) continue @@ -1482,6 +1508,14 @@ async def run_agent_request(text: str, image_data: bytes | None = None): if not text and not image_data: continue + if get_chat_lumabot_profile(session["chat_id"]) == "remote": + await ws.send_json({ + "type": "lumabot_control", + "ok": False, + "text": REMOTE_HELP, + }) + continue + normalized = text.lower() if not image_data and normalized in _EMAIL_AFFIRM and email_draft_store.get_latest_pending(): await ws.send_json(_handle_email_draft_action("approve")) diff --git a/tests/test_lumabot_mode.py b/tests/test_lumabot_mode.py index 9716c58..f626e32 100644 --- a/tests/test_lumabot_mode.py +++ b/tests/test_lumabot_mode.py @@ -1,4 +1,4 @@ -"""Focused LumaBot mode stays LLM-driven and scoped to one conversation.""" +"""Agent and no-LLM Remote modes remain isolated and deterministic.""" from agent import Agent from tool_registry import ToolRegistry @@ -39,14 +39,17 @@ def test_mode_persists_per_conversation(tmp_path, monkeypatch): from core import chat_store monkeypatch.setattr(chat_store, "DB_PATH", tmp_path / "memory.db") - assert chat_store.get_chat_lumabot_mode("chat-a") is False + assert chat_store.get_chat_lumabot_profile("chat-a") == "off" - chat_store.set_chat_lumabot_mode("chat-a", True) - assert chat_store.get_chat_lumabot_mode("chat-a") is True - assert chat_store.get_chat_lumabot_mode("chat-b") is False + chat_store.set_chat_lumabot_profile("chat-a", "agent") + assert chat_store.get_chat_lumabot_profile("chat-a") == "agent" + assert chat_store.get_chat_lumabot_profile("chat-b") == "off" - chat_store.set_chat_lumabot_mode("chat-a", False) - assert chat_store.get_chat_lumabot_mode("chat-a") is False + chat_store.set_chat_lumabot_profile("chat-a", "remote") + assert chat_store.get_chat_lumabot_profile("chat-a") == "remote" + + chat_store.set_chat_lumabot_profile("chat-a", "off") + assert chat_store.get_chat_lumabot_profile("chat-a") == "off" def test_mode_exposes_only_lumabot_tools_with_compact_prompt(): @@ -80,16 +83,27 @@ def test_turning_mode_off_restores_full_tool_catalog(): assert agent.build_system_prompt() == "FULL LUMAKIT PROMPT" +def test_remote_profile_exposes_no_tools_even_if_agent_is_called(): + agent = _minimal_agent() + agent.set_runtime_profile("lumabot_remote") + + assert agent.get_tools_for_llm() == [] + assert "No tools are available" in agent.build_system_prompt() + result = agent.execute_tool("lumabot_drive", {}) + assert result["success"] is False + assert "lumabot_remote mode" in result["error"] + + def test_cli_toggle_uses_shared_conversation_mode(monkeypatch, capsys): from core import commands saved = [] refreshed = [] - monkeypatch.setattr(commands, "get_chat_lumabot_mode", lambda chat_id: False) + monkeypatch.setattr(commands, "get_chat_lumabot_profile", lambda chat_id: "off") monkeypatch.setattr( commands, - "set_chat_lumabot_mode", - lambda chat_id, enabled: saved.append((chat_id, enabled)), + "set_chat_lumabot_profile", + lambda chat_id, profile: saved.append((chat_id, profile)), ) monkeypatch.setattr( commands, @@ -97,10 +111,10 @@ def test_cli_toggle_uses_shared_conversation_mode(monkeypatch, capsys): lambda agent, session, user_id, surface=None: refreshed.append(surface), ) - commands.cmd_lumabot("on", object(), {"chat_id": "cli-chat"}) - assert saved == [("cli-chat", True)] + commands.cmd_lumabot("remote", object(), {"chat_id": "cli-chat"}) + assert saved == [("cli-chat", "remote")] assert refreshed == ["cli"] - assert "LumaBot mode ON" in capsys.readouterr().out + assert "structured commands bypass the LLM" in capsys.readouterr().out def test_telegram_toggle_is_owner_only_and_uses_shared_mode(monkeypatch): @@ -110,12 +124,20 @@ def test_telegram_toggle_is_owner_only_and_uses_shared_mode(monkeypatch): saved = [] refreshed = [] monkeypatch.setattr(telegram_commands, "OWNER_ID", "owner-chat") - monkeypatch.setattr(telegram_commands, "send_message", sent.append) - monkeypatch.setattr(telegram_commands, "get_chat_lumabot_mode", lambda chat_id: False) monkeypatch.setattr( telegram_commands, - "set_chat_lumabot_mode", - lambda chat_id, enabled: saved.append((chat_id, enabled)), + "send_message", + lambda text, **kwargs: sent.append((text, kwargs)), + ) + monkeypatch.setattr( + telegram_commands, + "get_chat_lumabot_profile", + lambda chat_id: "off", + ) + monkeypatch.setattr( + telegram_commands, + "set_chat_lumabot_profile", + lambda chat_id, profile: saved.append((chat_id, profile)), ) monkeypatch.setattr( telegram_commands, @@ -125,14 +147,92 @@ def test_telegram_toggle_is_owner_only_and_uses_shared_mode(monkeypatch): session = {"chat_id": "shared-chat"} handled = telegram_commands.handle_telegram_command( - "/lumabot on", object(), session, "owner-chat", None + "/lumabot remote", object(), session, "owner-chat", None ) assert handled is True - assert saved == [("shared-chat", True)] + assert saved == [("shared-chat", "remote")] assert refreshed == ["owner-chat"] - assert sent[-1].startswith("LumaBot mode ON") + assert sent[-1][0].startswith("LumaBot Remote mode ON") + assert "inline_keyboard" in sent[-1][1]["reply_markup"] telegram_commands.handle_telegram_command( - "/lumabot on", object(), session, "someone-else", None + "/lumabot remote", object(), session, "someone-else", None + ) + assert sent[-1][0] == "This command is owner-only." + + +def test_remote_command_dispatches_once_without_llm(monkeypatch): + from tools.lumabot import remote + + calls = [] + + class Scheduler: + def start(self, direction, speed, duration): + calls.append(("start", direction, speed, duration)) + return {"accepted": True, "entire_request_scheduled": True} + + def stop(self): + calls.append(("stop",)) + return {"stopped": True} + + monkeypatch.setattr(remote, "SCHEDULER", Scheduler()) + result = remote.execute_remote_command("drive forward 2 0.4") + assert result["ok"] is True + assert calls == [("start", "forward", 0.4, 2.0)] + + stopped = remote.execute_remote_command("park") + assert stopped["ok"] is True + assert calls[-1] == ("stop",) + + around = remote.execute_remote_action("turn_around", speed=0.5) + assert around["ok"] is True + assert calls[-1] == ("start", "left", 0.5, 2.0) + + +def test_remote_command_rejects_free_form_and_bad_bounds(monkeypatch): + from tools.lumabot import remote + + monkeypatch.setattr( + remote, + "SCHEDULER", + type("Scheduler", (), {"start": lambda *args: {}})(), + ) + assert remote.execute_remote_command("come closer")["ok"] is False + result = remote.execute_remote_command("drive forward 60") + assert result["ok"] is False + assert "between 0.1 and 30" in result["text"] + + +def test_telegram_button_uses_structured_callback_without_llm(monkeypatch): + from core import telegram_commands + + calls = [] + monkeypatch.setattr( + telegram_commands, + "get_chat_lumabot_profile", + lambda chat_id: "remote", + ) + monkeypatch.setattr( + telegram_commands, + "execute_remote_action", + lambda action, **kwargs: calls.append((action, kwargs)) + or {"ok": True, "text": "accepted"}, ) - assert sent[-1] == "This command is owner-only." + + result = telegram_commands.handle_lumabot_callback( + "lbot:drive:forward", + {"chat_id": "robot-chat"}, + ) + assert result["ok"] is True + assert calls == [("drive", {"direction": "forward"})] + + +def test_remote_number_validation_rejects_boolean(): + from tools.lumabot.remote import execute_remote_action + + try: + execute_remote_action("drive", direction="forward", duration_s=True) + except ValueError as error: + assert "duration must be a number" in str(error) + else: + raise AssertionError("boolean duration should be rejected") diff --git a/tools/lumabot/remote.py b/tools/lumabot/remote.py new file mode 100644 index 0000000..a9334d0 --- /dev/null +++ b/tools/lumabot/remote.py @@ -0,0 +1,131 @@ +"""Deterministic LumaBot remote control; no language model involved.""" + +from __future__ import annotations + +import shlex + +from tools.lumabot import client +from tools.lumabot.motion import SCHEDULER + + +TURN_AROUND_FULL_SPEED_S = 1.0 + +REMOTE_HELP = ( + "LumaBot Remote commands\n\n" + "/lumabot drive forward 1 0.3\n" + "/lumabot drive backward 1 0.3\n" + "/lumabot turn left 1 0.3\n" + "/lumabot turn right 1 0.3\n" + "/lumabot turn around\n" + "/lumabot stop\n" + "/lumabot park\n" + "/lumabot status\n" + "/lumabot agent\n" + "/lumabot off\n\n" + "Numbers are duration in seconds and speed from 0.1 to 1.0." +) + + +def _number(value, label: str) -> float: + if isinstance(value, bool): + raise ValueError(f"{label} must be a number") + try: + return float(value) + except (TypeError, ValueError) as error: + raise ValueError(f"{label} must be a number") from error + + +def _motion_values(duration_s=1.0, speed=0.3) -> tuple[float, float]: + duration = _number(duration_s, "duration") + throttle = _number(speed, "speed") + if not 0.1 <= duration <= 30.0: + raise ValueError("duration must be between 0.1 and 30 seconds") + if not 0.1 <= throttle <= 1.0: + raise ValueError("speed must be between 0.1 and 1.0") + return duration, throttle + + +def _status_text(status: dict) -> str: + if status.get("error"): + return status["error"] + battery = status.get("battery_pct") + battery_text = f", battery {battery:.0f}%" if isinstance(battery, (int, float)) else "" + return ( + f"LumaBot is {status.get('mode', 'unknown')}{battery_text}. " + f"Motors {'ready' if status.get('motors_ready') else 'not ready'}." + ) + + +def execute_remote_action( + action: str, + *, + direction: str | None = None, + duration_s=1.0, + speed=0.3, +) -> dict: + """Execute one explicit remote action through the watchdog-safe scheduler.""" + action = str(action or "").lower() + if action == "status": + data = client.get_status() + return {"ok": not bool(data.get("error")), "text": _status_text(data), "data": data} + + if action in {"stop", "park"}: + data = SCHEDULER.stop() + if data.get("error"): + return {"ok": False, "text": data["error"], "data": data} + text = "Parked. Motors released." if action == "park" else "Stopped." + return {"ok": True, "text": text, "data": data} + + duration, throttle = _motion_values(duration_s, speed) + if action == "turn_around": + direction = "left" + duration = min(5.0, TURN_AROUND_FULL_SPEED_S / throttle) + elif action == "turn": + if direction not in {"left", "right"}: + raise ValueError("turn direction must be left or right") + elif action == "drive": + if direction not in {"forward", "backward"}: + raise ValueError("drive direction must be forward or backward") + else: + raise ValueError("unknown LumaBot remote action") + + data = SCHEDULER.start(direction, throttle, duration) + if data.get("error"): + return {"ok": False, "text": data["error"], "data": data} + label = "Turning around." if action == "turn_around" else f"Moving {direction}." + return {"ok": True, "text": label, "data": data} + + +def execute_remote_command(arguments: str) -> dict: + """Parse the documented slash-command grammar, never natural language.""" + try: + parts = shlex.split(arguments) + except ValueError as error: + return {"ok": False, "text": f"{error}\n\n{REMOTE_HELP}"} + if not parts or parts[0].lower() == "help": + return {"ok": True, "text": REMOTE_HELP} + + command = parts[0].lower() + try: + if command in {"status", "stop", "park"} and len(parts) == 1: + return execute_remote_action(command) + if command == "drive" and 2 <= len(parts) <= 4: + return execute_remote_action( + "drive", + direction=parts[1].lower(), + duration_s=parts[2] if len(parts) >= 3 else 1.0, + speed=parts[3] if len(parts) == 4 else 0.3, + ) + if command == "turn" and 2 <= len(parts) <= 4: + target = parts[1].lower() + if target == "around" and len(parts) == 2: + return execute_remote_action("turn_around") + return execute_remote_action( + "turn", + direction=target, + duration_s=parts[2] if len(parts) >= 3 else 1.0, + speed=parts[3] if len(parts) == 4 else 0.3, + ) + except ValueError as error: + return {"ok": False, "text": f"{error}\n\n{REMOTE_HELP}"} + return {"ok": False, "text": REMOTE_HELP} diff --git a/web/css/style.css b/web/css/style.css index c54b111..4276a42 100644 --- a/web/css/style.css +++ b/web/css/style.css @@ -340,10 +340,7 @@ body { min-width: 0; } -.lumabot-mode-btn { - display: inline-flex; - align-items: center; - gap: 7px; +.lumabot-mode-select { height: 28px; padding: 0 11px; border: 1px solid rgba(176, 124, 216, 0.28); @@ -353,33 +350,121 @@ body { font-size: 11px; font-weight: 700; cursor: pointer; - transition: 0.15s ease; -} - -.lumabot-mode-dot { - width: 7px; - height: 7px; - border-radius: 50%; - background: var(--text-muted); + outline: none; } -.lumabot-mode-btn.active { +.lumabot-mode-select.agent { color: #fff; border-color: rgba(74, 222, 128, 0.65); background: rgba(34, 197, 94, 0.18); - box-shadow: 0 0 14px rgba(74, 222, 128, 0.18); } -.lumabot-mode-btn.active .lumabot-mode-dot { - background: var(--success); - box-shadow: 0 0 8px rgba(74, 222, 128, 0.8); +.lumabot-mode-select.remote { + color: #fff; + border-color: rgba(251, 191, 36, 0.7); + background: rgba(245, 158, 11, 0.18); } -.lumabot-mode-btn:disabled { +.lumabot-mode-select:disabled { opacity: 0.5; cursor: not-allowed; } +.lumabot-estop-btn { + height: 28px; + padding: 0 10px; + border: 1px solid rgba(248, 113, 113, 0.75); + border-radius: 999px; + background: rgba(220, 38, 38, 0.2); + color: #fecaca; + font-size: 10px; + font-weight: 800; + cursor: pointer; +} + +.lumabot-estop-btn:hover { + background: rgba(220, 38, 38, 0.4); + color: #fff; +} + +.lumabot-remote-controls { + display: flex; + align-items: center; + justify-content: center; + gap: 18px; + width: min(760px, 100%); + margin: 0 auto 10px; + padding: 12px 16px; + border: 1px solid rgba(251, 191, 36, 0.35); + border-radius: 16px; + background: rgba(35, 27, 17, 0.92); +} + +.lumabot-remote-controls.hidden { + display: none; +} + +.remote-control-grid { + display: grid; + grid-template-columns: repeat(3, 42px); + grid-template-areas: + ". forward ." + "left stop right" + ". backward ."; + gap: 6px; +} + +.remote-control-grid button, +.remote-control-options button { + min-height: 36px; + border: 1px solid rgba(251, 191, 36, 0.35); + border-radius: 9px; + background: rgba(245, 158, 11, 0.12); + color: #fde68a; + font-weight: 700; + cursor: pointer; +} + +.remote-control-grid [data-direction="forward"] { grid-area: forward; } +.remote-control-grid [data-direction="left"] { grid-area: left; } +.remote-control-grid .remote-stop { grid-area: stop; } +.remote-control-grid [data-direction="right"] { grid-area: right; } +.remote-control-grid [data-direction="backward"] { grid-area: backward; } + +.remote-control-grid .remote-stop { + color: #fecaca; + border-color: rgba(248, 113, 113, 0.65); + background: rgba(220, 38, 38, 0.25); + font-size: 9px; +} + +.remote-control-options { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.remote-control-options label { + display: flex; + align-items: center; + gap: 5px; + color: var(--text-secondary); + font-size: 11px; +} + +.remote-control-options select { + height: 32px; + border: 1px solid rgba(251, 191, 36, 0.3); + border-radius: 8px; + background: rgba(20, 16, 12, 0.8); + color: var(--text-primary); +} + +.remote-control-options button { + padding: 0 10px; +} + .workspace-form { display: flex; align-items: center; @@ -2966,14 +3051,19 @@ body { display: none; } - .lumabot-mode-btn { - width: 28px; - padding: 0; - justify-content: center; + .lumabot-mode-select { + width: 82px; + padding: 0 5px; } - .lumabot-mode-text { - display: none; + .lumabot-estop-btn { + padding: 0 7px; + } + + .lumabot-remote-controls { + flex-direction: column; + gap: 10px; + padding: 10px; } .message.user .bubble { diff --git a/web/index.html b/web/index.html index 4c238b8..73b7288 100644 --- a/web/index.html +++ b/web/index.html @@ -44,11 +44,14 @@ New Chat
- + + @@ -80,6 +83,35 @@

Your local AI agent is ready.

+
diff --git a/web/js/app.js b/web/js/app.js index ba59f77..d050cc1 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -22,7 +22,12 @@ const $modelBadge = document.getElementById('model-badge'); const $modelBadgeText = $modelBadge?.querySelector('.model-badge-text') || $modelBadge; const $statusLabel = document.getElementById('status-label'); const $statusDot = document.getElementById('status-dot'); -const $lumabotModeBtn = document.getElementById('lumabot-mode-btn'); +const $lumabotModeSelect = document.getElementById('lumabot-mode-select'); +const $lumabotEstopBtn = document.getElementById('lumabot-estop-btn'); +const $lumabotRemoteControls = document.getElementById('lumabot-remote-controls'); +const $lumabotDuration = document.getElementById('lumabot-duration'); +const $lumabotSpeed = document.getElementById('lumabot-speed'); +const $suggestionCards = document.getElementById('suggestion-cards'); const $workspaceForm = document.getElementById('workspace-form'); const $workspaceInput = document.getElementById('workspace-input'); const $workspaceBrowse = document.getElementById('workspace-browse'); @@ -51,7 +56,7 @@ let isWorking = false; let currentView = 'chat'; let currentChatId = null; let currentWorkspacePath = ''; -let lumabotMode = false; +let lumabotMode = 'off'; let statusEl = null; let activityCardEl = null; let activityTitleEl = null; @@ -231,7 +236,7 @@ function setWorking(working) { if ($workspaceInput) $workspaceInput.disabled = working; if ($workspaceBrowse) $workspaceBrowse.disabled = working; if ($photoBtn) $photoBtn.disabled = working; - if ($lumabotModeBtn) $lumabotModeBtn.disabled = working; + if ($lumabotModeSelect) $lumabotModeSelect.disabled = working; // Type /stop to interrupt — no UI toggle needed } @@ -252,19 +257,26 @@ function setWorkspace(path, displayPath) { } } -function setLumabotMode(enabled) { - lumabotMode = !!enabled; - $lumabotModeBtn?.classList.toggle('active', lumabotMode); - $lumabotModeBtn?.setAttribute('aria-pressed', String(lumabotMode)); - if ($lumabotModeBtn) { - $lumabotModeBtn.title = lumabotMode - ? 'LumaBot mode is on — click to restore full LumaKit' - : 'Toggle focused LumaBot control mode'; +function setLumabotMode(mode) { + if (typeof mode === 'boolean') mode = mode ? 'agent' : 'off'; + lumabotMode = ['off', 'agent', 'remote'].includes(mode) ? mode : 'off'; + if ($lumabotModeSelect) { + $lumabotModeSelect.value = lumabotMode; + $lumabotModeSelect.classList.toggle('agent', lumabotMode === 'agent'); + $lumabotModeSelect.classList.toggle('remote', lumabotMode === 'remote'); } + const remote = lumabotMode === 'remote'; + $lumabotRemoteControls?.classList.toggle('hidden', !remote); + $suggestionCards?.classList.toggle('hidden', remote); + $input.disabled = requiresModelSetup || remote; + $sendBtn.disabled = requiresModelSetup || remote; + if ($photoBtn) $photoBtn.disabled = requiresModelSetup || remote || isWorking; if (!requiresModelSetup) { - $input.placeholder = lumabotMode - ? 'Tell LumaBot what to do...' - : 'Message Lumi... (type /stop to interrupt)'; + $input.placeholder = remote + ? 'Remote mode uses the controls above — no LLM calls' + : lumabotMode === 'agent' + ? 'Tell LumaBot what to do...' + : 'Message Lumi... (type /stop to interrupt)'; } } @@ -2349,14 +2361,18 @@ const ws = new WS({ workspace_updated(data) { setWorkspace(data.workspace_path, data.workspace_display); - if (typeof data.lumabot_mode === 'boolean') setLumabotMode(data.lumabot_mode); + if (data.lumabot_mode != null) setLumabotMode(data.lumabot_mode); }, lumabot_mode(data) { - setLumabotMode(data.enabled); + setLumabotMode(data.mode); if (data.text) showStatus(data.text); }, + lumabot_control(data) { + showStatus(data.text || (data.ok ? 'LumaBot command accepted.' : 'LumaBot command failed.')); + }, + workspace_error(data) { showWorkspaceError(data.text || 'Could not set working directory.'); }, @@ -2374,7 +2390,7 @@ const ws = new WS({ }, chat_loaded(data) { - if (typeof data.lumabot_mode === 'boolean') setLumabotMode(data.lumabot_mode); + if (data.lumabot_mode != null) setLumabotMode(data.lumabot_mode); const previousChatId = currentChatId; if (data.chat_id === previousChatId && isWorking) { currentChatId = data.chat_id; @@ -2616,6 +2632,10 @@ function sendMessage() { switchView('settings'); return; } + if (lumabotMode === 'remote') { + showStatus('Use the LumaBot Remote controls above.'); + return; + } // Only reset the activity card when starting a fresh turn — if the agent // is still working, the user's new message is queued alongside the @@ -2635,9 +2655,27 @@ function sendMessage() { $sendBtn.onclick = sendMessage; -$lumabotModeBtn?.addEventListener('click', () => { +$lumabotModeSelect?.addEventListener('change', () => { if (isWorking) return; - ws.send({ type: 'lumabot_mode', enabled: !lumabotMode }); + const requestedMode = $lumabotModeSelect.value; + $lumabotModeSelect.value = lumabotMode; + ws.send({ type: 'lumabot_mode', mode: requestedMode }); +}); + +$lumabotEstopBtn?.addEventListener('click', () => { + ws.send({ type: 'lumabot_control', action: 'stop' }); +}); + +$lumabotRemoteControls?.querySelectorAll('button[data-action]').forEach(button => { + button.addEventListener('click', () => { + ws.send({ + type: 'lumabot_control', + action: button.dataset.action, + direction: button.dataset.direction || null, + duration_s: Number($lumabotDuration?.value || 1), + speed: Number($lumabotSpeed?.value || 0.3), + }); + }); }); document.querySelectorAll('.suggestion-card').forEach(card => { From 8ba7131565905d9b18c7ad20dd68f3b2334f0772 Mon Sep 17 00:00:00 2001 From: Patrick Kearney Date: Thu, 30 Jul 2026 21:51:36 -0400 Subject: [PATCH 5/9] Latch LumaBot remote drive controls --- core/telegram_commands.py | 2 +- docs/lumabot_pi_setup.md | 9 ++- surfaces/web.py | 4 ++ tests/test_lumabot_mode.py | 16 ++++- tests/test_lumabot_tools.py | 38 +++++++++++ tools/lumabot/motion.py | 123 ++++++++++++++++++++++++++++++++---- tools/lumabot/remote.py | 25 ++++++-- web/index.html | 2 +- web/js/app.js | 1 + 9 files changed, 198 insertions(+), 22 deletions(-) diff --git a/core/telegram_commands.py b/core/telegram_commands.py index 9c89470..52fa049 100644 --- a/core/telegram_commands.py +++ b/core/telegram_commands.py @@ -70,7 +70,7 @@ def handle_lumabot_callback(data: str, session: dict) -> dict: return {"ok": False, "text": "LumaBot Remote mode is off."} try: if action == "drive" and len(parts) == 3: - return execute_remote_action("drive", direction=parts[2]) + return execute_remote_action("drive", direction=parts[2], continuous=True) if action == "turn" and len(parts) == 3: return execute_remote_action("turn", direction=parts[2]) if action in {"turn_around", "stop", "park", "status"} and len(parts) == 2: diff --git a/docs/lumabot_pi_setup.md b/docs/lumabot_pi_setup.md index 891cf84..c5529cc 100644 --- a/docs/lumabot_pi_setup.md +++ b/docs/lumabot_pi_setup.md @@ -71,6 +71,8 @@ Remote mode makes no LLM calls. The web UI shows a D-pad, Telegram shows inline buttons, and CLI/Telegram accept explicit commands such as: ```text +/lumabot drive forward +/lumabot drive backward /lumabot drive forward 2 0.3 /lumabot turn around /lumabot stop @@ -79,7 +81,12 @@ inline buttons, and CLI/Telegram accept explicit commands such as: ``` The setting follows the saved conversation. Direct movement uses the same -three-second hardware watchdog as Agent mode. The web STOP button and +three-second hardware watchdog as Agent mode. In Remote mode, the Forward and +Reverse buttons—and drive commands without a duration—stay active until STOP +or another movement control replaces them. LumaKit renews a three-second lease +while they are active, so the daemon still coasts the motors if LumaKit exits +or loses contact. Supplying a duration keeps the drive timed; the web duration +selector applies to turns. The web STOP button and `/lumabot stop` bypass the LLM and interrupt an active Agent turn before it can issue another movement. Free-form text, photos, and voice are deliberately not interpreted in Remote mode. “Park” cancels scheduled movement and coasts diff --git a/surfaces/web.py b/surfaces/web.py index 8b31823..ed83e97 100644 --- a/surfaces/web.py +++ b/surfaces/web.py @@ -1424,11 +1424,15 @@ async def run_agent_request(text: str, image_data: bytes | None = None): }) continue try: + continuous = data.get("continuous", False) + if not isinstance(continuous, bool): + raise ValueError("continuous must be true or false") result = execute_remote_action( action, direction=data.get("direction"), duration_s=data.get("duration_s", 1.0), speed=data.get("speed", 0.3), + continuous=continuous, ) except ValueError as error: result = {"ok": False, "text": str(error)} diff --git a/tests/test_lumabot_mode.py b/tests/test_lumabot_mode.py index f626e32..d442334 100644 --- a/tests/test_lumabot_mode.py +++ b/tests/test_lumabot_mode.py @@ -171,14 +171,26 @@ def start(self, direction, speed, duration): calls.append(("start", direction, speed, duration)) return {"accepted": True, "entire_request_scheduled": True} + def start_continuous(self, direction, speed): + calls.append(("continuous", direction, speed)) + return {"accepted": True, "continuous": True} + def stop(self): calls.append(("stop",)) return {"stopped": True} monkeypatch.setattr(remote, "SCHEDULER", Scheduler()) + latched = remote.execute_remote_command("drive forward") + assert latched["ok"] is True + assert calls == [("continuous", "forward", 0.3)] + result = remote.execute_remote_command("drive forward 2 0.4") assert result["ok"] is True - assert calls == [("start", "forward", 0.4, 2.0)] + assert calls[-1] == ("start", "forward", 0.4, 2.0) + + reversed_result = remote.execute_remote_command("drive reverse") + assert reversed_result["ok"] is True + assert calls[-1] == ("continuous", "backward", 0.3) stopped = remote.execute_remote_command("park") assert stopped["ok"] is True @@ -224,7 +236,7 @@ def test_telegram_button_uses_structured_callback_without_llm(monkeypatch): {"chat_id": "robot-chat"}, ) assert result["ok"] is True - assert calls == [("drive", {"direction": "forward"})] + assert calls == [("drive", {"direction": "forward", "continuous": True})] def test_remote_number_validation_rejects_boolean(): diff --git a/tests/test_lumabot_tools.py b/tests/test_lumabot_tools.py index 50fd370..09ca972 100644 --- a/tests/test_lumabot_tools.py +++ b/tests/test_lumabot_tools.py @@ -7,6 +7,7 @@ from tool_registry import ToolRegistry from tools.lumabot import client from tools.lumabot.motion import ( + MotionScheduler, SCHEDULER, get_lumabot_drive_tool, get_lumabot_sequence_tool, @@ -118,6 +119,43 @@ def test_sequence_runs_each_step_once_and_in_order(registry, monkeypatch): SCHEDULER.cancel() +def test_continuous_motion_renews_until_stopped(monkeypatch): + from tools.lumabot import motion + + calls = [] + monkeypatch.setattr(motion, "WATCHDOG_LEASE_S", 0.08) + monkeypatch.setattr(motion, "RENEW_MARGIN_S", 0.02) + monkeypatch.setattr( + client, + "drive", + lambda direction, speed, duration: calls.append( + ("drive", direction, speed, duration) + ) + or {"accepted": True, "direction": direction}, + ) + monkeypatch.setattr( + client, + "stop", + lambda: calls.append(("stop",)) or {"stopped": True}, + ) + + scheduler = MotionScheduler() + result = scheduler.start_continuous("forward", 0.3) + assert result["continuous"] is True + + deadline = time.monotonic() + 0.5 + while len(calls) < 2 and time.monotonic() < deadline: + time.sleep(0.01) + assert len(calls) >= 2 + assert all(call[1] == "forward" for call in calls if call[0] == "drive") + + scheduler.stop() + calls_after_stop = len(calls) + time.sleep(0.12) + assert len(calls) == calls_after_stop + assert calls[-1] == ("stop",) + + def test_stop_and_status_return_daemon_results(registry, monkeypatch): monkeypatch.setattr(client, "stop", lambda: {"stopped": True}) monkeypatch.setattr(client, "get_status", lambda: {"battery_pct": 75.0}) diff --git a/tools/lumabot/motion.py b/tools/lumabot/motion.py index 3e79b2b..b4d682a 100644 --- a/tools/lumabot/motion.py +++ b/tools/lumabot/motion.py @@ -15,25 +15,61 @@ class MotionScheduler: def __init__(self): self._lock = threading.Lock() + self._send_lock = threading.Lock() self._cancel_event: threading.Event | None = None def start(self, direction: str, speed: float, duration_s: float) -> dict: step = {"direction": direction, "speed": speed, "duration_s": duration_s} return self.start_sequence([step], single_drive=True) + def start_continuous(self, direction: str, speed: float) -> dict: + """Keep renewing a short drive lease until another command cancels it.""" + cancel_event = self._replace_active_motion() + result = self._send_drive_if_active( + cancel_event, + direction, + speed, + WATCHDOG_LEASE_S, + ) + if result is None: + return {"error": "Movement was replaced before it started."} + if result.get("error"): + self._clear_active_motion(cancel_event) + return result + + worker = threading.Thread( + target=self._renew_continuous, + args=(cancel_event, direction, speed), + daemon=True, + name="lumabot-continuous-motion", + ) + worker.start() + return { + **result, + "continuous": True, + "watchdog_lease_s": WATCHDOG_LEASE_S, + "scheduled": True, + "entire_request_scheduled": True, + "direction": direction, + "speed": speed, + } + def start_sequence(self, steps: list[dict], single_drive: bool = False) -> dict: - cancel_event = threading.Event() - with self._lock: - if self._cancel_event: - self._cancel_event.set() - self._cancel_event = cancel_event + cancel_event = self._replace_active_motion() first = steps[0] started_at = time.monotonic() first_lease = min(WATCHDOG_LEASE_S, first["duration_s"]) - result = client.drive(first["direction"], first["speed"], first_lease) + result = self._send_drive_if_active( + cancel_event, + first["direction"], + first["speed"], + first_lease, + ) + if result is None: + return {"error": "Movement was replaced before it started."} if result.get("error"): - cancel_event.set() + self._clear_active_motion(cancel_event) return result if len(steps) > 1 or first["duration_s"] > first_lease: @@ -81,8 +117,13 @@ def _run_sequence( return step_started = time.monotonic() first_lease = min(WATCHDOG_LEASE_S, step["duration_s"]) - result = client.drive(step["direction"], step["speed"], first_lease) - if result.get("error"): + result = self._send_drive_if_active( + cancel_event, + step["direction"], + step["speed"], + first_lease, + ) + if result is None or result.get("error"): return if not self._finish_step(cancel_event, step, step_started, first_lease): return @@ -112,11 +153,68 @@ def _finish_step( if remaining <= 0: return True next_lease = min(WATCHDOG_LEASE_S, remaining) - result = client.drive(step["direction"], step["speed"], next_lease) - if result.get("error"): + result = self._send_drive_if_active( + cancel_event, + step["direction"], + step["speed"], + next_lease, + ) + if result is None or result.get("error"): return False lease_deadline = time.monotonic() + next_lease + def _renew_continuous( + self, + cancel_event: threading.Event, + direction: str, + speed: float, + ) -> None: + renew_in = max(0.01, WATCHDOG_LEASE_S - RENEW_MARGIN_S) + while not cancel_event.wait(renew_in): + result = self._send_drive_if_active( + cancel_event, + direction, + speed, + WATCHDOG_LEASE_S, + ) + if result is None: + return + if result.get("error"): + self._clear_active_motion(cancel_event) + return + + def _replace_active_motion(self) -> threading.Event: + cancel_event = threading.Event() + with self._lock: + if self._cancel_event: + self._cancel_event.set() + self._cancel_event = cancel_event + return cancel_event + + def _clear_active_motion(self, cancel_event: threading.Event) -> None: + cancel_event.set() + with self._lock: + if self._cancel_event is cancel_event: + self._cancel_event = None + + def _send_drive(self, direction: str, speed: float, duration_s: float) -> dict: + with self._send_lock: + return client.drive(direction, speed, duration_s) + + def _send_drive_if_active( + self, + cancel_event: threading.Event, + direction: str, + speed: float, + duration_s: float, + ) -> dict | None: + # Hold the state lock through the send so an older renewal cannot race + # behind a newer direction and overwrite it at the daemon. + with self._lock: + if self._cancel_event is not cancel_event or cancel_event.is_set(): + return None + return self._send_drive(direction, speed, duration_s) + def cancel(self) -> None: with self._lock: if self._cancel_event: @@ -125,7 +223,8 @@ def cancel(self) -> None: def stop(self) -> dict: self.cancel() - return client.stop() + with self._send_lock: + return client.stop() SCHEDULER = MotionScheduler() diff --git a/tools/lumabot/remote.py b/tools/lumabot/remote.py index a9334d0..aa93061 100644 --- a/tools/lumabot/remote.py +++ b/tools/lumabot/remote.py @@ -12,8 +12,9 @@ REMOTE_HELP = ( "LumaBot Remote commands\n\n" - "/lumabot drive forward 1 0.3\n" - "/lumabot drive backward 1 0.3\n" + "/lumabot drive forward (keep moving)\n" + "/lumabot drive backward (keep moving)\n" + "/lumabot drive forward 2 0.3 (timed drive)\n" "/lumabot turn left 1 0.3\n" "/lumabot turn right 1 0.3\n" "/lumabot turn around\n" @@ -22,6 +23,7 @@ "/lumabot status\n" "/lumabot agent\n" "/lumabot off\n\n" + "Forward and backward continue until STOP or another control. " "Numbers are duration in seconds and speed from 0.1 to 1.0." ) @@ -62,8 +64,11 @@ def execute_remote_action( direction: str | None = None, duration_s=1.0, speed=0.3, + continuous: bool = False, ) -> dict: """Execute one explicit remote action through the watchdog-safe scheduler.""" + if not isinstance(continuous, bool): + raise ValueError("continuous must be true or false") action = str(action or "").lower() if action == "status": data = client.get_status() @@ -89,10 +94,16 @@ def execute_remote_action( else: raise ValueError("unknown LumaBot remote action") - data = SCHEDULER.start(direction, throttle, duration) + if action == "drive" and continuous: + data = SCHEDULER.start_continuous(direction, throttle) + else: + data = SCHEDULER.start(direction, throttle, duration) if data.get("error"): return {"ok": False, "text": data["error"], "data": data} - label = "Turning around." if action == "turn_around" else f"Moving {direction}." + if action == "drive" and continuous: + label = f"{direction.capitalize()} latched. Press STOP or choose another control." + else: + label = "Turning around." if action == "turn_around" else f"Moving {direction}." return {"ok": True, "text": label, "data": data} @@ -110,11 +121,15 @@ def execute_remote_command(arguments: str) -> dict: if command in {"status", "stop", "park"} and len(parts) == 1: return execute_remote_action(command) if command == "drive" and 2 <= len(parts) <= 4: + direction = parts[1].lower() + if direction == "reverse": + direction = "backward" return execute_remote_action( "drive", - direction=parts[1].lower(), + direction=direction, duration_s=parts[2] if len(parts) >= 3 else 1.0, speed=parts[3] if len(parts) == 4 else 0.3, + continuous=len(parts) == 2, ) if command == "turn" and 2 <= len(parts) <= 4: target = parts[1].lower() diff --git a/web/index.html b/web/index.html index 73b7288..4b76312 100644 --- a/web/index.html +++ b/web/index.html @@ -92,7 +92,7 @@

Your local AI agent is ready.

-