From fc6600c33eaadbf801d626dc97e3321501208a8c Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Mon, 11 May 2026 21:39:35 -0400 Subject: [PATCH] feat: Home Assistant API tools (ha_get_state, ha_get_states, ha_call_service) Register three HA orchestrator tools so Inara can read device states and control devices via the HA REST API. ha_call_service requires admin role and user confirmation. Also includes accumulated UI fixes (setProcessing helper, wasNewSession flag cleanup). Co-Authored-By: Claude Sonnet 4.6 --- cortex/static/app.js | 34 ++- cortex/static/style.css | 9 + cortex/tools/__init__.py | 15 +- cortex/tools/homeassistant.py | 277 +++++++++++++++++++++ documentation/DESIGN__Model_Registry_V2.md | 14 +- 5 files changed, 334 insertions(+), 15 deletions(-) create mode 100644 cortex/tools/homeassistant.py diff --git a/cortex/static/app.js b/cortex/static/app.js index 4d7e755..2c4b722 100644 --- a/cortex/static/app.js +++ b/cortex/static/app.js @@ -384,6 +384,16 @@ updateSendBtnTitle(); } + function setProcessing(state) { + if (state) { + headerEmoji.classList.add('processing'); + document.body.classList.add('processing'); + } else { + headerEmoji.classList.remove('processing'); + document.body.classList.remove('processing'); + } + } + // ── Settings dropdown ───────────────────────────────────────── settings_btn_el.addEventListener('click', (e) => { e.stopPropagation(); @@ -1148,7 +1158,7 @@ // ── Chat fetch + SSE handler ───────────────────────────────── // Extracted so the retry button can call it without re-adding the // user message to the DOM or currentHistory. - async function _doSend(payload, thinkingDiv) { + async function _doSend(payload, thinkingDiv, wasNewSession = false) { try { const res = await fetch('/chat', { method: 'POST', @@ -1238,13 +1248,13 @@ activeController = new AbortController(); sendBtn.style.display = 'none'; stopBtn.style.display = 'flex'; - headerEmoji.classList.add('processing'); + setProcessing(true); startRunTimer(); - await _doSend(payload, thinkingDiv); + await _doSend(payload, thinkingDiv, false); activeController = null; - headerEmoji.classList.remove('processing'); + setProcessing(false); sendBtn.style.display = 'block'; stopBtn.style.display = 'none'; stopRunTimer(); @@ -1265,7 +1275,7 @@ syncHeight(); sendBtn.style.display = 'none'; stopBtn.style.display = 'flex'; - headerEmoji.classList.add('processing'); + setProcessing(true); startRunTimer(); activeController = new AbortController(); @@ -1294,10 +1304,10 @@ persona: CORTEX_PERSONA, }; - await _doSend(payload, thinkingDiv); + await _doSend(payload, thinkingDiv, wasNewSession); activeController = null; - headerEmoji.classList.remove('processing'); + setProcessing(false); sendBtn.style.display = 'block'; stopBtn.style.display = 'none'; stopRunTimer(); @@ -1437,13 +1447,13 @@ activeController = new AbortController(); sendBtn.style.display = 'none'; stopBtn.style.display = 'flex'; - headerEmoji.classList.add('processing'); + setProcessing(true); startRunTimer(); await _doOrchestrate(text, thinkingDiv, userMsgDiv); activeController = null; - headerEmoji.classList.remove('processing'); + setProcessing(false); sendBtn.style.display = 'block'; stopBtn.style.display = 'none'; stopRunTimer(); @@ -1462,7 +1472,7 @@ syncHeight(); sendBtn.style.display = 'none'; stopBtn.style.display = 'flex'; - headerEmoji.classList.add('processing'); + setProcessing(true); startRunTimer(); activeController = new AbortController(); @@ -1476,7 +1486,7 @@ await _doOrchestrate(text, thinkingDiv, userMsgDiv); activeController = null; - headerEmoji.classList.remove('processing'); + setProcessing(false); sendBtn.style.display = 'block'; stopBtn.style.display = 'none'; stopRunTimer(); @@ -2215,4 +2225,4 @@ if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').catch(() => {}); - } + } \ No newline at end of file diff --git a/cortex/static/style.css b/cortex/static/style.css index f7684b2..1ef6779 100644 --- a/cortex/static/style.css +++ b/cortex/static/style.css @@ -142,6 +142,15 @@ .header-emoji.processing { animation: shimmer 0.75s ease-in-out infinite; } + @keyframes border-pulse { + 0%, 100% { box-shadow: inset 0 0 15px var(--amber-glow); } + 50% { box-shadow: inset 0 0 30px var(--amber-glow); } + } + + body.processing { + animation: border-pulse 1.5s ease-in-out infinite; + } + header .name { font-size: 1.1rem; font-weight: 600; color: var(--accent); } header .subtitle { font-size: 0.78rem; color: var(--muted); } diff --git a/cortex/tools/__init__.py b/cortex/tools/__init__.py index 0c37556..f275ea5 100644 --- a/cortex/tools/__init__.py +++ b/cortex/tools/__init__.py @@ -71,6 +71,11 @@ from tools.agent_notes import ( agent_notes_clear as _agent_notes_clear, ) from tools.agents import spawn_agent as _spawn_agent +from tools.homeassistant import ( + ha_get_state as _ha_get_state, + ha_get_states as _ha_get_states, + ha_call_service as _ha_call_service, +) # ── Declaration imports ─────────────────────────────────────────────────────── @@ -85,7 +90,8 @@ import tools.reminders as _mod_reminders import tools.scratch as _mod_scratch import tools.notify as _mod_notify import tools.agent_notes as _mod_agent_notes -import tools.agents as _mod_agents +import tools.agents as _mod_agents +import tools.homeassistant as _mod_homeassistant # ── Tool categories — used by the Model Registry UI for grouped checkboxes ─── @@ -109,6 +115,7 @@ TOOL_CATEGORIES: dict[str, list[str]] = { "Aether Tasks": ["ae_task_list"], "Agent Notes": ["agent_notes_read", "agent_notes_write", "agent_notes_append", "agent_notes_clear"], "Agents": ["spawn_agent"], + "Home Assistant": ["ha_get_state", "ha_get_states", "ha_call_service"], } # ── Callable registry ───────────────────────────────────────────────────────── @@ -164,6 +171,9 @@ _CALLABLES: dict[str, callable] = { "agent_notes_append": _agent_notes_append, "agent_notes_clear": _agent_notes_clear, "spawn_agent": _spawn_agent, + "ha_get_state": _ha_get_state, + "ha_get_states": _ha_get_states, + "ha_call_service": _ha_call_service, } # ── Role-based access control ───────────────────────────────────────────────── @@ -185,6 +195,7 @@ TOOL_ROLES: dict[str, str] = { "nc_talk_send": "admin", "http_post": "admin", "nc_talk_history": "admin", + "ha_call_service": "admin", } # Tools that require explicit user confirmation before executing. @@ -196,6 +207,7 @@ CONFIRM_REQUIRED: set[str] = { "cron_remove", "reminders_clear", "http_post", + "ha_call_service", } _ROLE_RANK: dict[str, int] = {"user": 0, "admin": 1} @@ -221,6 +233,7 @@ _ALL_DECLARATIONS: list[types.FunctionDeclaration] = ( + _mod_ae_tasks.DECLARATIONS + _mod_agent_notes.DECLARATIONS + _mod_agents.DECLARATIONS + + _mod_homeassistant.DECLARATIONS ) # Full Gemini Tool object (all tools — use get_tools_for_role() in production) diff --git a/cortex/tools/homeassistant.py b/cortex/tools/homeassistant.py new file mode 100644 index 0000000..f92e8f6 --- /dev/null +++ b/cortex/tools/homeassistant.py @@ -0,0 +1,277 @@ +""" +Home Assistant tools — read device states and call services. + +Credentials are read automatically from the current user's channels.json: + "homeassistant": {"url": "https://ha.example.com", "token": ""} + +Configure in Settings → Notifications → Home Assistant. +""" + +import json +import logging + +import httpx +from google.genai import types + +from auth_utils import get_user_channels +from persona import get_user + +logger = logging.getLogger(__name__) + +_TIMEOUT = 10 + +# Attributes that are internal/noisy and not useful to show +_SKIP_ATTRS = { + "friendly_name", "icon", "entity_picture", "supported_features", + "supported_color_modes", "color_mode", "min_color_temp_kelvin", + "max_color_temp_kelvin", "min_mireds", "max_mireds", + "assumed_state", "attribution", +} + + +def _get_ha_cfg() -> tuple[str, str]: + """Return (base_url, token) from the current user's channels.json.""" + channels = get_user_channels(get_user()) + ha = channels.get("homeassistant") or {} + url = (ha.get("url") or "").rstrip("/") + token = ha.get("token") or "" + if not url or not token: + raise ValueError( + "Home Assistant not configured — add URL and token in Settings → Notifications." + ) + return url, token + + +def _auth(token: str) -> dict: + return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + +def _fmt_state(s: dict) -> str: + """Format a single HA state dict as a compact readable line.""" + entity_id = s.get("entity_id", "") + state = s.get("state", "unknown") + attrs = s.get("attributes", {}) + name = attrs.get("friendly_name", entity_id) + + label = f"{name} ({entity_id})" if name != entity_id else entity_id + useful = {k: v for k, v in attrs.items() if k not in _SKIP_ATTRS} + + extra = "" + if useful: + parts = [] + for k, v in list(useful.items())[:6]: # cap at 6 attrs per entity + parts.append(f"{k}: {v}") + extra = " [" + ", ".join(parts) + "]" + + return f"{label}: {state}{extra}" + + +async def ha_get_state(entity_id: str) -> str: + """Return the current state and attributes of a single Home Assistant entity.""" + try: + url, token = _get_ha_cfg() + except ValueError as e: + return str(e) + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get(f"{url}/api/states/{entity_id}", headers=_auth(token)) + + if resp.status_code == 404: + return f"Entity not found: {entity_id}" + if resp.status_code != 200: + return f"HA API error {resp.status_code}: {resp.text[:400]}" + + s = resp.json() + attrs = s.get("attributes", {}) + lines = [ + f"**{attrs.get('friendly_name', entity_id)}** (`{entity_id}`)", + f"State: **{s.get('state', 'unknown')}**", + ] + changed = (s.get("last_changed") or "")[:19].replace("T", " ") + if changed: + lines.append(f"Last changed: {changed} UTC") + + useful = {k: v for k, v in attrs.items() if k not in _SKIP_ATTRS} + if useful: + lines.append("Attributes:") + for k, v in useful.items(): + lines.append(f" {k}: {v}") + + return "\n".join(lines) + + except httpx.HTTPError as e: + return f"Connection error: {e}" + except Exception as e: + logger.warning("ha_get_state error: %s", e) + return f"Error: {e}" + + +async def ha_get_states(domain: str = "", area: str = "") -> str: + """List HA entity states, optionally filtered by domain (e.g. 'light') or area name.""" + try: + url, token = _get_ha_cfg() + except ValueError as e: + return str(e) + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.get(f"{url}/api/states", headers=_auth(token)) + + if resp.status_code != 200: + return f"HA API error {resp.status_code}: {resp.text[:400]}" + + states = resp.json() + + if domain: + states = [s for s in states if s.get("entity_id", "").startswith(f"{domain}.")] + if area: + al = area.lower() + states = [s for s in states + if al in (s.get("attributes", {}).get("friendly_name") or "").lower()] + + if not states: + filters = [f"domain={domain}"] * bool(domain) + [f"area={area}"] * bool(area) + return "No entities found" + (f" ({', '.join(filters)})" if filters else "") + + lines = [f"{len(states)} entit{'y' if len(states) == 1 else 'ies'}:"] + for s in sorted(states, key=lambda x: x.get("entity_id", "")): + lines.append(_fmt_state(s)) + return "\n".join(lines) + + except httpx.HTTPError as e: + return f"Connection error: {e}" + except Exception as e: + logger.warning("ha_get_states error: %s", e) + return f"Error: {e}" + + +async def ha_call_service( + domain: str, + service: str, + entity_id: str = "", + data: str = "", +) -> str: + """Call a Home Assistant service (turn on/off lights, set thermostat, lock doors, etc.).""" + try: + url, token = _get_ha_cfg() + except ValueError as e: + return str(e) + + payload: dict = {} + if entity_id: + payload["entity_id"] = entity_id + if data: + try: + extra = json.loads(data) + if isinstance(extra, dict): + payload.update(extra) + except json.JSONDecodeError: + return f"Invalid JSON in data: {data}" + + try: + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + resp = await client.post( + f"{url}/api/services/{domain}/{service}", + headers=_auth(token), + json=payload, + ) + + if resp.status_code not in (200, 201): + return f"HA API error {resp.status_code}: {resp.text[:400]}" + + changed = resp.json() + if not changed: + return f"✓ {domain}.{service} called (no state changes reported)." + + lines = [f"✓ {domain}.{service} — {len(changed)} entity state(s) updated:"] + for s in changed: + lines.append(f" {s.get('entity_id', '')}: {s.get('state', '')}") + return "\n".join(lines) + + except httpx.HTTPError as e: + return f"Connection error: {e}" + except Exception as e: + logger.warning("ha_call_service error: %s", e) + return f"Error: {e}" + + +DECLARATIONS = [ + types.FunctionDeclaration( + name="ha_get_state", + description=( + "Get the current state and attributes of a single Home Assistant entity. " + "Use to check if a light is on, read a thermostat temperature, check a " + "door/window sensor, battery level, HVAC mode, etc. " + "entity_id format: domain.name — e.g. light.living_room, switch.garage, " + "climate.ecobee, binary_sensor.front_door, sensor.outdoor_temp." + ), + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "entity_id": types.Schema( + type=types.Type.STRING, + description="Full entity ID, e.g. light.living_room or climate.ecobee_main", + ), + }, + required=["entity_id"], + ), + ), + types.FunctionDeclaration( + name="ha_get_states", + description=( + "List Home Assistant entity states, optionally filtered by domain or area. " + "Use to survey what devices exist or check multiple entities at once. " + "Domain examples: light, switch, sensor, climate, binary_sensor, lock, cover, " + "media_player, input_boolean. Leave both blank to list everything (can be large)." + ), + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "domain": types.Schema( + type=types.Type.STRING, + description="Filter to this domain, e.g. 'light' or 'switch' (optional)", + ), + "area": types.Schema( + type=types.Type.STRING, + description="Filter by area name substring match on friendly name (optional)", + ), + }, + ), + ), + types.FunctionDeclaration( + name="ha_call_service", + description=( + "Call a Home Assistant service to control a device or trigger an automation. " + "Requires user confirmation before executing. Common examples: " + "domain=light service=turn_on entity_id=light.living_room; " + "domain=light service=turn_off entity_id=light.all; " + "domain=switch service=toggle entity_id=switch.garage; " + "domain=climate service=set_temperature data={\"temperature\":72}; " + "domain=lock service=lock entity_id=lock.front_door; " + "domain=script service=turn_on entity_id=script.goodnight." + ), + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "domain": types.Schema( + type=types.Type.STRING, + description="Service domain: light, switch, climate, lock, cover, script, automation, etc.", + ), + "service": types.Schema( + type=types.Type.STRING, + description="Service name: turn_on, turn_off, toggle, set_temperature, lock, unlock, open, close, etc.", + ), + "entity_id": types.Schema( + type=types.Type.STRING, + description="Target entity ID — omit for services that don't target a specific entity", + ), + "data": types.Schema( + type=types.Type.STRING, + description='Extra service data as JSON string, e.g. {"temperature": 72, "hvac_mode": "heat"}', + ), + }, + required=["domain", "service"], + ), + ), +] diff --git a/documentation/DESIGN__Model_Registry_V2.md b/documentation/DESIGN__Model_Registry_V2.md index b9f79a7..f2d2159 100644 --- a/documentation/DESIGN__Model_Registry_V2.md +++ b/documentation/DESIGN__Model_Registry_V2.md @@ -90,7 +90,8 @@ Stored in `home/{user}/model_registry.json`. "models": [ {"id": "m1", "type": "claude_cli", "label": "Sonnet 4.6 (CLI)", "model_name": "claude-sonnet-4-6", "provider": "anthropic", "credential_id": "cli", "context_k": 1000, "tags": []}, {"id": "m2", "type": "gemini_api", "label": "Gemini 2.5 Flash", "model_name": "gemini-2.5-flash", "provider": "google", "account_id": "a1b2", "context_k": 1000, "tags": []}, - {"id": "m3", "type": "local_openai", "label": "Gemma 4 E4B", "model_name": "gemma4:e4b", "provider": "local", "host_id": "h1", "context_k": 72, "tags": []} + {"id": "m3", "type": "local_openai", "label": "Gemma 4 E4B", "model_name": "gemma4:e4b", "provider": "local", "host_id": "h1", "context_k": 72, "tags": []}, + {"id": "m4", "type": "local_openai", "label": "DeepSeek: V4 Flash", "model_name": "deepseek/deepseek-v4-flash", "provider": "local", "host_id": "h1", "context_k": 750, "reasoning_budget_tokens": 4096, "tags": ["frontier"]} ], "roles": { "chat": {"primary": "m1", "backup_1": "m2", "backup_2": "m3"}, @@ -109,6 +110,15 @@ Stored in `home/{user}/model_registry.json`. | `gemini_api` | Currently: Gemini CLI (gap — see Phase 4) | Should use google-genai SDK | | `local_openai` | HTTP to OpenAI-compatible endpoint | host_type controls path | +### Optional model fields + +| Field | Type | Default | Meaning | +|---|---|---|---| +| `context_k` | int | 32 | Context window in thousands of tokens. Used for compaction budget (75% of window). | +| `max_rounds` | int \| null | null | Per-model tool loop cap. `null` = use global `orchestrator_max_rounds`. Effective limit = `min(per_model, global)`. | +| `tools` | bool | true | Whether this model supports tool calling. `false` = skip tool loop entirely; model gets a plain chat request. | +| `reasoning_budget_tokens` | int \| null | null | Per-model reasoning/thinking budget for models that support it (e.g., DeepSeek V4 via OpenRouter). `null` = no reasoning override. When set, injected as `{"reasoning": {"budget_tokens": }}` in the API call to OpenRouter-compatible endpoints. | + ### Built-in model IDs Always resolvable without a registry entry (used as `.env` role defaults): @@ -196,4 +206,4 @@ the orchestrator role can now be a local model. - Claude direct API key support (alternative to CLI OAuth) - OpenRouter as a named provider (already works as local host; could be promoted) - Per-role "test" button in role assignments UI -- Per-user catalog additions (extend ANTHROPIC_CATALOG / GOOGLE_CATALOG from UI) +- Per-user catalog additions (extend ANTHROPIC_CATALOG / GOOGLE_CATALOG from UI) \ No newline at end of file