first commit
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
"""OpenClaw conversation agent for Home Assistant Assist pipeline.
|
||||
|
||||
Registers OpenClaw as a native conversation agent so it can be used
|
||||
with Assist, Voice PE, and any HA voice satellite.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .api import OpenClawApiClient, OpenClawApiError
|
||||
from .const import (
|
||||
ATTR_MESSAGE,
|
||||
ATTR_MODEL,
|
||||
ATTR_SESSION_ID,
|
||||
ATTR_TIMESTAMP,
|
||||
DATA_MODEL,
|
||||
DOMAIN,
|
||||
EVENT_MESSAGE_RECEIVED,
|
||||
)
|
||||
from .coordinator import OpenClawCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the OpenClaw conversation agent."""
|
||||
agent = OpenClawConversationAgent(hass, entry)
|
||||
conversation.async_set_agent(hass, entry, agent)
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
) -> bool:
|
||||
"""Unload the conversation agent."""
|
||||
conversation.async_unset_agent(hass, entry)
|
||||
return True
|
||||
|
||||
|
||||
class OpenClawConversationAgent(conversation.AbstractConversationAgent):
|
||||
"""Conversation agent that routes messages through OpenClaw.
|
||||
|
||||
Enables OpenClaw to appear as a selectable agent in the Assist pipeline,
|
||||
allowing use with Voice PE, satellites, and the built-in HA Assist dialog.
|
||||
"""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Initialize the conversation agent."""
|
||||
self.hass = hass
|
||||
self.entry = entry
|
||||
|
||||
@property
|
||||
def attribution(self) -> dict[str, str]:
|
||||
"""Return attribution info."""
|
||||
return {"name": "Powered by OpenClaw", "url": "https://openclaw.dev"}
|
||||
|
||||
@property
|
||||
def supported_languages(self) -> list[str] | str:
|
||||
"""Return supported languages.
|
||||
|
||||
OpenClaw handles language via its configured model, so we declare
|
||||
support for all languages and let the model handle translation.
|
||||
"""
|
||||
return conversation.MATCH_ALL
|
||||
|
||||
async def async_process(
|
||||
self, user_input: conversation.ConversationInput
|
||||
) -> conversation.ConversationResult:
|
||||
"""Process a user message through OpenClaw.
|
||||
|
||||
Tries streaming first for lower latency (first-token fast).
|
||||
Falls back to non-streaming if the stream yields nothing.
|
||||
|
||||
Args:
|
||||
user_input: The conversation input from HA Assist.
|
||||
|
||||
Returns:
|
||||
ConversationResult with the assistant's response.
|
||||
"""
|
||||
entry_data = self.hass.data.get(DOMAIN, {}).get(self.entry.entry_id)
|
||||
if not entry_data:
|
||||
return self._error_result(
|
||||
user_input, "OpenClaw integration not configured"
|
||||
)
|
||||
|
||||
client: OpenClawApiClient = entry_data["client"]
|
||||
coordinator: OpenClawCoordinator = entry_data["coordinator"]
|
||||
|
||||
message = user_input.text
|
||||
conversation_id = user_input.conversation_id or "default"
|
||||
|
||||
try:
|
||||
full_response = await self._get_response(client, message, conversation_id)
|
||||
except OpenClawApiError as err:
|
||||
_LOGGER.error("OpenClaw conversation error: %s", err)
|
||||
|
||||
# Try token refresh if we have the capability
|
||||
refresh_fn = entry_data.get("refresh_token")
|
||||
if refresh_fn:
|
||||
refreshed = await refresh_fn()
|
||||
if refreshed:
|
||||
try:
|
||||
full_response = await self._get_response(
|
||||
client, message, conversation_id
|
||||
)
|
||||
except OpenClawApiError as retry_err:
|
||||
return self._error_result(
|
||||
user_input,
|
||||
f"Error communicating with OpenClaw: {retry_err}",
|
||||
)
|
||||
else:
|
||||
return self._error_result(
|
||||
user_input,
|
||||
f"Error communicating with OpenClaw: {err}",
|
||||
)
|
||||
else:
|
||||
return self._error_result(
|
||||
user_input,
|
||||
f"Error communicating with OpenClaw: {err}",
|
||||
)
|
||||
|
||||
# Fire event so automations can react to the response
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_MESSAGE_RECEIVED,
|
||||
{
|
||||
ATTR_MESSAGE: full_response,
|
||||
ATTR_SESSION_ID: conversation_id,
|
||||
ATTR_MODEL: coordinator.data.get(DATA_MODEL) if coordinator.data else None,
|
||||
ATTR_TIMESTAMP: datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
coordinator.update_last_activity()
|
||||
|
||||
intent_response = conversation.IntentResponse(language=user_input.language)
|
||||
intent_response.async_set_speech(full_response)
|
||||
|
||||
return conversation.ConversationResult(
|
||||
response=intent_response,
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
async def _get_response(
|
||||
self,
|
||||
client: OpenClawApiClient,
|
||||
message: str,
|
||||
conversation_id: str,
|
||||
) -> 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,
|
||||
):
|
||||
full_response += chunk
|
||||
|
||||
if full_response:
|
||||
return full_response
|
||||
|
||||
# Fallback to non-streaming
|
||||
response = await client.async_send_message(
|
||||
message=message,
|
||||
session_id=conversation_id,
|
||||
)
|
||||
choices = response.get("choices", [])
|
||||
if choices:
|
||||
return choices[0].get("message", {}).get("content", "")
|
||||
return ""
|
||||
|
||||
def _error_result(
|
||||
self,
|
||||
user_input: conversation.ConversationInput,
|
||||
error_message: str,
|
||||
) -> conversation.ConversationResult:
|
||||
"""Build an error ConversationResult."""
|
||||
intent_response = conversation.IntentResponse(language=user_input.language)
|
||||
intent_response.async_set_error(
|
||||
conversation.IntentResponseErrorCode.UNKNOWN,
|
||||
error_message,
|
||||
)
|
||||
return conversation.ConversationResult(
|
||||
response=intent_response,
|
||||
conversation_id=user_input.conversation_id,
|
||||
)
|
||||
Reference in New Issue
Block a user