Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,14 @@ 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"

# LumaBot hardware daemon (local when LumaKit runs on the robot).
LUMABOT_URL="http://127.0.0.1:8971"
LUMABOT_ACTIVITY_INDICATOR="0"

# 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)
Expand Down
85 changes: 80 additions & 5 deletions agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from core.summarizer import apply_summary, build_summary_request, needs_summarization
from core.storage import StorageManager
from tools.code_intel.code_index import LazyCodeIndex, update_index_after_tool
from tools.lumabot.activity import LumaBotActivityLease


# Tools that modify files — require diff preview + confirmation
Expand All @@ -37,6 +38,9 @@
"git_add",
"git_commit",
"git_push",
"lumabot_reboot",
"lumabot_poweroff",
"lumabot_start_autonomy",
}

# Tools that have a built-in preview/confirm flow — always preview first
Expand Down Expand Up @@ -132,6 +136,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
Expand Down Expand Up @@ -516,15 +522,71 @@ 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", "lumabot_remote"}:
raise ValueError(f"Unknown runtime profile: {profile}")
if self.runtime_profile == profile:
return
self.runtime_profile = profile
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()

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_start_autonomy only for an explicit roaming request, lumabot_stop to stop "
"manual or autonomous movement, and lumabot_status for hardware or battery questions. "
"Use lumabot_reboot or lumabot_poweroff only for the owner's explicit whole-robot "
"power request; those actions require confirmation. "
"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."
)

@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()
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._lumabot_remote_system_prompt()
if self.runtime_profile == "lumabot_remote"
else self._system_prompt_prefix
)
)
if extra:
prompt += (
"\n\nPersonality override for this Telegram user:\n"
Expand All @@ -545,7 +607,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)
Expand Down Expand Up @@ -606,20 +668,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):
Expand Down Expand Up @@ -867,6 +938,8 @@ def ask_llm(self, prompt, image_data=None, image_path=None):
prompt or ("Image analysis" if has_image else ""),
kind="vision" if has_image else "chat",
)
activity_lease = LumaBotActivityLease()
activity_lease.start()
watchdog = StallWatchdog(
self.run_controller,
notify=lambda text: self.display.status(text),
Expand Down Expand Up @@ -1179,6 +1252,8 @@ def _finish(response, *, state="completed", final_message="", error=""):
watchdog.stop()
self.run_controller.finish_run("failed", error=str(exc))
raise
finally:
activity_lease.close()

SUPPORTED_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}

Expand Down
13 changes: 13 additions & 0 deletions core/approval_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
"execute_shell",
"execute_python",
"run_command",
"lumabot_reboot",
"lumabot_poweroff",
"lumabot_start_autonomy",
})

# Tools an autonomous task may never execute (it has no way to ask).
Expand All @@ -41,6 +44,9 @@
"git_add",
"git_commit",
"git_push",
"lumabot_reboot",
"lumabot_poweroff",
"lumabot_start_autonomy",
})

# Screens shell commands issued from inside autonomous tasks. This is a
Expand Down Expand Up @@ -133,12 +139,19 @@ def tool_always_requires_approval(tool_name: str, tool_inputs: dict) -> bool:
# system control / destructive maintenance
"reboot_system",
"restart_service",
"lumabot_reboot",
"lumabot_poweroff",
"clear_storage",
# secrets manager
"lumalok_list_secrets",
"lumalok_get_secret",
"lumalok_add_secret",
"lumalok_update_secret",
# physical robot control
"lumabot_drive",
"lumabot_sequence",
"lumabot_stop",
"lumabot_start_autonomy",
# tasks run autonomously with broader powers — creating/deleting them is
# an escalation path for non-owners
"create_task",
Expand Down
74 changes: 74 additions & 0 deletions core/chat_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,26 @@ 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,
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")
Expand Down Expand Up @@ -214,6 +234,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
Expand Down Expand Up @@ -251,6 +272,59 @@ 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:
"""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, 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_profile(chat_id: str | None) -> str:
"""Return off, agent, or remote for this conversation."""
if not chat_id:
return "off"
conn = _connect()
row = conn.execute(
"SELECT profile, lumabot_enabled FROM chat_runtime_modes WHERE chat_id = ?",
(str(chat_id),),
).fetchone()
conn.close()
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]:
"""Distinct workspace paths any chat has used, most recently used first."""
conn = _connect()
Expand Down
Loading
Loading