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:
@@ -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`
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
|
||||
@@ -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: >
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user