Merge pull request #18 from techartdev/papurha/safe-fixes-and-reworks

Fix model selection, cleanup dead schema, and rework Assist agent routing
This commit is contained in:
TechArtDev
2026-04-04 16:31:46 +03:00
committed by GitHub
11 changed files with 121 additions and 138 deletions
+13 -1
View File
@@ -28,6 +28,7 @@ OpenClaw is a Home Assistant custom integration that connects your HA instance t
- `openclaw.send_message`
- `openclaw.clear_history`
- `openclaw.invoke_tool`
- **Integration options** for model selection and voice-specific routing
- **Event**
- `openclaw_message_received`
- `openclaw_tool_invoked`
@@ -192,6 +193,13 @@ When enabled, OpenClaw tool-call responses can execute Home Assistant services.
- **Wake word enabled**
- **Wake word** (default: `hey openclaw`)
- **Voice input provider** (`browser` or `assist_stt`)
- **Voice agent ID** (optional)
### Model selection
- **Active Model** select entity controls the model override used for chat-card and Assist requests.
- If no model is selected, the gateway default is used.
- Assist conversation IDs are conservatively namespaced by agent ID so different agents do not accidentally share the same conversation context.
### Voice provider usage
@@ -227,11 +235,13 @@ If voice is unreliable in Brave, use Chrome/Edge for card voice input or continu
Send a message to OpenClaw.
> Note: file attachments are not currently supported by this service. The old `attachments` field was removed because it was accepted by the schema but never sent to the gateway.
Fields:
- `message` (required)
- `session_id` (optional)
- `attachments` (optional)
- `agent_id` (optional)
Example:
@@ -313,6 +323,8 @@ action:
Fired when `openclaw.invoke_tool` completes.
The integration also exposes native Event entities for both message-received and tool-invoked events so they can be selected directly in the automation UI.
Event data includes:
- `tool`
+8 -51
View File
@@ -31,7 +31,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .api import OpenClawApiClient, OpenClawApiError
from .const import (
ATTR_AGENT_ID,
ATTR_ATTACHMENTS,
ATTR_MESSAGE,
ATTR_MODEL,
ATTR_OK,
@@ -90,6 +89,7 @@ from .const import (
)
from .coordinator import OpenClawCoordinator
from .exposure import apply_context_policy, build_exposed_entities_context
from .helpers import extract_text_recursive
_LOGGER = logging.getLogger(__name__)
@@ -98,6 +98,7 @@ _MAX_CHAT_HISTORY = 200
_VOICE_REQUEST_HEADERS = {
"x-openclaw-source": "voice",
"x-ha-voice": "true",
"x-openclaw-message-channel": "voice",
}
# Path to the chat card JS inside the integration package (custom_components/openclaw/www/)
@@ -106,7 +107,7 @@ _CARD_PATH = Path(__file__).parent / "www" / _CARD_FILENAME
# URL at which the card JS is served (registered via register_static_path)
_CARD_STATIC_URL = f"/openclaw/{_CARD_FILENAME}"
# Versioned URL used for Lovelace resource registration to avoid stale browser cache
_CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.60"
_CARD_URL = f"{_CARD_STATIC_URL}?v=0.1.61"
OpenClawConfigEntry = ConfigEntry
@@ -117,7 +118,6 @@ SEND_MESSAGE_SCHEMA = vol.Schema(
vol.Required(ATTR_MESSAGE): cv.string,
vol.Optional(ATTR_SOURCE): cv.string,
vol.Optional(ATTR_SESSION_ID): cv.string,
vol.Optional(ATTR_ATTACHMENTS): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(ATTR_AGENT_ID): cv.string,
}
)
@@ -447,6 +447,8 @@ def _async_register_services(hass: HomeAssistant) -> None:
)
system_prompt = apply_context_policy(raw_context, max_chars, strategy)
active_model = _normalize_optional_text(options.get("active_model"))
_append_chat_history(hass, session_id, "user", message)
response = await client.async_send_message(
@@ -454,6 +456,7 @@ def _async_register_services(hass: HomeAssistant) -> None:
session_id=session_id,
system_prompt=system_prompt,
agent_id=resolved_agent_id,
model=active_model,
extra_headers=extra_headers,
)
@@ -469,6 +472,7 @@ def _async_register_services(hass: HomeAssistant) -> None:
session_id=session_id,
system_prompt=system_prompt,
agent_id=resolved_agent_id,
model=active_model,
extra_headers=extra_headers,
)
@@ -635,53 +639,6 @@ def _get_entry_options(hass: HomeAssistant, entry_data: dict[str, Any]) -> dict[
return latest_entry.options if latest_entry else {}
def _extract_text_recursive(value: Any, depth: int = 0) -> str | None:
"""Recursively extract assistant text from nested response payloads."""
if depth > 8:
return None
if isinstance(value, str):
text = value.strip()
return text or None
if isinstance(value, list):
parts: list[str] = []
for item in value:
extracted = _extract_text_recursive(item, depth + 1)
if extracted:
parts.append(extracted)
if parts:
return "\n".join(parts)
return None
if isinstance(value, dict):
priority_keys = (
"output_text",
"text",
"content",
"message",
"response",
"answer",
"choices",
"output",
"delta",
)
for key in priority_keys:
if key not in value:
continue
extracted = _extract_text_recursive(value.get(key), depth + 1)
if extracted:
return extracted
for nested_value in value.values():
extracted = _extract_text_recursive(nested_value, depth + 1)
if extracted:
return extracted
return None
def _summarize_tool_result(value: Any, max_len: int = 240) -> str | None:
"""Return compact string preview of tool result payload."""
if value is None:
@@ -703,7 +660,7 @@ def _summarize_tool_result(value: Any, max_len: int = 240) -> str | None:
def _extract_assistant_message(response: dict[str, Any]) -> str | None:
"""Extract assistant text from modern/legacy OpenAI-compatible responses."""
return _extract_text_recursive(response)
return extract_text_recursive(response)
def _extract_tool_calls(response: dict[str, Any]) -> list[dict[str, Any]]:
+5
View File
@@ -104,6 +104,11 @@ class OpenClawApiClient:
async def _get_session(self) -> aiohttp.ClientSession:
"""Get or create an aiohttp session."""
if self._session is None or self._session.closed:
if self._session is not None and self._session.closed:
_LOGGER.warning(
"Primary aiohttp session unavailable — creating fallback session. "
"This may bypass HA connection management"
)
self._session = aiohttp.ClientSession()
return self._session
+35 -58
View File
@@ -24,11 +24,13 @@ from .const import (
ATTR_SESSION_ID,
ATTR_TIMESTAMP,
CONF_ASSIST_SESSION_ID,
CONF_AGENT_ID,
CONF_CONTEXT_MAX_CHARS,
CONF_CONTEXT_STRATEGY,
CONF_INCLUDE_EXPOSED_CONTEXT,
CONF_VOICE_AGENT_ID,
DEFAULT_ASSIST_SESSION_ID,
DEFAULT_AGENT_ID,
DEFAULT_CONTEXT_MAX_CHARS,
DEFAULT_CONTEXT_STRATEGY,
DEFAULT_INCLUDE_EXPOSED_CONTEXT,
@@ -38,6 +40,7 @@ from .const import (
)
from .coordinator import OpenClawCoordinator
from .exposure import apply_context_policy, build_exposed_entities_context
from .helpers import extract_text_recursive
_LOGGER = logging.getLogger(__name__)
@@ -117,12 +120,20 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent):
coordinator: OpenClawCoordinator = entry_data["coordinator"]
message = user_input.text
conversation_id = self._resolve_conversation_id(user_input)
assistant_id = "conversation"
options = self.entry.options
voice_agent_id = self._normalize_optional_text(
options.get(CONF_VOICE_AGENT_ID)
)
configured_agent_id = self._normalize_optional_text(
options.get(
CONF_AGENT_ID,
self.entry.data.get(CONF_AGENT_ID, DEFAULT_AGENT_ID),
)
)
resolved_agent_id = voice_agent_id or configured_agent_id
conversation_id = self._resolve_conversation_id(user_input, resolved_agent_id)
active_model = self._normalize_optional_text(options.get("active_model"))
include_context = options.get(
CONF_INCLUDE_EXPOSED_CONTEXT,
DEFAULT_INCLUDE_EXPOSED_CONTEXT,
@@ -149,8 +160,9 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent):
client,
message,
conversation_id,
voice_agent_id,
resolved_agent_id,
system_prompt,
active_model,
)
except OpenClawApiError as err:
_LOGGER.error("OpenClaw conversation error: %s", err)
@@ -165,8 +177,9 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent):
client,
message,
conversation_id,
voice_agent_id,
resolved_agent_id,
system_prompt,
active_model,
)
except OpenClawApiError as retry_err:
return self._error_result(
@@ -205,8 +218,12 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent):
continue_conversation=self._should_continue(full_response),
)
def _resolve_conversation_id(self, user_input: conversation.ConversationInput) -> str:
"""Return conversation id from HA or a stable Assist fallback session key."""
def _resolve_conversation_id(
self,
user_input: conversation.ConversationInput,
agent_id: str | None,
) -> str:
"""Return conversation id from HA with conservative agent namespacing."""
configured_session_id = self._normalize_optional_text(
self.entry.options.get(
CONF_ASSIST_SESSION_ID,
@@ -216,19 +233,25 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent):
if configured_session_id:
return configured_session_id
agent_suffix = self._normalize_optional_text(agent_id)
if user_input.conversation_id:
if agent_suffix:
return f"{user_input.conversation_id}:{agent_suffix}"
return user_input.conversation_id
context = getattr(user_input, "context", None)
user_id = getattr(context, "user_id", None)
if user_id:
return f"assist_user_{user_id}"
base_id = f"assist_user_{user_id}"
return f"{base_id}:{agent_suffix}" if agent_suffix else base_id
device_id = getattr(user_input, "device_id", None)
if device_id:
return f"assist_device_{device_id}"
base_id = f"assist_device_{device_id}"
return f"{base_id}:{agent_suffix}" if agent_suffix else base_id
return "assist_default"
return f"assist_default:{agent_suffix}" if agent_suffix else "assist_default"
def _normalize_optional_text(self, value: Any) -> str | None:
"""Return a stripped string or None for blank values."""
@@ -244,13 +267,14 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent):
conversation_id: str,
agent_id: str | None = None,
system_prompt: str | None = None,
model: str | None = None,
) -> str:
"""Get a response from OpenClaw, trying streaming first."""
# Try streaming (lower TTFB for voice pipeline)
full_response = ""
async for chunk in client.async_stream_message(
message=message,
session_id=conversation_id,
model=model,
system_prompt=system_prompt,
agent_id=agent_id,
extra_headers=_VOICE_REQUEST_HEADERS,
@@ -260,62 +284,15 @@ class OpenClawConversationAgent(conversation.AbstractConversationAgent):
if full_response:
return full_response
# Fallback to non-streaming
response = await client.async_send_message(
message=message,
session_id=conversation_id,
model=model,
system_prompt=system_prompt,
agent_id=agent_id,
extra_headers=_VOICE_REQUEST_HEADERS,
)
extracted = self._extract_text_recursive(response)
return extracted or ""
def _extract_text_recursive(self, value: Any, depth: int = 0) -> str | None:
"""Recursively extract assistant text from nested response payloads."""
if depth > 8:
return None
if isinstance(value, str):
text = value.strip()
return text or None
if isinstance(value, list):
parts: list[str] = []
for item in value:
extracted = self._extract_text_recursive(item, depth + 1)
if extracted:
parts.append(extracted)
if parts:
return "\n".join(parts)
return None
if isinstance(value, dict):
priority_keys = (
"output_text",
"text",
"content",
"message",
"response",
"answer",
"choices",
"output",
"delta",
)
for key in priority_keys:
if key not in value:
continue
extracted = self._extract_text_recursive(value.get(key), depth + 1)
if extracted:
return extracted
for nested_value in value.values():
extracted = self._extract_text_recursive(nested_value, depth + 1)
if extracted:
return extracted
return None
return extract_text_recursive(response) or ""
@staticmethod
def _should_continue(response: str) -> bool:
+5 -10
View File
@@ -14,7 +14,7 @@ from typing import Any
from homeassistant.components.event import EventEntity, EventEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
@@ -53,10 +53,6 @@ async def async_setup_entry(
]
async_add_entities(entities)
# Wire HA bus events → entity triggers
for entity in entities:
entity.async_start_listening(hass)
class OpenClawEventEntity(EventEntity):
"""Event entity that mirrors HA bus events into the entity registry."""
@@ -78,11 +74,10 @@ class OpenClawEventEntity(EventEntity):
"model": "OpenClaw Gateway",
}
self._entry_id = entry.entry_id
self._unsub: callback | None = None
self._unsub = None
@callback
def async_start_listening(self, hass: HomeAssistant) -> None:
"""Subscribe to the matching HA bus event."""
async def async_added_to_hass(self) -> None:
"""Subscribe to the matching HA bus event after the entity is added."""
key = self.entity_description.key
if key == "message_received":
@@ -103,7 +98,7 @@ class OpenClawEventEntity(EventEntity):
self._trigger_event(event_type, data)
self.async_write_ha_state()
self._unsub = hass.bus.async_listen(bus_event, _handle_event)
self._unsub = self.hass.bus.async_listen(bus_event, _handle_event)
async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe when entity is removed."""
+52
View File
@@ -0,0 +1,52 @@
"""Shared helper utilities for the OpenClaw integration."""
from __future__ import annotations
from typing import Any
def extract_text_recursive(value: Any, depth: int = 0) -> str | None:
"""Recursively extract assistant text from nested response payloads."""
if depth > 8:
return None
if isinstance(value, str):
text = value.strip()
return text or None
if isinstance(value, list):
parts: list[str] = []
for item in value:
extracted = extract_text_recursive(item, depth + 1)
if extracted:
parts.append(extracted)
if parts:
return "\n".join(parts)
return None
if isinstance(value, dict):
priority_keys = (
"output_text",
"text",
"content",
"message",
"response",
"answer",
"choices",
"output",
"delta",
)
for key in priority_keys:
if key not in value:
continue
extracted = extract_text_recursive(value.get(key), depth + 1)
if extracted:
return extracted
for nested_value in value.values():
extracted = extract_text_recursive(nested_value, depth + 1)
if extracted:
return extracted
return None
+2 -2
View File
@@ -3,10 +3,10 @@
"name": "OpenClaw",
"codeowners": ["@techartdev"],
"config_flow": true,
"documentation": "https://github.com/techartdev/OpenClawHomeAssistant",
"documentation": "https://github.com/techartdev/OpenClawHomeAssistantIntegration",
"integration_type": "hub",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistant/issues",
"issue_tracker": "https://github.com/techartdev/OpenClawHomeAssistantIntegration/issues",
"requirements": [],
"version": "0.1.61",
"dependencies": ["conversation"],
-7
View File
@@ -17,13 +17,6 @@ send_message:
example: "my-automation-session"
selector:
text:
attachments:
name: Attachments
description: Optional list of file paths to attach to the message.
required: false
selector:
text:
multiline: true
agent_id:
name: Agent ID
description: >
-4
View File
@@ -125,10 +125,6 @@
"name": "Session ID",
"description": "Optional session ID for conversation context."
},
"attachments": {
"name": "Attachments",
"description": "Optional file attachments."
},
"agent_id": {
"name": "Agent ID",
"description": "Optional OpenClaw agent ID to route this message to. Overrides the configured default. Defaults to \"main\"."
@@ -127,10 +127,6 @@
"name": "Session ID",
"description": "Optional session ID for conversation context."
},
"attachments": {
"name": "Attachments",
"description": "Optional file attachments."
},
"agent_id": {
"name": "Agent ID",
"description": "Optional OpenClaw agent ID to route this message to. Overrides the configured default. Defaults to \"main\"."
+1 -1
View File
@@ -1,7 +1,7 @@
(async () => {
try {
if (!customElements.get("openclaw-chat-card")) {
const src = "/openclaw/openclaw-chat-card.js?v=0.1.55";
const src = "/openclaw/openclaw-chat-card.js?v=0.1.61";
console.info("OpenClaw loader importing", src);
await import(src);
}