Skip to content

fix(quick-router): a scheduled or conditional setpoint actuates immediately - #917

Open
kai392 wants to merge 1 commit into
GeniePod:mainfrom
kai392:fix/critical-issue-scheduled-setpoint-fires-now
Open

fix(quick-router): a scheduled or conditional setpoint actuates immediately#917
kai392 wants to merge 1 commit into
GeniePod:mainfrom
kai392:fix/critical-issue-scheduled-setpoint-fires-now

Conversation

@kai392

@kai392 kai392 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Fixes #914.

Root cause

#828 / #829 fixed the relative delay on the setpoint path. Every other way of
qualifying the same setpoint still actuates immediately.

home_control_request's set /preheat branch guards only in <duration>.
parse_temperature_target then splits on to and hands the whole remainder to
parse_amount, which reads 68 straight out of "68 at 9pm" — the qualifying
clause is never inspected, so the setpoint fires now and the caller's actual
request is silently dropped.

"Set the thermostat to 68 at 9pm"                 -> home_control {set_temperature, 68}
"Set the thermostat to 68 tonight"                -> home_control {set_temperature, 68}
"Set the thermostat to 68 when I get home"        -> home_control {set_temperature, 68}
"Set the lights to 40 percent except the bedroom" -> home_control {set_brightness, 40}

The exclusion case is the worst: the room the caller excluded is dimmed anyway —
the outcome canonicalize_household_action explicitly refuses to produce for the
*_except verbs.

This is a gap rather than a missing feature because the turn_on/turn_off path
already refuses all of it. simple_turn_request has a multi-clause guard and its
known_device gate rejects a trailing schedule, so "Turn on the porch light at 9pm" and "Turn on the porch light when I get home" both abstain today. Only
the setpoint path was missed.

Fix

Two guards on the same branch, both mirroring what already exists elsewhere in
this function:

  1. Absolute / event-anchored schedule — keyed on the tail after the value,
    so the device half is untouched, and only on the to split, because
    parse_temperature_target also accepts at as the value separator
    ("set the thermostat at 68" must keep routing).
  2. Conditional / exclusion / whole-house — the same marker set
    simple_turn_request applies, minus and: on a setpoint that joins a
    spoken compound number ("one hundred and five"), not a second device.

Impact

Scheduled and conditional setpoints abstain so the LLM can arm them, matching the
turn path. Unqualified setpoints are unchanged — the new test pins six of them,
including the at value separator and the trailing-room form the #829 test
already covers.

Risk / tradeoffs

Low, and it is a narrowing only — no utterance starts routing that did not
before.

The honest cost: the marker lists are keyword-based, so a schedule phrased
outside them ("set the thermostat to 68 once the kids are asleep") still fires
now, and a device whose name contains a marker word would abstain. Neither is
new — both lists are the ones the turn path and #829 already live with, and I
kept them identical rather than inventing a third dialect. A grammar-level fix
(require the value tail to be a bare number plus a unit) would be more complete
but is a much larger change to parse_temperature_target's contract, and I did
not want to bundle it here.

Real Behavior Proof

  • I have built and run the affected code locally (or noted why I could not).
  • I have NOT verified on Jetson hardware, and I explain the equivalent verification path or validation gap below.

Tested profile / hardware:

  • laptop (Windows host — see the gap note below)
  • CI-only / docs-only

What I ran

I do not have a Jetson, and genie-core does not build on my Windows host
(libc::gmtime_r, tokio::signal::unix and friends are Unix-only), so the
routing change was exercised two ways.

1. Direct route() calls. I compiled the four quick-router source files
unmodifiedcrates/genie-core/src/tools/{quick,home_action,calc_input,number_words}.rs,
included by #[path], with only ToolCall and HomeActionKind stubbed — into a
throwaway binary and called tools::quick::route() directly. No logic was copied
or reimplemented.

2. cargo test -p genie-core on Linux via GitHub Actions, plus the rest of
the matrix (cargo fmt, cargo clippy -D warnings, --no-default-features, and
the aarch64-unknown-linux-gnu release build), run on this exact commit before
opening this PR: kai392#6 — all green.

test tools::quick::tests::setpoint_with_an_absolute_schedule_or_condition_abstains ... ok
test tools::quick::tests::setpoint_with_scheduled_delay_abstains_instead_of_firing_now ... ok
test result: ok. 902 passed; 0 failed; 6 ignored; 0 measured; 0 filtered out

The second of those is the pre-existing #829 test; it still passes unchanged.

What I observed

Before, on main @ 02a577d:

"Set the thermostat to 68 at 9pm"                 -> home_control {"action":"set_temperature","entity":"thermostat","value":68}
"Set the thermostat to 68 at bedtime"             -> home_control {"action":"set_temperature","entity":"thermostat","value":68}
"Set the thermostat to 68 tonight"                -> home_control {"action":"set_temperature","entity":"thermostat","value":68}
"Set the thermostat to 68 tomorrow"               -> home_control {"action":"set_temperature","entity":"thermostat","value":68}
"Set the thermostat to 68 before bed"             -> home_control {"action":"set_temperature","entity":"thermostat","value":68}
"Set the lights to 30 percent at 9pm"             -> home_control {"action":"set_brightness","entity":"lights","value":30.0}
"Set the thermostat to 68 when I get home"        -> home_control {"action":"set_temperature","entity":"thermostat","value":68}
"Set the thermostat to 68 unless it is cold"      -> home_control {"action":"set_temperature","entity":"thermostat","value":68}
"Set the thermostat to 68 only at night"          -> home_control {"action":"set_temperature","entity":"thermostat","value":68}
"Set the lights to 40 percent except the bedroom" -> home_control {"action":"set_brightness","entity":"lights","value":40.0}

