Skip to content
Open
Changes from 3 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
57 changes: 54 additions & 3 deletions custom_components/openclaw/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import area_registry as ar, device_registry as dr
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers import intent

Expand Down Expand Up @@ -138,17 +139,36 @@ async def async_process(
)
exposed_context = apply_context_policy(raw_context, max_chars, strategy)
extra_system_prompt = getattr(user_input, "extra_system_prompt", None)

# Resolve the originating device's area for room-aware responses
device_area_context = self._resolve_device_area(user_input)

system_prompt = "\n\n".join(
part for part in (exposed_context, extra_system_prompt) if part
part
for part in (device_area_context, exposed_context, extra_system_prompt)
if part
) or None

# Add device/area headers when available
device_id = getattr(user_input, "device_id", None)
if device_id or device_area_context:
voice_headers = dict(_VOICE_REQUEST_HEADERS)
if device_id:
voice_headers["x-openclaw-device-id"] = device_id
if device_area_context:
area_name = device_area_context.removeprefix("[Voice command from: ").removesuffix("]")
voice_headers["x-openclaw-area"] = area_name
Comment on lines +181 to +183
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extracting area_name by parsing device_area_context (removeprefix/removesuffix) is brittle because any future change to the prompt wording or formatting will silently break the header value. Prefer having _resolve_device_area return the raw area_name (or a small dataclass/tuple like (area_name, context_line)) and build both the system prompt line and the x-openclaw-area header from that.

Copilot uses AI. Check for mistakes.
Comment on lines +181 to +183
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

area_name is derived from device_area_context and then sent as an HTTP header value. Because area_entry.name is user-controlled, it should be sanitized to prevent CRLF/newline characters (which can cause aiohttp header validation errors or enable header injection) and to keep the injected system-prompt line single-line. Consider deriving area_name directly from the registry lookup and normalizing it (strip, replace \r/\n with spaces, and optionally enforce a reasonable max length) before using it in both the prompt and headers.

Copilot uses AI. Check for mistakes.
else:
voice_headers = None

try:
full_response = await self._get_response(
client,
message,
conversation_id,
voice_agent_id,
system_prompt,
voice_headers,
)
except OpenClawApiError as err:
_LOGGER.error("OpenClaw conversation error: %s", err)
Expand All @@ -165,6 +185,7 @@ async def async_process(
conversation_id,
voice_agent_id,
system_prompt,
voice_headers,
)
except OpenClawApiError as retry_err:
return self._error_result(
Expand Down Expand Up @@ -227,6 +248,34 @@ def _resolve_conversation_id(self, user_input: conversation.ConversationInput) -

return "assist_default"

def _resolve_device_area(
self, user_input: conversation.ConversationInput
) -> str | None:
"""Resolve the area name for the device that initiated the conversation.

Returns a short context string like '[Voice command from: Study]'
so the agent knows which room the user is in.
"""
device_id = getattr(user_input, "device_id", None)
if not device_id:
return None

try:
dev_reg = dr.async_get(self.hass)
device_entry = dev_reg.async_get(device_id)
if not device_entry or not device_entry.area_id:
return None

area_reg = ar.async_get(self.hass)
area_entry = area_reg.async_get_area(device_entry.area_id)
if not area_entry:
return None

return f"[Voice command from: {area_entry.name}]"
except Exception:
_LOGGER.debug("Could not resolve area for device %s", device_id)
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catching a broad Exception here can hide unexpected bugs (e.g., registry API changes) and the log message drops the stack trace. Consider narrowing the exception types you expect from the registry lookups and/or logging with exc_info=True so failures can be diagnosed from logs without reproducing locally.

Suggested change
_LOGGER.debug("Could not resolve area for device %s", device_id)
_LOGGER.debug("Could not resolve area for device %s", device_id, exc_info=True)

Copilot uses AI. Check for mistakes.
return None

def _normalize_optional_text(self, value: Any) -> str | None:
"""Return a stripped string or None for blank values."""
if not isinstance(value, str):
Expand All @@ -241,16 +290,18 @@ async def _get_response(
conversation_id: str,
agent_id: str | None = None,
system_prompt: str | None = None,
extra_headers: dict[str, str] | None = None,
) -> str:
"""Get a response from OpenClaw, trying streaming first."""
headers = extra_headers or _VOICE_REQUEST_HEADERS
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

headers = extra_headers or _VOICE_REQUEST_HEADERS treats extra_headers as a full replacement, even though the parameter name suggests these are additional headers. To avoid accidentally dropping the voice routing headers in future call sites, consider always merging with the defaults (e.g., start from _VOICE_REQUEST_HEADERS and update with extra_headers when provided).

Suggested change
headers = extra_headers or _VOICE_REQUEST_HEADERS
headers: dict[str, str] = dict(_VOICE_REQUEST_HEADERS)
if extra_headers:
headers.update(extra_headers)

Copilot uses AI. Check for mistakes.
# Try streaming (lower TTFB for voice pipeline)
full_response = ""
async for chunk in client.async_stream_message(
message=message,
session_id=conversation_id,
system_prompt=system_prompt,
agent_id=agent_id,
extra_headers=_VOICE_REQUEST_HEADERS,
extra_headers=headers,
):
full_response += chunk

Expand All @@ -263,7 +314,7 @@ async def _get_response(
session_id=conversation_id,
system_prompt=system_prompt,
agent_id=agent_id,
extra_headers=_VOICE_REQUEST_HEADERS,
extra_headers=headers,
)
extracted = self._extract_text_recursive(response)
return extracted or ""
Expand Down
Loading