Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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`
Expand Down
59 changes: 8 additions & 51 deletions custom_components/openclaw/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
from .api import OpenClawApiClient, OpenClawApiError
from .const import (
ATTR_AGENT_ID,
ATTR_ATTACHMENTS,
ATTR_MESSAGE,
ATTR_MODEL,
ATTR_OK,
Expand Down Expand Up @@ -90,6 +89,7 @@
)
from .coordinator import OpenClawCoordinator
from .exposure import apply_context_policy, build_exposed_entities_context
from .helpers import extract_text_recursive

_LOGGER = logging.getLogger(__name__)

Expand All @@ -98,6 +98,7 @@
_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/)
Expand All @@ -106,7 +107,7 @@
# 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

Expand All @@ -117,7 +118,6 @@
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,
}
)
Expand Down Expand Up @@ -447,13 +447,16 @@ async def handle_send_message(call: ServiceCall) -> 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(
message=message,
session_id=session_id,
system_prompt=system_prompt,
agent_id=resolved_agent_id,
model=active_model,
extra_headers=extra_headers,
)

Expand All @@ -469,6 +472,7 @@ async def handle_send_message(call: ServiceCall) -> None:
session_id=session_id,
system_prompt=system_prompt,
agent_id=resolved_agent_id,
model=active_model,
extra_headers=extra_headers,
)

Expand Down Expand Up @@ -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:
Expand All @@ -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]]:
Expand Down
5 changes: 5 additions & 0 deletions custom_components/openclaw/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ def _headers(
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

Expand Down
93 changes: 35 additions & 58 deletions custom_components/openclaw/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@
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,
Expand All @@ -38,6 +40,7 @@
)
from .coordinator import OpenClawCoordinator
from .exposure import apply_context_policy, build_exposed_entities_context
from .helpers import extract_text_recursive

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -117,12 +120,20 @@ async def async_process(
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,
Expand All @@ -149,8 +160,9 @@ async def async_process(
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)
Expand All @@ -165,8 +177,9 @@ async def async_process(
client,
message,
conversation_id,
voice_agent_id,
resolved_agent_id,
system_prompt,
active_model,
)
except OpenClawApiError as retry_err:
return self._error_result(
Expand Down Expand Up @@ -205,8 +218,12 @@ async def async_process(
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,
Expand All @@ -216,19 +233,25 @@ def _resolve_conversation_id(self, user_input: conversation.ConversationInput) -
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."""
Expand All @@ -244,13 +267,14 @@ async def _get_response(
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,
Expand All @@ -260,62 +284,15 @@ async def _get_response(
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:
Expand Down
Loading