For contrast, the turn path on the same qualifiers, unchanged by this PR:

"Turn on the porch light at 9pm"                  -> (abstain, falls through to the LLM)
"Turn off the lights tonight"                     -> (abstain, falls through to the LLM)
"Turn on the porch light when I get home"         -> (abstain, falls through to the LLM)

After, same harness, same build — the setpoint path now matches:

"Set the thermostat to 68 at 9pm"                 -> (abstain, falls through to the LLM)
"Set the thermostat to 68 at bedtime"             -> (abstain, falls through to the LLM)
"Set the thermostat to 68 tonight"                -> (abstain, falls through to the LLM)
"Set the thermostat to 68 tomorrow"               -> (abstain, falls through to the LLM)
"Set the thermostat to 68 before bed"             -> (abstain, falls through to the LLM)
"Set the lights to 30 percent at 9pm"             -> (abstain, falls through to the LLM)
"Set the thermostat to 68 when I get home"        -> (abstain, falls through to the LLM)
"Set the thermostat to 68 unless it is cold"      -> (abstain, falls through to the LLM)
"Set the thermostat to 68 only at night"          -> (abstain, falls through to the LLM)
"Set the lights to 40 percent except the bedroom" -> (abstain, falls through to the LLM)
"Set the thermostat to 68 in an hour"             -> (abstain, falls through to the LLM)   [#829, unchanged]

"Set the thermostat to 68"                        -> home_control {"action":"set_temperature","entity":"thermostat","value":68}
"Set the thermostat to 72"                        -> home_control {"action":"set_temperature","entity":"thermostat","value":72}
"set the thermostat to 68 in the den"             -> home_control {"action":"set_temperature","entity":"thermostat","value":68}
"Set the oven to 400 degrees"                     -> home_control {"action":"set_temperature","entity":"oven","value":400}
"Preheat the oven to 350"                         -> home_control {"action":"set_temperature","entity":"oven","value":350}
"Set the lights to 40 percent"                    -> home_control {"action":"set_brightness","entity":"lights","value":40.0}
"set the bedroom lamp to 30 percent"              -> home_control {"action":"set_brightness","entity":"bedroom lamp","value":30.0}
"Set the thermostat to seventy"                   -> home_control {"action":"set_temperature","entity":"thermostat","value":70}
"Set up the slow cooker for chili"                -> home_control {"action":"activate","entity":"slow cooker chili"}

Validation gap

The routing half is fully covered above. What I could not verify is the
downstream half: that the LLM, now that it sees "set the thermostat to 68 at 9pm", actually arms a schedule rather than actuating — and that the exclusion
form reaches Home Assistant as a correctly-scoped call. Both need the model and a
live HA on device. With deploy/homeassistant/ up, a maintainer could confirm
the thermostat does not change state at request time for the scheduled form.

Summary by CodeRabbit

  • Bug Fixes
    • Improved handling of scheduled home-control requests, including commands tied to specific times or events.
    • Prevented automatic actions when requests include conditions, exclusions, or whole-home qualifiers.
    • Preserved direct device control for clear, unqualified setpoint commands.

… now

`home_control_request`'s setpoint path guards only a relative delay
("in an hour", GeniePod#828/GeniePod#829). Every other qualifier on the same clause was
dropped and the setpoint actuated immediately, because
`parse_amount` reads "68" straight out of "68 at 9pm":

  set the thermostat to 68 at 9pm            -> fires now
  set the thermostat to 68 tonight           -> fires now
  set the thermostat to 68 when i get home   -> fires now
  set the lights to 40 percent except the bedroom -> dims the bedroom

The turn_on/turn_off path already refuses all of these; only the
setpoint path was missing the check. The exclusion case is the worst of
them: it actuates the device the caller just excluded, which is exactly
what `canonicalize_household_action` refuses to do for the `*_except`
verbs.

Add the absolute/event-anchored schedule guard on the value tail, and
the conditional/exclusion guard `simple_turn_request` already applies.
" and " is left out of the second list — on a setpoint it joins a spoken
compound number, not a second device.
@github-actions github-actions Bot added the bug Something isn't working label Jul 30, 2026
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 37e12e21-5644-4b06-bdca-0e9add045ceb

📥 Commits

Reviewing files that changed from the base of the PR and between 02a577d and 4008cc8.

📒 Files selected for processing (1)
  • crates/genie-core/src/tools/quick.rs
 __________________
< Zero bugs given. >
 ------------------
  \
   \   \
        \ /\
        ( )
      .( o ).
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] quick-router: "set the thermostat to 68 at 9pm" actuates now — only relative delays are guarded, not absolute schedules or conditions

1 participant