Move HA user agent routing to options

This commit is contained in:
2026-05-06 14:39:46 +02:00
parent 2a02c18b86
commit 1624bb9ff7
7 changed files with 155 additions and 19 deletions
+40 -5
View File
@@ -50,9 +50,11 @@ from .const import (
ATTR_HA_USER_ID, ATTR_HA_USER_ID,
CONF_ADDON_CONFIG_PATH, CONF_ADDON_CONFIG_PATH,
CONF_AGENT_ID, CONF_AGENT_ID,
CONF_FALLBACK_AGENT_ID,
CONF_GATEWAY_HOST, CONF_GATEWAY_HOST,
CONF_GATEWAY_PORT, CONF_GATEWAY_PORT,
CONF_GATEWAY_TOKEN, CONF_GATEWAY_TOKEN,
CONF_USER_AGENT_MAP,
CONF_USE_SSL, CONF_USE_SSL,
CONF_VERIFY_SSL, CONF_VERIFY_SSL,
CONF_CONTEXT_MAX_CHARS, CONF_CONTEXT_MAX_CHARS,
@@ -90,6 +92,7 @@ from .coordinator import OpenClawCoordinator
from .exposure import apply_context_policy, build_exposed_entities_context from .exposure import apply_context_policy, build_exposed_entities_context
from .helpers import extract_text_recursive from .helpers import extract_text_recursive
from .routing import ( from .routing import (
DEFAULT_CHAT_AGENT_ID,
build_scoped_session_id, build_scoped_session_id,
build_scoped_session_prefix, build_scoped_session_prefix,
normalize_ha_user_id, normalize_ha_user_id,
@@ -427,7 +430,11 @@ async def _async_send_openclaw_chat(
client: OpenClawApiClient = entry_data["client"] client: OpenClawApiClient = entry_data["client"]
coordinator: OpenClawCoordinator = entry_data["coordinator"] coordinator: OpenClawCoordinator = entry_data["coordinator"]
options = _get_entry_options(hass, entry_data) options = _get_entry_options(hass, entry_data)
resolved_agent_id = resolve_agent_id(ha_user_id, call_agent_id) resolved_agent_id = _resolve_agent_id_from_options(
options,
ha_user_id,
call_agent_id,
)
scoped_session_id = build_scoped_session_id( scoped_session_id = build_scoped_session_id(
raw_session_id, raw_session_id,
ha_user_id, ha_user_id,
@@ -524,7 +531,13 @@ def _async_register_services(hass: HomeAssistant) -> None:
raw_session_id: str = call.data.get(ATTR_SESSION_ID) or "default" raw_session_id: str = call.data.get(ATTR_SESSION_ID) or "default"
ha_user_id = normalize_ha_user_id(call.context.user_id) ha_user_id = normalize_ha_user_id(call.context.user_id)
call_agent_id = normalize_optional_text(call.data.get(ATTR_AGENT_ID)) call_agent_id = normalize_optional_text(call.data.get(ATTR_AGENT_ID))
resolved_agent_id = resolve_agent_id(ha_user_id, call_agent_id) entry_data = _get_first_entry_data(hass)
options = _get_entry_options(hass, entry_data) if entry_data else {}
resolved_agent_id = _resolve_agent_id_from_options(
options,
ha_user_id,
call_agent_id,
)
scoped_session_id = build_scoped_session_id( scoped_session_id = build_scoped_session_id(
raw_session_id, raw_session_id,
ha_user_id, ha_user_id,
@@ -568,7 +581,13 @@ def _async_register_services(hass: HomeAssistant) -> None:
raw_session_id: str | None = call.data.get(ATTR_SESSION_ID) raw_session_id: str | None = call.data.get(ATTR_SESSION_ID)
ha_user_id = normalize_ha_user_id(call.context.user_id) ha_user_id = normalize_ha_user_id(call.context.user_id)
requested_agent_id = normalize_optional_text(call.data.get(ATTR_AGENT_ID)) requested_agent_id = normalize_optional_text(call.data.get(ATTR_AGENT_ID))
agent_id = resolve_agent_id(ha_user_id, requested_agent_id) entry_data = _get_first_entry_data(hass)
options = _get_entry_options(hass, entry_data) if entry_data else {}
agent_id = _resolve_agent_id_from_options(
options,
ha_user_id,
requested_agent_id,
)
_LOGGER.info( _LOGGER.info(
"Clear history requested (user=%s, agent=%s, session=%s)", "Clear history requested (user=%s, agent=%s, session=%s)",
ha_user_id, ha_user_id,
@@ -707,6 +726,20 @@ def _get_entry_options(hass: HomeAssistant, entry_data: dict[str, Any]) -> dict[
return latest_entry.options if latest_entry else {} return latest_entry.options if latest_entry else {}
def _resolve_agent_id_from_options(
options: dict[str, Any],
ha_user_id: str | None,
explicit_agent_id: str | None = None,
) -> str:
"""Resolve the OpenClaw agent from config entry options."""
return resolve_agent_id(
ha_user_id,
explicit_agent_id,
options.get(CONF_USER_AGENT_MAP),
options.get(CONF_FALLBACK_AGENT_ID, DEFAULT_CHAT_AGENT_ID),
)
def _summarize_tool_result(value: Any, max_len: int = 240) -> str | None: def _summarize_tool_result(value: Any, max_len: int = 240) -> str | None:
"""Return compact string preview of tool result payload.""" """Return compact string preview of tool result payload."""
if value is None: if value is None:
@@ -864,7 +897,9 @@ def _async_register_websocket_api(hass: HomeAssistant) -> None:
raw_session_id = msg.get("session_id") or "default" raw_session_id = msg.get("session_id") or "default"
connection_user = getattr(connection, "user", None) connection_user = getattr(connection, "user", None)
ha_user_id = normalize_ha_user_id(getattr(connection_user, "id", None)) ha_user_id = normalize_ha_user_id(getattr(connection_user, "id", None))
agent_id = resolve_agent_id(ha_user_id) entry_data = _get_first_entry_data(hass)
options = _get_entry_options(hass, entry_data) if entry_data else {}
agent_id = _resolve_agent_id_from_options(options, ha_user_id)
scoped_session_id = build_scoped_session_id( scoped_session_id = build_scoped_session_id(
raw_session_id, raw_session_id,
ha_user_id, ha_user_id,
@@ -939,7 +974,7 @@ def _async_register_websocket_api(hass: HomeAssistant) -> None:
options = _get_entry_options(hass, entry_data) if entry_data else {} options = _get_entry_options(hass, entry_data) if entry_data else {}
connection_user = getattr(connection, "user", None) connection_user = getattr(connection, "user", None)
ha_user_id = normalize_ha_user_id(getattr(connection_user, "id", None)) ha_user_id = normalize_ha_user_id(getattr(connection_user, "id", None))
agent_id = resolve_agent_id(ha_user_id) agent_id = _resolve_agent_id_from_options(options, ha_user_id)
connection.send_result( connection.send_result(
msg["id"], msg["id"],
{ {
+49 -4
View File
@@ -46,6 +46,7 @@ from .const import (
CONF_CONTEXT_MAX_CHARS, CONF_CONTEXT_MAX_CHARS,
CONF_CONTEXT_STRATEGY, CONF_CONTEXT_STRATEGY,
CONF_ENABLE_TOOL_CALLS, CONF_ENABLE_TOOL_CALLS,
CONF_FALLBACK_AGENT_ID,
CONF_INCLUDE_EXPOSED_CONTEXT, CONF_INCLUDE_EXPOSED_CONTEXT,
CONF_WAKE_WORD, CONF_WAKE_WORD,
CONF_WAKE_WORD_ENABLED, CONF_WAKE_WORD_ENABLED,
@@ -54,6 +55,7 @@ from .const import (
CONF_BROWSER_VOICE_LANGUAGE, CONF_BROWSER_VOICE_LANGUAGE,
CONF_VOICE_PROVIDER, CONF_VOICE_PROVIDER,
CONF_THINKING_TIMEOUT, CONF_THINKING_TIMEOUT,
CONF_USER_AGENT_MAP,
BROWSER_VOICE_LANGUAGES, BROWSER_VOICE_LANGUAGES,
CONTEXT_STRATEGY_CLEAR, CONTEXT_STRATEGY_CLEAR,
CONTEXT_STRATEGY_TRUNCATE, CONTEXT_STRATEGY_TRUNCATE,
@@ -75,6 +77,13 @@ from .const import (
DOMAIN, DOMAIN,
OPENCLAW_CONFIG_REL_PATH, OPENCLAW_CONFIG_REL_PATH,
) )
from .routing import (
DEFAULT_CHAT_AGENT_ID,
DEFAULT_HA_USER_AGENT_MAP,
normalize_optional_text,
parse_user_agent_map,
serialize_user_agent_map,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -456,11 +465,35 @@ class OpenClawOptionsFlow(OptionsFlowWithReload):
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Manage integration options.""" """Manage integration options."""
if user_input is not None: errors: dict[str, str] = {}
return self.async_create_entry(title="", data=user_input)
options = self._config_entry.options if user_input is not None:
try:
user_agent_map = parse_user_agent_map(
user_input.get(CONF_USER_AGENT_MAP)
)
except ValueError:
errors[CONF_USER_AGENT_MAP] = "invalid_user_agent_map"
else:
fallback_agent_id = normalize_optional_text(
user_input.get(CONF_FALLBACK_AGENT_ID)
) or DEFAULT_CHAT_AGENT_ID
new_options = dict(self._config_entry.options)
new_options.update(user_input)
new_options[CONF_FALLBACK_AGENT_ID] = fallback_agent_id
new_options[CONF_USER_AGENT_MAP] = serialize_user_agent_map(
user_agent_map
)
return self.async_create_entry(title="", data=new_options)
options = dict(self._config_entry.options)
if user_input is not None and errors:
options.update(user_input)
selected_provider = options.get(CONF_VOICE_PROVIDER, DEFAULT_VOICE_PROVIDER) selected_provider = options.get(CONF_VOICE_PROVIDER, DEFAULT_VOICE_PROVIDER)
user_agent_map_default = options.get(
CONF_USER_AGENT_MAP,
serialize_user_agent_map(DEFAULT_HA_USER_AGENT_MAP),
)
schema: dict[Any, Any] = { schema: dict[Any, Any] = {
vol.Optional( vol.Optional(
@@ -470,6 +503,14 @@ class OpenClawOptionsFlow(OptionsFlowWithReload):
self._config_entry.data.get(CONF_AGENT_ID, DEFAULT_AGENT_ID), self._config_entry.data.get(CONF_AGENT_ID, DEFAULT_AGENT_ID),
), ),
): str, ): str,
vol.Optional(
CONF_FALLBACK_AGENT_ID,
default=options.get(CONF_FALLBACK_AGENT_ID, DEFAULT_CHAT_AGENT_ID),
): str,
vol.Optional(
CONF_USER_AGENT_MAP,
default=user_agent_map_default,
): str,
vol.Optional( vol.Optional(
CONF_VOICE_AGENT_ID, CONF_VOICE_AGENT_ID,
default=options.get( default=options.get(
@@ -557,4 +598,8 @@ class OpenClawOptionsFlow(OptionsFlowWithReload):
) )
] = vol.In(BROWSER_VOICE_LANGUAGES) ] = vol.In(BROWSER_VOICE_LANGUAGES)
return self.async_show_form(step_id="init", data_schema=vol.Schema(schema)) return self.async_show_form(
step_id="init",
data_schema=vol.Schema(schema),
errors=errors,
)
+2
View File
@@ -26,6 +26,8 @@ CONF_ADDON_CONFIG_PATH = "addon_config_path"
CONF_AGENT_ID = "agent_id" CONF_AGENT_ID = "agent_id"
CONF_VOICE_AGENT_ID = "voice_agent_id" CONF_VOICE_AGENT_ID = "voice_agent_id"
CONF_ASSIST_SESSION_ID = "assist_session_id" CONF_ASSIST_SESSION_ID = "assist_session_id"
CONF_FALLBACK_AGENT_ID = "fallback_agent_id"
CONF_USER_AGENT_MAP = "user_agent_map"
# Options # Options
CONF_INCLUDE_EXPOSED_CONTEXT = "include_exposed_context" CONF_INCLUDE_EXPOSED_CONTEXT = "include_exposed_context"
+8 -1
View File
@@ -29,7 +29,9 @@ from .const import (
CONF_ASSIST_SESSION_ID, CONF_ASSIST_SESSION_ID,
CONF_CONTEXT_MAX_CHARS, CONF_CONTEXT_MAX_CHARS,
CONF_CONTEXT_STRATEGY, CONF_CONTEXT_STRATEGY,
CONF_FALLBACK_AGENT_ID,
CONF_INCLUDE_EXPOSED_CONTEXT, CONF_INCLUDE_EXPOSED_CONTEXT,
CONF_USER_AGENT_MAP,
DEFAULT_ASSIST_SESSION_ID, DEFAULT_ASSIST_SESSION_ID,
DEFAULT_CONTEXT_MAX_CHARS, DEFAULT_CONTEXT_MAX_CHARS,
DEFAULT_CONTEXT_STRATEGY, DEFAULT_CONTEXT_STRATEGY,
@@ -42,6 +44,7 @@ from .coordinator import OpenClawCoordinator
from .exposure import apply_context_policy, build_exposed_entities_context from .exposure import apply_context_policy, build_exposed_entities_context
from .helpers import extract_text_recursive from .helpers import extract_text_recursive
from .routing import ( from .routing import (
DEFAULT_CHAT_AGENT_ID,
build_scoped_session_id, build_scoped_session_id,
normalize_ha_user_id, normalize_ha_user_id,
resolve_agent_id, resolve_agent_id,
@@ -130,7 +133,11 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent):
context = getattr(user_input, "context", None) context = getattr(user_input, "context", None)
ha_user_id = normalize_ha_user_id(getattr(context, "user_id", None)) ha_user_id = normalize_ha_user_id(getattr(context, "user_id", None))
options = self.entry.options options = self.entry.options
resolved_agent_id = resolve_agent_id(ha_user_id) resolved_agent_id = resolve_agent_id(
ha_user_id,
user_agent_map=options.get(CONF_USER_AGENT_MAP),
fallback_agent_id=options.get(CONF_FALLBACK_AGENT_ID, DEFAULT_CHAT_AGENT_ID),
)
conversation_id = self._resolve_conversation_id( conversation_id = self._resolve_conversation_id(
user_input, user_input,
ha_user_id, ha_user_id,
+46 -9
View File
@@ -2,20 +2,14 @@
from __future__ import annotations from __future__ import annotations
import json
import re import re
from typing import Any from typing import Any
DEFAULT_CHAT_AGENT_ID = "tetra-home" DEFAULT_CHAT_AGENT_ID = "tetra-home"
SYSTEM_HA_USER_ID = "system" SYSTEM_HA_USER_ID = "system"
HA_USER_AGENT_MAP: dict[str, str] = { DEFAULT_HA_USER_AGENT_MAP: dict[str, str] = {}
# TODO: Move this user-to-agent map into config entry options.
"bd3f666f43f941e886ef007a794eefe8": "main",
"1d2be9a622584cf0abb1c243d12a9959": "family-krzysztof",
"eca5887898544556840878ab37a35fbc": "family-ewa",
"2f5bf9f09478400082380bf7f26966d9": "family-tosia",
"f613d90efe8b4afea63c4a5a7ef7b559": "family-domi",
}
def normalize_optional_text(value: Any) -> str | None: def normalize_optional_text(value: Any) -> str | None:
@@ -34,12 +28,55 @@ def normalize_ha_user_id(value: Any) -> str:
def resolve_agent_id( def resolve_agent_id(
ha_user_id: str | None, ha_user_id: str | None,
explicit_agent_id: str | None = None, explicit_agent_id: str | None = None,
user_agent_map: Any = None,
fallback_agent_id: str | None = None,
) -> str: ) -> str:
"""Resolve the OpenClaw agent for a Home Assistant user.""" """Resolve the OpenClaw agent for a Home Assistant user."""
if explicit_agent_id: if explicit_agent_id:
return explicit_agent_id return explicit_agent_id
normalized_user_id = normalize_ha_user_id(ha_user_id) normalized_user_id = normalize_ha_user_id(ha_user_id)
return HA_USER_AGENT_MAP.get(normalized_user_id, DEFAULT_CHAT_AGENT_ID) try:
routing_map = parse_user_agent_map(user_agent_map)
except ValueError:
routing_map = dict(DEFAULT_HA_USER_AGENT_MAP)
fallback = normalize_optional_text(fallback_agent_id) or DEFAULT_CHAT_AGENT_ID
return routing_map.get(normalized_user_id, fallback)
def parse_user_agent_map(value: Any = None) -> dict[str, str]:
"""Parse a HA-user-id to OpenClaw-agent-id mapping from options."""
if value is None:
return dict(DEFAULT_HA_USER_AGENT_MAP)
if isinstance(value, str):
cleaned = value.strip()
if not cleaned:
return {}
try:
value = json.loads(cleaned)
except json.JSONDecodeError as err:
raise ValueError("User agent map must be a JSON object") from err
if not isinstance(value, dict):
raise ValueError("User agent map must be a JSON object")
parsed: dict[str, str] = {}
for raw_user_id, raw_agent_id in value.items():
user_id = normalize_optional_text(raw_user_id)
agent_id = normalize_optional_text(raw_agent_id)
if user_id and agent_id:
parsed[user_id] = agent_id
return parsed
def serialize_user_agent_map(value: Any = None) -> str:
"""Serialize a user-agent routing map for config entry options."""
return json.dumps(
parse_user_agent_map(value),
ensure_ascii=False,
indent=2,
sort_keys=True,
)
def resolve_model_override(value: Any) -> str | None: def resolve_model_override(value: Any) -> str | None:
+5
View File
@@ -39,6 +39,8 @@
"description": "Configure context and tool-calling behavior.", "description": "Configure context and tool-calling behavior.",
"data": { "data": {
"agent_id": "Agent ID (e.g. main)", "agent_id": "Agent ID (e.g. main)",
"fallback_agent_id": "Fallback agent ID",
"user_agent_map": "HA user to agent map (JSON)",
"voice_agent_id": "Voice agent ID (optional)", "voice_agent_id": "Voice agent ID (optional)",
"assist_session_id": "Assist session ID override (optional)", "assist_session_id": "Assist session ID override (optional)",
"include_exposed_context": "Include exposed entities context", "include_exposed_context": "Include exposed entities context",
@@ -53,6 +55,9 @@
"thinking_timeout": "Response timeout (seconds)" "thinking_timeout": "Response timeout (seconds)"
} }
} }
},
"error": {
"invalid_user_agent_map": "User agent map must be a JSON object whose keys are Home Assistant user IDs and whose values are OpenClaw agent IDs."
} }
}, },
"entity": { "entity": {
@@ -41,6 +41,8 @@
"description": "Configure context and tool-calling behavior.", "description": "Configure context and tool-calling behavior.",
"data": { "data": {
"agent_id": "Agent ID (e.g. main)", "agent_id": "Agent ID (e.g. main)",
"fallback_agent_id": "Fallback agent ID",
"user_agent_map": "HA user to agent map (JSON)",
"voice_agent_id": "Voice agent ID (optional)", "voice_agent_id": "Voice agent ID (optional)",
"assist_session_id": "Assist session ID override (optional)", "assist_session_id": "Assist session ID override (optional)",
"include_exposed_context": "Include exposed entities context", "include_exposed_context": "Include exposed entities context",
@@ -55,6 +57,9 @@
"thinking_timeout": "Response timeout (seconds)" "thinking_timeout": "Response timeout (seconds)"
} }
} }
},
"error": {
"invalid_user_agent_map": "User agent map must be a JSON object whose keys are Home Assistant user IDs and whose values are OpenClaw agent IDs."
} }
}, },
"entity": { "entity": {