From 363ab378afa0235afd0d5768f334add14d60f8cb Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 18 Dec 2025 20:58:39 +0100 Subject: [PATCH] feat: optimize Housekeeper for Mistral-Nemo tool calling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite system prompt with negative constraints and step-by-step process - Set temperature to 0.1 for deterministic tool calling - Sort room groups to top of device list (address positional bias) - Add [ROOM GROUP] marker in list_devices output - Update tool docstrings with explicit entity_id= parameter examples - Add optimization findings doc (experiment log: 0% → 100% success) - Add test script for room group detection regression testing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 12 ++ docs/housekeeper-optimization-findings.md | 246 ++++++++++++++++++++++ scripts/test_housekeeper.sh | 141 +++++++++++++ src/agents/housekeeper/agent.py | 126 +++++------ src/agents/housekeeper/tools.py | 49 +++-- 5 files changed, 488 insertions(+), 86 deletions(-) create mode 100644 docs/housekeeper-optimization-findings.md create mode 100755 scripts/test_housekeeper.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 33862ce..f5fcb15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Housekeeper prompt optimization** - Rewrote system prompt for Mistral-Nemo function calling with negative constraints, step-by-step process, and explicit entity ID format guidance +- **Housekeeper temperature setting** - Set temperature to 0.1 for deterministic tool calling behavior +- **Device list room group priority** - Room groups now appear first in `list_devices` output with `[ROOM GROUP]` marker to address positional bias +- **Tool docstring improvements** - Updated turn_on/turn_off/toggle with explicit `entity_id=` parameter examples + +### Added + +- **Housekeeper optimization findings** - Added `docs/housekeeper-optimization-findings.md` documenting the experiment journey from 0% to 100% success rate +- **Housekeeper test script** - Added `scripts/test_housekeeper.sh` for room group detection regression testing + ## [1.8.6] - 2025-12-17 ### Fixed diff --git a/docs/housekeeper-optimization-findings.md b/docs/housekeeper-optimization-findings.md new file mode 100644 index 0000000..286862b --- /dev/null +++ b/docs/housekeeper-optimization-findings.md @@ -0,0 +1,246 @@ +# Housekeeper Agent Optimization Findings + +## Background + +Research with Gemini identified key issues with mistral-nemo and tool calling: +- "Pre-computation Hallucination" - model answers before using tools +- High default temperature (0.7-0.8) causes wandering +- Model is "chatty and confident" - needs explicit constraints + +## Key Recommendations from Gemini Research + +1. **Temperature 0.0** for tool-calling agents (deterministic, follows schema) +2. **Chain of Thought (CoT)** - force step-by-step reasoning +3. **Negative constraints** - tell model what NOT to do (Nemo responds better) +4. **Explicit tool descriptions** - verbose docstrings with "never estimate yourself" +5. **"Strictly tool-based assistant"** pattern - NO internal knowledge claim + +--- + +## Experiment Log + +### Baseline (v1.8.6) +- **Date**: 2025-12-17 +- **Configuration**: Default temperature, improved prompt requiring list_devices first +- **Results**: + - Called list_devices first ✓ + - Still hallucinated `light.study_desk` despite seeing list with only `light.study` and `light.study_main` + - Partial success: turned off `light.study_main`, failed on hallucinated entity +- **Success rate**: ~50% (1 of 2 study lights controlled correctly) + +--- + +### Experiment 1: Temperature 0.0 +- **Date**: 2025-12-18 +- **Change**: Set `model_settings=ModelSettings(temperature=0.0)` for Housekeeper +- **Hypothesis**: Deterministic output will force model to use exact entity IDs from tool results +- **Results**: + + **Study lights test:** + - Called `list_devices()` first ✓ (but no domain filter) + - Used wrong parameter `device_id` instead of `entity_id` (recovered after validation error) + - Only identified `light.studeerlamp` as "study" related (Dutch name) + - **Missed `light.study` and `light.study_main`** - didn't match English "study" + - Turned off 1 wrong light, missed 2 actual study lights + + **Kitchen lights test:** + - Called `list_devices()` first ✓ (no domain filter) + - Saw full device list including `light.kitchen` + - Used wrong parameter `device_id` instead of `entity_id` (recovered after validation) + - After correction, dropped domain prefix: used `kitchen` instead of `light.kitchen` + - 404 error - device not found + +- **Success rate**: 0% (no target lights successfully controlled) +- **Observations**: + - Temperature 0.0 alone is insufficient + - Model consistently confuses `device_id` vs `entity_id` parameter name + - After validation error correction, model truncates entity_id (drops domain prefix) + - Semantic matching of room names to devices is weak + - Model doesn't understand entity_id format: `domain.name` + +--- + +### Experiment 2: Negative Constraints + CoT +- **Date**: 2025-12-18 +- **Change**: Complete prompt rewrite with: + - "You have NO Internal Knowledge" - negative framing + - Explicit entity_id format with WRONG/RIGHT examples + - Step-by-step process (ALWAYS FOLLOW) + - Explicit parameter names section + - "What NOT To Do" negative constraints +- **Hypothesis**: Negative constraints work better with Mistral-Nemo +- **Results**: + + **Study lights test:** + - Called `list_devices(domain="light")` ✓ with domain filter (improvement!) + - Still used `device_id` first, recovered to `entity_id` after validation error + - After recovery, used correct full format: `light.studeerlamp` + - **Still only matched `studeerlamp` not `light.study` or `light.study_main`** + + **Kitchen lights test:** + - Called `list_devices(domain="light")` ✓ + - Called `turn_off(entity_id="light.kitchen")` ✓ correct format! + - All 4 kitchen lights turned off (light.kitchen is a group) + - **100% success for kitchen!** + +- **Success rate**: + - Study: 0% (wrong semantic match) + - Kitchen: 100% (4/4 lights off) + - Combined: ~50% (1 of 2 tests successful) +- **Observations**: + - Domain filter now consistently used ✓ + - Entity_id format correct after recovery ✓ + - Semantic matching still fails for "study" → prefers Dutch "studeerlamp" over English "study" + - Parameter name confusion persists (`device_id` vs `entity_id`) + - Simple room names (kitchen) work; mixed language fails (study/studeerlamp) + +--- + +### Experiment 3: Temperature 0.1 + Explicit Tool Docstrings +- **Date**: 2025-12-18 +- **Change**: + - Temperature 0.1 + - Updated turn_on/turn_off docstrings with explicit `entity_id=` in examples +- **Results**: + - Still uses `device_id` first, recovers to `entity_id` after validation + - Still picks wrong entity (studeerlamp over study) +- **Success rate**: 0% + +--- + +### Experiment 4: Room Group Priority (with explicit examples) +- **Date**: 2025-12-18 +- **Change**: Updated prompt with: + - Explicit instruction: "Look for EXACT match `light.` first!" + - Concrete examples: "For 'study lights' → look for `light.study`" + - Working example showing `turn_off(entity_id="light.study")` +- **Hypothesis**: Explicit examples will guide model to use room groups +- **Results**: + + **Test 1 & 2 (consecutive):** + - Called `list_devices(domain="light")` ✓ + - Device list clearly shows `light.study` at the bottom + - First call: `turn_off({"devices":["studeerlamp"]})` - wrong param AND wrong device + - After validation error: `turn_off(entity_id="light.studeerlamp")` - correct param, still wrong device + - **Completely ignored `light.study` despite prompt explicitly saying to use it** + +- **Success rate**: 0% (wrong device controlled) +- **Observations**: + - Model ignores explicit step-by-step instructions in favor of substring matching + - Dutch "studeerlamp" contains "studer" which the model prefers over exact "study" match + - Even when prompt has a literal example `turn_off(entity_id="light.study")`, model uses `light.studeerlamp` + - Positional bias possible - `light.study` appears at end of 21-item list + - **Fundamental limitation**: Mistral-Nemo cannot follow explicit matching rules + +--- + +### Experiment 5: Room Groups First (Tool Output Ordering) +- **Date**: 2025-12-18 +- **Change**: Modified `list_devices` to sort room groups to top of list using HA attributes (`is_hue_group`, `hue_type="room"`) +- **Hypothesis**: Positional bias - model focuses on items earlier in list +- **Results**: + - Room groups (`light.study`, `light.kitchen`, etc.) now appear first in device list + - Combined with improved prompt, model now consistently uses room groups + - **70% success rate** (7/10 tests) with default q4 quantization + +--- + +### Experiment 6: Model Quantization (q5_1) +- **Date**: 2025-12-18 +- **Change**: Upgraded from default Mistral-Nemo quantization (q4) to `mistral-nemo:12b-instruct-2407-q5_1` +- **Hypothesis**: Higher precision weights improve tool calling accuracy +- **Results**: + + | Test | Action | Result | + |------|--------|--------| + | 1 | Turn off study | PASS | + | 2 | Turn on study | PASS | + | 3 | Toggle study | PASS | + | 4 | Turn off kitchen | PASS | + | 5 | Turn on kitchen | PASS | + | 6 | Toggle kitchen | PASS | + | 7 | Turn off bedroom | PASS | + | 8 | Turn on bedroom | PASS | + | 9 | Turn off living room | PASS | + | 10 | Turn on living room | PASS | + +- **Success rate**: **100%** (10/10 tests) +- **Observations**: + - q5_1 quantization dramatically improves tool calling accuracy + - All room groups correctly identified and used + - No parameter confusion (`entity_id` used correctly) + - No entity_id truncation issues + - Toggle operations now work reliably + - Model fits within 10GB VRAM (q6 did not) + +--- + +### Experiment 7: Device List in System Prompt (Context Injection) +- **Date**: [PENDING] +- **Change**: Store device list in database (per user/household) and inject into system prompt +- **Approach**: + 1. Periodically sync device list from Home Assistant to PostgreSQL + 2. On each Housekeeper invocation, fetch device list and include in prompt + 3. Remove need for model to call list_devices() - just match from context +- **Hypothesis**: + - Eliminates tool call step where errors occur + - Reduces context size by not returning full device list as tool output + - Makes entity matching a language task (in prompt) rather than tool result parsing +- **Trade-offs**: + - Stale data if sync is infrequent + - Prompt size increase (but less than tool call response) + - Need sync mechanism and storage +- **Results**: [TO BE RECORDED] +- **Success rate**: [TO BE RECORDED] + +--- + +## Key Problem Identified (Solved) + +The model struggled with: +1. **Parameter schema adherence** - uses `device_id` when schema requires `entity_id` +2. **Value preservation** - truncates values after validation errors (drops `light.` prefix) +3. **Semantic matching** - prefers substring matches ("studeerlamp" contains "studer") over exact matches (`light.study`) +4. **Following explicit instructions** - ignores step-by-step processes even when examples are provided +5. **Positional bias** - may not "see" items at the end of long lists + +**Solution**: These issues were resolved by: +1. Using q5_1 quantization instead of default q4 (higher precision weights) +2. Sorting room groups to top of device list (address positional bias) +3. Explicit prompt guidance with negative constraints and examples + +--- + +## Potential Next Experiments + +### Experiment 5: Room Groups First (List Ordering) +- **Hypothesis**: Positional bias - model focuses on items earlier in list +- **Change**: Sort device list to put room groups (entities matching `light.`) at the TOP +- **Effort**: Low - modify list_devices output formatting +- **Risk**: May affect other use cases where individual devices are needed + +### Experiment 6: Simplified Device List Format +- **Hypothesis**: Markdown formatting adds noise that confuses the model +- **Change**: Return simple list: `light.study (Study - GROUP), light.study_main (Ceiling light), ...` +- **Effort**: Low - modify list_devices output +- **Risk**: Less human-readable responses + +--- + +## Learnings to Apply Elsewhere + +1. **Quantization matters** - q5_1 dramatically outperforms q4 for tool calling (100% vs 70%) +2. **Positional bias is real** - sort important items to top of lists +3. **Smaller models need simpler workflows** - fewer tool calls, more context injection +4. **Validation errors don't teach** - model often makes worse mistakes on retry +5. **Entity IDs are hard** - domain.name format confuses the model +6. **Consider pre-computation** - move matching logic to code, not LLM +7. **Use explicit negative constraints** - "NEVER do X" works better than "always do Y" + +--- + +## Notes + +- Librarian may need higher temperature for creative synthesis +- All "action" agents (Housekeeper, future agents) should use low temperature +- Consider testing with Gemma 2 9B for better function calling (Google, open weights) diff --git a/scripts/test_housekeeper.sh b/scripts/test_housekeeper.sh new file mode 100755 index 0000000..1efcb4c --- /dev/null +++ b/scripts/test_housekeeper.sh @@ -0,0 +1,141 @@ +#!/bin/bash +# Housekeeper Room Group Detection Test Suite +# Verifies room groups are controlled by checking actual state changes + +API_URL="http://localhost:8777/v1/chat/completions" +CORE_API="http://192.168.86.149:8083" +RESULTS_FILE="/tmp/housekeeper_test_results.txt" + +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' + +get_state() { + curl -s "$CORE_API/housekeeping/devices/$1" 2>/dev/null | jq -r '.state' 2>/dev/null +} + +echo "==========================================" +echo "Housekeeper Room Group Test Suite" +echo "==========================================" +echo "" + +> "$RESULTS_FILE" + +run_toggle_test() { + local test_num=$1 + local room=$2 + local entity="light.$room" + local prompt_room="${room//_/ }" + + printf "Test %2d: Toggle %-12s lights ... " "$test_num" "$prompt_room" + + local before=$(get_state "$entity") + if [ -z "$before" ] || [ "$before" = "null" ]; then + echo -e "${YELLOW}SKIP${NC} (cannot get state)" + echo "SKIP|$test_num|Toggle $room|error" >> "$RESULTS_FILE" + return + fi + + curl -s -X POST "$API_URL" \ + -H "Content-Type: application/json" \ + -d "{\"model\": \"tatlock\", \"messages\": [{\"role\": \"user\", \"content\": \"Toggle the $prompt_room lights\"}]}" > /dev/null + + sleep 4 + + local after=$(get_state "$entity") + + if [ "$before" != "$after" ]; then + echo -e "${GREEN}PASS${NC} ($before -> $after)" + echo "PASS|$test_num|Toggle $room|$before->$after" >> "$RESULTS_FILE" + else + echo -e "${RED}FAIL${NC} (state unchanged: $before)" + echo "FAIL|$test_num|Toggle $room|unchanged:$before" >> "$RESULTS_FILE" + fi +} + +run_onoff_test() { + local test_num=$1 + local room=$2 + local action=$3 + local expected_state=$4 + # Entity uses underscore, prompt uses space + local entity="light.${room//_/ }" + entity="light.$room" + local prompt_room="${room//_/ }" + + printf "Test %2d: %-8s %-12s lights ... " "$test_num" "$action" "$prompt_room" + + curl -s -X POST "$API_URL" \ + -H "Content-Type: application/json" \ + -d "{\"model\": \"tatlock\", \"messages\": [{\"role\": \"user\", \"content\": \"$action the $prompt_room lights\"}]}" > /dev/null + + sleep 4 + + local after=$(get_state "$entity") + + if [ "$after" = "$expected_state" ]; then + echo -e "${GREEN}PASS${NC} ($after)" + echo "PASS|$test_num|$action $room|$after" >> "$RESULTS_FILE" + else + echo -e "${RED}FAIL${NC} (got $after, expected $expected_state)" + echo "FAIL|$test_num|$action $room|got:$after,expected:$expected_state" >> "$RESULTS_FILE" + fi +} + +echo "Running tests (~4s each)..." +echo "" + +# Study tests +run_onoff_test 1 "study" "Turn off" "off" +run_onoff_test 2 "study" "Turn on" "on" +run_toggle_test 3 "study" + +# Kitchen tests +run_onoff_test 4 "kitchen" "Turn off" "off" +run_onoff_test 5 "kitchen" "Turn on" "on" +run_toggle_test 6 "kitchen" + +# Bedroom tests +run_onoff_test 7 "bedroom" "Turn off" "off" +run_onoff_test 8 "bedroom" "Turn on" "on" + +# Living room tests (entity is light.living_room) +run_onoff_test 9 "living_room" "Turn off" "off" +run_onoff_test 10 "living_room" "Turn on" "on" + +# Ensure all lights end up ON +echo "" +echo "Restoring all lights to ON..." +for room in "study" "kitchen" "bedroom" "living room"; do + curl -s -X POST "$API_URL" \ + -H "Content-Type: application/json" \ + -d "{\"model\": \"tatlock\", \"messages\": [{\"role\": \"user\", \"content\": \"Turn on the $room lights\"}]}" > /dev/null + sleep 3 +done +echo "Done." + +echo "" +echo "==========================================" +echo "Results" +echo "==========================================" + +PASS=$(grep -c "^PASS" "$RESULTS_FILE" 2>/dev/null || echo 0) +FAIL=$(grep -c "^FAIL" "$RESULTS_FILE" 2>/dev/null || echo 0) +SKIP=$(grep -c "^SKIP" "$RESULTS_FILE" 2>/dev/null || echo 0) +TOTAL=$((PASS + FAIL)) + +echo "Passed: $PASS" +echo "Failed: $FAIL" +echo "Skipped: $SKIP" + +if [ "$TOTAL" -gt 0 ]; then + echo "" + echo "Success Rate: $((PASS * 100 / TOTAL))% ($PASS/$TOTAL)" +fi + +if [ "$FAIL" -gt 0 ]; then + echo "" + echo "Failures:" + grep "^FAIL" "$RESULTS_FILE" +fi diff --git a/src/agents/housekeeper/agent.py b/src/agents/housekeeper/agent.py index cef931a..4870a64 100644 --- a/src/agents/housekeeper/agent.py +++ b/src/agents/housekeeper/agent.py @@ -32,93 +32,69 @@ from src.core.logging_config import get_logger logger = get_logger(__name__) -# Housekeeper system prompt -HOUSEKEEPER_SYSTEM_PROMPT = """You are The Housekeeper, an expert home automation assistant in the Tatlock household. +# Housekeeper system prompt - Optimized for Mistral-Nemo function calling +HOUSEKEEPER_SYSTEM_PROMPT = """You are a strictly tool-based home automation assistant. -Your role is to help users control and monitor their smart home through Home Assistant: -- Lights, switches, and other devices -- Scenes (pre-configured device states) -- Scripts (automation sequences) -- Automations (event-triggered rules) +## CRITICAL: You Have NO Internal Knowledge -## Your Personality -- Efficient and practical -- Safety-conscious (confirm destructive actions) -- Proactive in suggesting optimizations -- Clear about what actions you're taking +You do NOT know what devices exist. You do NOT know any entity IDs. +Entity IDs are different in every installation. You MUST discover them using tools. -## Your Tools +## Entity ID Format -### Discovery Tools -- **list_areas**: See all rooms/areas configured in Home Assistant -- **list_devices**: Find devices by type (domain) or location (area) -- **get_device_state**: Check a device's current state and attributes +Entity IDs follow the format: `domain.name` +Examples: `light.kitchen`, `light.study_main`, `switch.coffee_maker` -### Control Tools -- **turn_on**: Turn on lights, switches, etc. (supports brightness/color for lights) -- **turn_off**: Turn off devices -- **toggle**: Flip a device's state +The `entity_id` parameter MUST be the COMPLETE value including the domain prefix. +WRONG: `entity_id="kitchen"` +RIGHT: `entity_id="light.kitchen"` -### Scene Tools -- **list_scenes**: See available scene presets -- **activate_scene**: Activate a scene (e.g., "movie night", "good morning") +## Step-by-Step Process (ALWAYS FOLLOW) -### Script Tools -- **list_scripts**: See available automation scripts -- **run_script**: Execute a script +When asked to control devices in a room: -### Automation Tools -- **list_automations**: See all automations and their status -- **toggle_automation**: Enable or disable an automation +1. THINK: What domain? (light, switch, climate, etc.) +2. CALL: list_devices(domain="light") to discover available devices +3. CHECK: Look for EXACT match `light.` first! + - For "study lights" → look for `light.study` (not light.study_main, not light.studeerlamp) + - For "kitchen lights" → look for `light.kitchen` (not light.kitchen_spot_1) + - These room groups control ALL lights in that room at once + - If found, use ONLY the group (stop looking for individual lights) +4. FALLBACK: Only if no exact room group exists, find entity_ids containing the room name +5. CALL: turn_on/turn_off using the EXACT entity_id from step 3 or 4 -### History Tools -- **get_history**: Check a device's state history +Example for "Turn off study lights": +1. Domain is "light" +2. Call list_devices(domain="light") +3. Look for room group: `light.study` - FOUND! +4. Call turn_off(entity_id="light.study") # This controls all study lights -## Critical Rules +Example for "Turn off hallway lights" (no room group): +1. Domain is "light" +2. Call list_devices(domain="light") +3. Look for room group: `light.hallway` - NOT FOUND +4. Find all with "hallway": light.hallway_spot_1, light.hallway_spot_2 +5. Call turn_off for each -**NEVER GUESS ENTITY IDs.** You do not know what devices exist. Entity IDs vary between -installations. You MUST call list_devices() FIRST to discover actual entity_ids before -ANY control action (turn_on, turn_off, toggle). +## Tool Parameter Names -Wrong approach: - User: "Turn off the study lights" - You: turn_off("light.study") ← WRONG! You guessed the entity_id +- turn_on, turn_off, toggle: Use `entity_id` (NOT device_id, NOT id) +- activate_scene: Use `scene_id` +- run_script: Use `script_id` -Correct approach: - User: "Turn off the study lights" - You: list_devices(domain="light") ← First discover what exists - You: [See results like light.study_main, light.study] - You: turn_off("light.study_main"), turn_off("light.study") ← Use actual IDs +## What NOT To Do -## Best Practices - -1. **ALWAYS list_devices first** before any control action. No exceptions. - Filter by domain and/or area to narrow results. - -2. **Use exact entity_ids** from list_devices results. Never construct or guess them. - -3. **Area-aware filtering**: Use area parameter when users mention a room. - Note: Some devices may have area=None but contain the room name in entity_id. - -4. **Verify after actions**: Use get_device_state to confirm state changes if needed. - -5. **Safety for bulk actions**: When affecting multiple devices, summarize first. - -## Common Patterns - -- "Turn on the lights" → list_devices(domain="light"), then turn_on each -- "What's on?" → list_devices() and filter for state="on" -- "Movie time" → Either activate_scene("scene.movie_night") or run_script if available -- "Dim the bedroom" → turn_on("light.bedroom", brightness=64) +- NEVER guess an entity_id +- NEVER construct an entity_id from the room name +- NEVER drop the domain prefix (light., switch., etc.) +- NEVER use "device_id" - the parameter is called "entity_id" +- NEVER provide an answer without calling list_devices first ## Response Format -Your responses are returned to Tatlock (the butler) who will synthesize them into -a final answer for the user. Keep this in mind: -- Lead with confirmation of what you did or found -- Be specific about which devices were affected -- Include relevant state information -- Note any issues or failures -- Be concise - Tatlock will format the final response + +After completing actions, briefly confirm: +- Which devices were affected (list the entity_ids) +- Whether each action succeeded or failed """ # Lazy initialization to avoid connection issues during imports @@ -231,9 +207,13 @@ async def run_housekeeper( ) try: + # Use temperature 0.1 for slight exploration + from pydantic_ai.settings import ModelSettings + result = await agent.run( prompt, message_history=message_history, + model_settings=ModelSettings(temperature=0.1), ) logger.info( @@ -289,9 +269,13 @@ async def run_housekeeper_stream( ) try: + # Use temperature 0.1 for slight exploration + from pydantic_ai.settings import ModelSettings + async with agent.run_stream( prompt, message_history=message_history, + model_settings=ModelSettings(temperature=0.1), ) as response: async for delta in response.stream_text(delta=True): yield delta diff --git a/src/agents/housekeeper/tools.py b/src/agents/housekeeper/tools.py index cf8d857..e95946d 100644 --- a/src/agents/housekeeper/tools.py +++ b/src/agents/housekeeper/tools.py @@ -59,10 +59,27 @@ async def list_devices( for dom, dom_devices in sorted(by_domain.items()): output_parts.append(f"### {dom.title()}s") - for device in dom_devices: + + # Sort devices: room groups first (using Home Assistant's is_hue_group attribute) + def is_room_group(d: object) -> bool: + """Check if device is a room group based on HA attributes.""" + attrs = getattr(d, "attributes", {}) + # Check for Hue room groups + if attrs.get("is_hue_group") and attrs.get("hue_type") == "room": + return True + # Check for other group indicators (icon or entity_id list) + if "entity_id" in attrs and isinstance(attrs["entity_id"], list): + return True + return False + + sorted_devices = sorted(dom_devices, key=lambda d: (not is_room_group(d), d.entity_id)) + + for device in sorted_devices: state_icon = "on" if device.state == "on" else "off" if device.state == "off" else device.state area_str = f" ({device.area})" if device.area else "" - output_parts.append(f"- **{device.name}**{area_str}: {state_icon}") + # Mark room groups clearly using actual HA data + group_marker = " [ROOM GROUP]" if is_room_group(device) else "" + output_parts.append(f"- **{device.name}**{area_str}{group_marker}: {state_icon}") output_parts.append(f" ID: `{device.entity_id}`") output_parts.append("") @@ -164,12 +181,12 @@ async def turn_on( color_temp: int | None = None, ) -> str: """ - Turn on a device. + Turn on a device. Use the entity_id parameter with the EXACT value from list_devices. For lights, can optionally set brightness and color temperature. Args: - entity_id: Device to turn on (e.g., light.living_room, switch.coffee_maker) + entity_id: The EXACT entity ID from list_devices including domain prefix. brightness: Optional brightness for lights (0-255, where 255 is full brightness) color_temp: Optional color temperature in Kelvin (2700=warm, 6500=cool) @@ -177,10 +194,9 @@ async def turn_on( Confirmation of the action Examples: - turn_on("light.living_room") # Turn on at current brightness - turn_on("light.bedroom", brightness=128) # Turn on at 50% brightness - turn_on("light.office", brightness=255, color_temp=4000) # Full, neutral white - turn_on("switch.coffee_maker") # Turn on a switch + turn_on(entity_id="light.living_room") + turn_on(entity_id="light.bedroom", brightness=128) + turn_on(entity_id="switch.coffee_maker") """ try: async with CoreAPIClient() as client: @@ -209,17 +225,18 @@ async def turn_on( async def turn_off(entity_id: str) -> str: """ - Turn off a device. + Turn off a device. Use the entity_id parameter with the EXACT value from list_devices. Args: - entity_id: Device to turn off (e.g., light.living_room, switch.coffee_maker) + entity_id: The EXACT entity ID from list_devices including domain prefix. Returns: Confirmation of the action Examples: - turn_off("light.living_room") - turn_off("switch.coffee_maker") + turn_off(entity_id="light.living_room") + turn_off(entity_id="switch.coffee_maker") + turn_off(entity_id="light.kitchen") """ try: async with CoreAPIClient() as client: @@ -239,15 +256,17 @@ async def toggle(entity_id: str) -> str: """ Toggle a device's state (on becomes off, off becomes on). + Use the entity_id parameter with the EXACT value from list_devices. + Args: - entity_id: Device to toggle + entity_id: The EXACT entity ID from list_devices including domain prefix. Returns: Confirmation with the new state Examples: - toggle("light.living_room") - toggle("switch.fan") + toggle(entity_id="light.living_room") + toggle(entity_id="switch.fan") """ try: async with CoreAPIClient() as client: