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 <noreply@anthropic.com>
This commit is contained in:
@@ -384,6 +384,16 @@
|
|||||||
updateSendBtnTitle();
|
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 dropdown ─────────────────────────────────────────
|
||||||
settings_btn_el.addEventListener('click', (e) => {
|
settings_btn_el.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -1148,7 +1158,7 @@
|
|||||||
// ── Chat fetch + SSE handler ─────────────────────────────────
|
// ── Chat fetch + SSE handler ─────────────────────────────────
|
||||||
// Extracted so the retry button can call it without re-adding the
|
// Extracted so the retry button can call it without re-adding the
|
||||||
// user message to the DOM or currentHistory.
|
// user message to the DOM or currentHistory.
|
||||||
async function _doSend(payload, thinkingDiv) {
|
async function _doSend(payload, thinkingDiv, wasNewSession = false) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/chat', {
|
const res = await fetch('/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1238,13 +1248,13 @@
|
|||||||
activeController = new AbortController();
|
activeController = new AbortController();
|
||||||
sendBtn.style.display = 'none';
|
sendBtn.style.display = 'none';
|
||||||
stopBtn.style.display = 'flex';
|
stopBtn.style.display = 'flex';
|
||||||
headerEmoji.classList.add('processing');
|
setProcessing(true);
|
||||||
startRunTimer();
|
startRunTimer();
|
||||||
|
|
||||||
await _doSend(payload, thinkingDiv);
|
await _doSend(payload, thinkingDiv, false);
|
||||||
|
|
||||||
activeController = null;
|
activeController = null;
|
||||||
headerEmoji.classList.remove('processing');
|
setProcessing(false);
|
||||||
sendBtn.style.display = 'block';
|
sendBtn.style.display = 'block';
|
||||||
stopBtn.style.display = 'none';
|
stopBtn.style.display = 'none';
|
||||||
stopRunTimer();
|
stopRunTimer();
|
||||||
@@ -1265,7 +1275,7 @@
|
|||||||
syncHeight();
|
syncHeight();
|
||||||
sendBtn.style.display = 'none';
|
sendBtn.style.display = 'none';
|
||||||
stopBtn.style.display = 'flex';
|
stopBtn.style.display = 'flex';
|
||||||
headerEmoji.classList.add('processing');
|
setProcessing(true);
|
||||||
startRunTimer();
|
startRunTimer();
|
||||||
|
|
||||||
activeController = new AbortController();
|
activeController = new AbortController();
|
||||||
@@ -1294,10 +1304,10 @@
|
|||||||
persona: CORTEX_PERSONA,
|
persona: CORTEX_PERSONA,
|
||||||
};
|
};
|
||||||
|
|
||||||
await _doSend(payload, thinkingDiv);
|
await _doSend(payload, thinkingDiv, wasNewSession);
|
||||||
|
|
||||||
activeController = null;
|
activeController = null;
|
||||||
headerEmoji.classList.remove('processing');
|
setProcessing(false);
|
||||||
sendBtn.style.display = 'block';
|
sendBtn.style.display = 'block';
|
||||||
stopBtn.style.display = 'none';
|
stopBtn.style.display = 'none';
|
||||||
stopRunTimer();
|
stopRunTimer();
|
||||||
@@ -1437,13 +1447,13 @@
|
|||||||
activeController = new AbortController();
|
activeController = new AbortController();
|
||||||
sendBtn.style.display = 'none';
|
sendBtn.style.display = 'none';
|
||||||
stopBtn.style.display = 'flex';
|
stopBtn.style.display = 'flex';
|
||||||
headerEmoji.classList.add('processing');
|
setProcessing(true);
|
||||||
startRunTimer();
|
startRunTimer();
|
||||||
|
|
||||||
await _doOrchestrate(text, thinkingDiv, userMsgDiv);
|
await _doOrchestrate(text, thinkingDiv, userMsgDiv);
|
||||||
|
|
||||||
activeController = null;
|
activeController = null;
|
||||||
headerEmoji.classList.remove('processing');
|
setProcessing(false);
|
||||||
sendBtn.style.display = 'block';
|
sendBtn.style.display = 'block';
|
||||||
stopBtn.style.display = 'none';
|
stopBtn.style.display = 'none';
|
||||||
stopRunTimer();
|
stopRunTimer();
|
||||||
@@ -1462,7 +1472,7 @@
|
|||||||
syncHeight();
|
syncHeight();
|
||||||
sendBtn.style.display = 'none';
|
sendBtn.style.display = 'none';
|
||||||
stopBtn.style.display = 'flex';
|
stopBtn.style.display = 'flex';
|
||||||
headerEmoji.classList.add('processing');
|
setProcessing(true);
|
||||||
startRunTimer();
|
startRunTimer();
|
||||||
|
|
||||||
activeController = new AbortController();
|
activeController = new AbortController();
|
||||||
@@ -1476,7 +1486,7 @@
|
|||||||
await _doOrchestrate(text, thinkingDiv, userMsgDiv);
|
await _doOrchestrate(text, thinkingDiv, userMsgDiv);
|
||||||
|
|
||||||
activeController = null;
|
activeController = null;
|
||||||
headerEmoji.classList.remove('processing');
|
setProcessing(false);
|
||||||
sendBtn.style.display = 'block';
|
sendBtn.style.display = 'block';
|
||||||
stopBtn.style.display = 'none';
|
stopBtn.style.display = 'none';
|
||||||
stopRunTimer();
|
stopRunTimer();
|
||||||
@@ -2215,4 +2225,4 @@
|
|||||||
|
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -142,6 +142,15 @@
|
|||||||
|
|
||||||
.header-emoji.processing { animation: shimmer 0.75s ease-in-out infinite; }
|
.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 .name { font-size: 1.1rem; font-weight: 600; color: var(--accent); }
|
||||||
header .subtitle { font-size: 0.78rem; color: var(--muted); }
|
header .subtitle { font-size: 0.78rem; color: var(--muted); }
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ from tools.agent_notes import (
|
|||||||
agent_notes_clear as _agent_notes_clear,
|
agent_notes_clear as _agent_notes_clear,
|
||||||
)
|
)
|
||||||
from tools.agents import spawn_agent as _spawn_agent
|
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 ───────────────────────────────────────────────────────
|
# ── Declaration imports ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -85,7 +90,8 @@ import tools.reminders as _mod_reminders
|
|||||||
import tools.scratch as _mod_scratch
|
import tools.scratch as _mod_scratch
|
||||||
import tools.notify as _mod_notify
|
import tools.notify as _mod_notify
|
||||||
import tools.agent_notes as _mod_agent_notes
|
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 ───
|
# ── 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"],
|
"Aether Tasks": ["ae_task_list"],
|
||||||
"Agent Notes": ["agent_notes_read", "agent_notes_write", "agent_notes_append", "agent_notes_clear"],
|
"Agent Notes": ["agent_notes_read", "agent_notes_write", "agent_notes_append", "agent_notes_clear"],
|
||||||
"Agents": ["spawn_agent"],
|
"Agents": ["spawn_agent"],
|
||||||
|
"Home Assistant": ["ha_get_state", "ha_get_states", "ha_call_service"],
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Callable registry ─────────────────────────────────────────────────────────
|
# ── Callable registry ─────────────────────────────────────────────────────────
|
||||||
@@ -164,6 +171,9 @@ _CALLABLES: dict[str, callable] = {
|
|||||||
"agent_notes_append": _agent_notes_append,
|
"agent_notes_append": _agent_notes_append,
|
||||||
"agent_notes_clear": _agent_notes_clear,
|
"agent_notes_clear": _agent_notes_clear,
|
||||||
"spawn_agent": _spawn_agent,
|
"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 ─────────────────────────────────────────────────
|
# ── Role-based access control ─────────────────────────────────────────────────
|
||||||
@@ -185,6 +195,7 @@ TOOL_ROLES: dict[str, str] = {
|
|||||||
"nc_talk_send": "admin",
|
"nc_talk_send": "admin",
|
||||||
"http_post": "admin",
|
"http_post": "admin",
|
||||||
"nc_talk_history": "admin",
|
"nc_talk_history": "admin",
|
||||||
|
"ha_call_service": "admin",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Tools that require explicit user confirmation before executing.
|
# Tools that require explicit user confirmation before executing.
|
||||||
@@ -196,6 +207,7 @@ CONFIRM_REQUIRED: set[str] = {
|
|||||||
"cron_remove",
|
"cron_remove",
|
||||||
"reminders_clear",
|
"reminders_clear",
|
||||||
"http_post",
|
"http_post",
|
||||||
|
"ha_call_service",
|
||||||
}
|
}
|
||||||
|
|
||||||
_ROLE_RANK: dict[str, int] = {"user": 0, "admin": 1}
|
_ROLE_RANK: dict[str, int] = {"user": 0, "admin": 1}
|
||||||
@@ -221,6 +233,7 @@ _ALL_DECLARATIONS: list[types.FunctionDeclaration] = (
|
|||||||
+ _mod_ae_tasks.DECLARATIONS
|
+ _mod_ae_tasks.DECLARATIONS
|
||||||
+ _mod_agent_notes.DECLARATIONS
|
+ _mod_agent_notes.DECLARATIONS
|
||||||
+ _mod_agents.DECLARATIONS
|
+ _mod_agents.DECLARATIONS
|
||||||
|
+ _mod_homeassistant.DECLARATIONS
|
||||||
)
|
)
|
||||||
|
|
||||||
# Full Gemini Tool object (all tools — use get_tools_for_role() in production)
|
# Full Gemini Tool object (all tools — use get_tools_for_role() in production)
|
||||||
|
|||||||
277
cortex/tools/homeassistant.py
Normal file
277
cortex/tools/homeassistant.py
Normal file
@@ -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": "<long-lived-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"],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -90,7 +90,8 @@ Stored in `home/{user}/model_registry.json`.
|
|||||||
"models": [
|
"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": "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": "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": {
|
"roles": {
|
||||||
"chat": {"primary": "m1", "backup_1": "m2", "backup_2": "m3"},
|
"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 |
|
| `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 |
|
| `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": <value>}}` in the API call to OpenRouter-compatible endpoints. |
|
||||||
|
|
||||||
### Built-in model IDs
|
### Built-in model IDs
|
||||||
|
|
||||||
Always resolvable without a registry entry (used as `.env` role defaults):
|
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)
|
- Claude direct API key support (alternative to CLI OAuth)
|
||||||
- OpenRouter as a named provider (already works as local host; could be promoted)
|
- OpenRouter as a named provider (already works as local host; could be promoted)
|
||||||
- Per-role "test" button in role assignments UI
|
- 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)
|
||||||
Reference in New Issue
Block a user