From 61f0030f33676ebf87019b2676464076316fc251 Mon Sep 17 00:00:00 2001 From: sudoxreboot <76703581+sudoxreboot@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:44:09 -0500 Subject: [PATCH] Add files via upload updated tool call handling updated light color handling updated searxng handling - all 3 should work now --- custom_components/groqd/__init__.py | 6 +- custom_components/groqd/button.py | 43 +++++ custom_components/groqd/conversation.py | 187 ++++++++++++++++++- custom_components/groqd/translations/en.json | 7 + 4 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 custom_components/groqd/button.py diff --git a/custom_components/groqd/__init__.py b/custom_components/groqd/__init__.py index 881f5d1..257920d 100644 --- a/custom_components/groqd/__init__.py +++ b/custom_components/groqd/__init__.py @@ -2,7 +2,8 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import Any import groq @@ -12,7 +13,7 @@ from homeassistant.core import HomeAssistant from .const import DOMAIN -PLATFORMS: list[Platform] = [Platform.CONVERSATION] +PLATFORMS: list[Platform] = [Platform.CONVERSATION, Platform.BUTTON] @dataclass @@ -20,6 +21,7 @@ class GroqdRuntimeData: """Runtime data for groqd.""" client: groq.AsyncClient + conversation_agent: Any = field(default=None) type GroqdConfigEntry = ConfigEntry[GroqdRuntimeData] diff --git a/custom_components/groqd/button.py b/custom_components/groqd/button.py new file mode 100644 index 0000000..881ef3d --- /dev/null +++ b/custom_components/groqd/button.py @@ -0,0 +1,43 @@ +"""Button platform for groqd — provides a 'Clear session memory' button.""" + +from __future__ import annotations + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import device_registry as dr + +from . import GroqdConfigEntry +from .const import DOMAIN, LOGGER + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: GroqdConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up groqd button entities.""" + async_add_entities([GroqdClearMemoryButton(config_entry)]) + + +class GroqdClearMemoryButton(ButtonEntity): + """Button that wipes all persisted conversation history for this entry.""" + + _attr_has_entity_name = True + _attr_translation_key = "clear_memory" + + def __init__(self, entry: GroqdConfigEntry) -> None: + self._entry = entry + self._attr_unique_id = f"{entry.entry_id}_clear_memory" + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + ) + + async def async_press(self) -> None: + """Handle button press — delegate to the conversation agent.""" + agent = self._entry.runtime_data.conversation_agent + if agent is not None: + await agent.async_clear_memory() + LOGGER.info("groqd: clear memory button pressed for entry %s", self._entry.entry_id) + else: + LOGGER.warning("groqd: clear memory pressed but conversation agent not ready") diff --git a/custom_components/groqd/conversation.py b/custom_components/groqd/conversation.py index 9afb5fe..aa1f76c 100644 --- a/custom_components/groqd/conversation.py +++ b/custom_components/groqd/conversation.py @@ -95,17 +95,153 @@ async def async_setup_entry( async_add_entities([agent]) +def _make_nullable(schema: Any) -> Any: + """Wrap a schema so it also accepts JSON null (Python None). + + Models often emit ``null`` for optional parameters they don't need rather + than omitting the key entirely. Making optional properties nullable in the + schema sent to Groq prevents server-side validation failures for those + ``null`` values. ``_coerce_args_for_schema`` then strips the nulls before + forwarding to Home Assistant, which does not accept null slot values. + """ + if not isinstance(schema, dict): + return schema + if schema.get("type") == "null": + return schema # already null + if "anyOf" in schema: + options = schema["anyOf"] + if any(isinstance(o, dict) and o.get("type") == "null" for o in options): + return schema # null already present in union + return {**schema, "anyOf": [*options, {"type": "null"}]} + if "type" in schema: + return {"anyOf": [schema, {"type": "null"}]} + return schema + + +def _is_slot_wrapper(schema: dict) -> bool: + """Return True if schema looks like an HA intent slot wrapper. + + HA represents intent slot values as ``{"value": X}`` (sometimes also + with a ``"text"`` sibling key). Detecting this pattern lets us relax + the schema so Groq models can emit the bare value directly (e.g. "red" + instead of {"value": "red"}). + """ + if schema.get("type") != "object": + return False + props = schema.get("properties", {}) + return "value" in props and len(props) <= 2 + + +def _relax_schema_arrays(schema: Any) -> Any: + """Recursively relax schemas so Groq's server-side validation accepts + common model output patterns that differ from the strict HA schema. + + Two relaxations are applied: + + * ``type: array`` → ``anyOf: [array, string]`` + Models often emit ``"light"`` instead of ``["light"]``. + + * HA slot-wrapper objects ``{type: object, properties: {value: X}}`` + → ``anyOf: [object, X]`` + Models often emit ``"red"`` instead of ``{"value": "red"}``. + + After Groq accepts the output, ``_coerce_args_for_schema`` fixes the + args back to what HA expects before forwarding the tool call. + """ + if not isinstance(schema, dict): + return schema + if schema.get("type") == "object": + props = schema.get("properties", {}) + required = set(schema.get("required", [])) + is_wrapper = _is_slot_wrapper(schema) + # Capture the raw value schema BEFORE recursing (used in anyOf below) + raw_value_schema = props.get("value") if is_wrapper else None + result = dict(schema) + new_props: dict[str, Any] = {} + for k, v in props.items(): + relaxed = _relax_schema_arrays(v) + # Optional properties must also accept null so the model can emit + # null instead of omitting the key and still pass Groq validation. + if k not in required: + relaxed = _make_nullable(relaxed) + new_props[k] = relaxed + result["properties"] = new_props + if is_wrapper and raw_value_schema is not None: + # Allow either the full {value: X} object OR a bare value of the + # same base type — but WITHOUT any enum constraints. Models often + # produce values outside the declared enum (e.g. XY colour strings + # instead of named colours); stripping the enum here lets Groq + # accept the output so we can pass it to HA and let HA report the + # proper error if the value is truly invalid. + raw_type = raw_value_schema.get("type", "string") + loose_alt: dict[str, Any] = {"type": raw_type} if raw_type else {"type": "string"} + return {"anyOf": [result, loose_alt]} + return result + if schema.get("type") == "array": + return {"anyOf": [schema, {"type": "string"}]} + return schema + + +def _coerce_args_for_schema(args: Any, schema: Any) -> Any: + """Recursively coerce args so they match what HA expects. + + Handles the two common model output mismatches: + + * ``"light"`` (string) where schema expects ``["light"]`` (array). + * ``"red"`` (string) where schema expects ``{"value": "red"}`` + (HA intent slot wrapper object). + """ + if not isinstance(schema, dict): + return args + schema_type = schema.get("type") + # Array: wrap bare string in a list + if schema_type == "array": + if isinstance(args, str): + return [args] + return args + if schema_type == "object": + props = schema.get("properties", {}) + # Models sometimes double-encode an object as a JSON string, e.g. + # "color": "{\"xy\":[0.64,0.33]}" instead of a real dict. + # Try to JSON-decode such strings before further coercion. + if isinstance(args, str): + try: + decoded = json.loads(args) + if isinstance(decoded, dict): + args = decoded + except (json.JSONDecodeError, ValueError): + pass + # Slot wrapper: model sent bare value instead of {"value": X} + if "value" in props and not isinstance(args, dict): + return {"value": args} + if isinstance(args, dict): + result = dict(args) + for key, prop_schema in props.items(): + if key in result and isinstance(prop_schema, dict): + result[key] = _coerce_args_for_schema(result[key], prop_schema) + # Strip None values — null means "omit" for optional HA parameters. + # HA's voluptuous validators don't accept None for optional slots; + # they simply expect the key to be absent. + return {k: v for k, v in result.items() if v is not None} + return args + + def _format_tool( tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None -) -> ChatCompletionToolParam: - """Format tool specification.""" +) -> tuple[ChatCompletionToolParam, dict]: + """Format tool specification. + + Returns the Groq-compatible tool param (with relaxed array schemas) and + the original schema dict (for use in argument coercion before calling HA). + """ + original_schema = convert(tool.parameters, custom_serializer=custom_serializer) tool_spec = FunctionDefinition( name=tool.name, - parameters=convert(tool.parameters, custom_serializer=custom_serializer), + parameters=_relax_schema_arrays(original_schema), ) if tool.description: tool_spec["description"] = tool.description - return ChatCompletionToolParam(type="function", function=tool_spec) + return ChatCompletionToolParam(type="function", function=tool_spec), original_schema def _parse_tool_choice(value: str) -> Any: @@ -198,12 +334,29 @@ def _extract_urls(text: str, limit: int = 3) -> list[str]: break return cleaned +def _normalize_searxng_url(url: str) -> str: + """Return the bare SearXNG base URL, stripping any /search path or OpenSearch suffix. + + Users sometimes copy the OpenSearch template URL (e.g. + ``https://searxng.example.com/search=%s``) instead of just the base + URL. We strip the ``/search`` path component and anything after it so + the code can always safely append ``/search`` with its own query params. + A subpath mount is preserved (e.g. ``https://example.com/searxng``). + """ + url = url.strip() + # Strip OpenSearch-style /search=%s (and variants like /search?q=%s) + url = re.sub(r"/search[=?].*$", "", url, flags=re.IGNORECASE) + # Strip a trailing bare /search as well + url = re.sub(r"/search/?$", "", url, flags=re.IGNORECASE) + return url.rstrip("/") + + async def _run_searxng( hass: HomeAssistant, options: dict[str, Any], tool_args: dict[str, Any], ) -> dict[str, Any]: - base_url = options.get(CONF_SEARXNG_URL, DEFAULT_SEARXNG_URL).rstrip("/") + base_url = _normalize_searxng_url(options.get(CONF_SEARXNG_URL, DEFAULT_SEARXNG_URL)) params = { "q": tool_args.get("query", ""), "format": "json", @@ -269,14 +422,25 @@ class GroqdConversationEntity( data = await self._store.async_load() or {} self._persisted_history = data.get("history", {}) self._memory_index = data.get("memory_index", {}) + self.entry.runtime_data.conversation_agent = self self.entry.async_on_unload( self.entry.add_update_listener(self._async_entry_update_listener) ) async def async_will_remove_from_hass(self) -> None: + self.entry.runtime_data.conversation_agent = None conversation.async_unset_agent(self.hass, self.entry) await super().async_will_remove_from_hass() + async def async_clear_memory(self) -> None: + """Clear all conversation history and persisted memory.""" + self.history.clear() + self._persisted_history.clear() + self._memory_index.clear() + if self._store: + await self._store.async_save({"history": {}, "memory_index": {}}) + LOGGER.info("groqd: memory cleared for entry %s", self.entry.entry_id) + async def async_process( self, user_input: conversation.ConversationInput ) -> conversation.ConversationResult: @@ -284,6 +448,7 @@ class GroqdConversationEntity( intent_response = intent.IntentResponse(language=user_input.language) llm_api: llm.APIInstance | None = None tools: list[ChatCompletionToolParam] | None = None + tool_schemas: dict[str, dict] = {} user_name: str | None = None llm_context = llm.LLMContext( platform=DOMAIN, @@ -309,7 +474,11 @@ class GroqdConversationEntity( return conversation.ConversationResult( response=intent_response, conversation_id=user_input.conversation_id ) - tools = [_format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools] + _formatted = [_format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools] + tools = [f[0] for f in _formatted] + tool_schemas = { + tool.name: f[1] for tool, f in zip(llm_api.tools, _formatted) + } if options.get(CONF_SEARXNG_ENABLED, DEFAULT_SEARXNG_ENABLED): if tools is None: tools = [] @@ -547,6 +716,12 @@ class GroqdConversationEntity( except Exception as err: tool_response = {"error": type(err).__name__, "error_text": str(err)} elif llm_api: + # Coerce any string values back to arrays where the HA tool + # schema requires them (Groq models sometimes emit a bare + # string instead of a one-element array, e.g. "light" vs + # ["light"] for the domain parameter). + if tool_name in tool_schemas: + tool_args = _coerce_args_for_schema(tool_args, tool_schemas[tool_name]) tool_input = llm.ToolInput( tool_name=tool_name, tool_args=tool_args, diff --git a/custom_components/groqd/translations/en.json b/custom_components/groqd/translations/en.json index 97c566c..ae6c871 100644 --- a/custom_components/groqd/translations/en.json +++ b/custom_components/groqd/translations/en.json @@ -1,4 +1,11 @@ { + "entity": { + "button": { + "clear_memory": { + "name": "Clear session memory" + } + } + }, "config": { "step": { "user": {