@@ -153,27 +153,30 @@ async def _exact_match_search(
153153
154154def _project_entity (
155155 record : dict [str , Any ],
156- fields : str | list [str ] | None ,
157- attribute_keys : str | list [str ] | None ,
156+ fields : list [str ] | None ,
157+ attribute_keys : list [str ] | None ,
158158) -> dict [str , Any ]:
159159 """Apply optional field projection to a HA entity record.
160160
161161 ``fields`` filters which top-level keys to keep (e.g. ["state", "attributes"]).
162162 ``attribute_keys`` further filters the ``attributes`` sub-dict.
163163 Both default None = full payload (no-op).
164- Accepts a list or a CSV/JSON-array string for both parameters.
164+
165+ Both parameters are already parsed into ``list[str] | None`` — string/CSV inputs
166+ must be normalised at the call site via ``parse_string_list_param`` (see
167+ ``ha_get_state`` which parses once before the bulk loop to avoid re-parsing per
168+ entity record).
165169 """
166170 if not isinstance (record , dict ):
167171 return record # non-dict (e.g. error path returning None) — skip projection
168172 if fields is not None :
169- parsed_fields = parse_string_list_param (fields , "fields" , allow_csv = True ) or []
170- keep = set (parsed_fields )
173+ keep = set (fields )
171174 record = {k : v for k , v in record .items () if k in keep }
172175 if attribute_keys is not None :
173- parsed_attr_keys = parse_string_list_param (attribute_keys , "attribute_keys" , allow_csv = True ) or []
174176 attrs = record .get ("attributes" )
175177 if isinstance (attrs , dict ):
176- record = {** record , "attributes" : {k : v for k , v in attrs .items () if k in parsed_attr_keys }}
178+ attr_keep = set (attribute_keys )
179+ record = {** record , "attributes" : {k : v for k , v in attrs .items () if k in attr_keep }}
177180 return record
178181
179182
@@ -920,8 +923,9 @@ async def ha_get_overview(
920923 Standard/full modes paginate entities (default 200 per page) — use offset
921924 to fetch more. Use 'domains' filter to narrow scope.
922925
923- Use fields= to project the response to only the keys you need — up to 94%
924- token reduction when fetching a single sub-section (e.g. fields=["system_info"]).
926+ Use fields= to project the response to only the keys you need — a
927+ significantly smaller payload when fetching a single sub-section (e.g.
928+ fields=["system_info"] returns just that section instead of the full overview).
925929 """
926930 # Validate fields= early so a malformed value returns VALIDATION_INVALID_PARAMETER
927931 # (ha_get_overview has no outer try/except, so ValueError would escape uncaught)
@@ -1250,17 +1254,61 @@ async def ha_get_state(
12501254 Returns success=True if at least one entity state was retrieved.
12511255 Check 'error_count' for any failed lookups in partial-success scenarios.
12521256
1257+ FIELDS PROJECTION:
1258+ `fields=` projects the per-entity record (`entity_id`, `state`, `attributes`,
1259+ `last_changed`, `last_updated`, `context`), NOT the outer bulk response wrapper.
1260+ In single-entity mode it filters keys of the returned record directly. In bulk
1261+ mode it filters keys of each record inside `states[entity_id]`; outer keys
1262+ (`success`, `count`, `states`, `errors`, ...) are always preserved.
1263+ `attribute_keys=` further narrows the `attributes` sub-dict and is only applied
1264+ when `"attributes"` is in `fields=` (or `fields=None`); otherwise it is a no-op.
1265+
12531266 EXAMPLES:
12541267 - Single: ha_get_state("light.kitchen")
12551268 - Multiple: ha_get_state(["light.kitchen", "light.living_room", "sensor.temperature"])
12561269 - State only: ha_get_state("light.kitchen", fields=["state"])
12571270 - Slim bulk: ha_get_state(["light.kitchen", "sensor.temperature"], fields=["state", "attributes"], attribute_keys=["brightness"])
12581271 """
1272+ # Parse projection params once up front so the bulk loop doesn't re-parse
1273+ # the same string/CSV input per entity (100 entities → 200 parses pre-fix).
1274+ # parse_string_list_param raises ValueError on bad input; surface as
1275+ # VALIDATION_INVALID_PARAMETER via the normal ToolError flow.
1276+ try :
1277+ parsed_fields = parse_string_list_param (fields , "fields" , allow_csv = True )
1278+ parsed_attribute_keys = parse_string_list_param (
1279+ attribute_keys , "attribute_keys" , allow_csv = True
1280+ )
1281+ except ValueError as e :
1282+ raise_tool_error (
1283+ create_validation_error (
1284+ str (e ),
1285+ parameter = (
1286+ "attribute_keys" if "attribute_keys" in str (e ) else "fields"
1287+ ),
1288+ )
1289+ )
1290+
1291+ # `attribute_keys` only takes effect when `attributes` is in the projected
1292+ # field set (or `fields=None`). Surface a warning rather than silently
1293+ # ignoring it — caller likely intended to slim attributes and would
1294+ # otherwise see an unfiltered or absent `attributes` key with no signal.
1295+ attribute_keys_no_effect = (
1296+ parsed_attribute_keys is not None
1297+ and parsed_fields is not None
1298+ and "attributes" not in parsed_fields
1299+ )
1300+
12591301 # Single entity path
12601302 if isinstance (entity_id , str ):
12611303 try :
12621304 result = await client .get_entity_state (entity_id )
1263- result = _project_entity (result , fields , attribute_keys )
1305+ result = _project_entity (result , parsed_fields , parsed_attribute_keys )
1306+ if attribute_keys_no_effect and isinstance (result , dict ):
1307+ result ["warning" ] = (
1308+ "attribute_keys was ignored because 'attributes' is not in "
1309+ "fields=. Add 'attributes' to fields= (or omit fields=) to "
1310+ "apply attribute_keys."
1311+ )
12641312 return await add_timezone_metadata (client , result )
12651313 except ToolError :
12661314 raise
@@ -1332,7 +1380,9 @@ async def _fetch_state(eid: str) -> dict[str, Any]:
13321380
13331381 for eid , result in zip (unique_ids , results , strict = True ):
13341382 if result .get ("success" ) is True and "state" in result :
1335- states [eid ] = _project_entity (result ["state" ], fields , attribute_keys )
1383+ states [eid ] = _project_entity (
1384+ result ["state" ], parsed_fields , parsed_attribute_keys
1385+ )
13361386 else :
13371387 error_detail = result .get ("error" )
13381388 if error_detail is None :
@@ -1353,6 +1403,13 @@ async def _fetch_state(eid: str) -> dict[str, Any]:
13531403 "states" : states ,
13541404 }
13551405
1406+ if attribute_keys_no_effect :
1407+ response ["warning" ] = (
1408+ "attribute_keys was ignored because 'attributes' is not in "
1409+ "fields=. Add 'attributes' to fields= (or omit fields=) to "
1410+ "apply attribute_keys."
1411+ )
1412+
13561413 if errors :
13571414 response ["errors" ] = errors
13581415 response ["error_count" ] = len (errors )
0 commit comments