Skip to content

Commit d80fc77

Browse files
committed
fix(tools): reject non-numeric home_control value at execution boundary
parse_home_control_args read the optional value argument with `args.get("value").and_then(|v| v.as_f64())`, which silently dropped a *provided* non-numeric value to None. A model emitting `"value": "72"` (string) or `"value": true` for set_temperature / set_brightness would actuate the device with no value instead of the user's intent, with no error surfaced or audited. Keep value optional — absent or explicit null is still a no-op None — but reject a provided value that is not a number, the same way set_timer rejects a non-integer 'seconds'. The malformed call now fails at the boundary and is tool-audited as a failure instead of silently mis-actuating. Cover the contract with a parse_home_control_args unit test (numeric -> Some, absent/null -> None, string/bool/array -> rejected) and extend the home_control tool-gate integration test with a non-numeric value case.
1 parent e5755b6 commit d80fc77

2 files changed

Lines changed: 54 additions & 1 deletion

File tree

crates/genie-core/src/tools/dispatch.rs

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,20 @@ fn parse_home_control_args(args: &serde_json::Value) -> Result<(&str, &str, Opti
5252
HOME_CONTROL_ACTIONS.join(", ")
5353
)
5454
})?;
55-
Ok((entity, action, args.get("value").and_then(|v| v.as_f64())))
55+
// `value` stays optional, but a *provided* value must be a number. The old
56+
// `args.get("value").and_then(|v| v.as_f64())` silently dropped a non-numeric
57+
// value (e.g. a model emitting `"value": "72"` or `"value": true`) to `None`,
58+
// so `set_temperature` / `set_brightness` actuated with no value instead of
59+
// the user's intent. Reject the malformed value at the boundary the same way
60+
// set_timer rejects a non-integer `seconds`; an absent or null value is still
61+
// a no-op None.
62+
let value = match args.get("value") {
63+
None | Some(serde_json::Value::Null) => None,
64+
Some(provided) => Some(provided.as_f64().ok_or_else(|| {
65+
anyhow::anyhow!("home_control 'value' must be a number when provided")
66+
})?),
67+
};
68+
Ok((entity, action, value))
5669
}
5770

5871
/// Canonicalize a model-emitted action verb to one of [`HOME_CONTROL_ACTIONS`].
@@ -2508,6 +2521,42 @@ mod tests {
25082521
assert_eq!(action, "turn_off");
25092522
}
25102523

2524+
#[test]
2525+
fn home_control_value_must_be_numeric_when_provided() {
2526+
// A numeric value parses through to Some(..).
2527+
let args =
2528+
serde_json::json!({"entity": "thermostat", "action": "set_temperature", "value": 72});
2529+
let (_, _, value) = parse_home_control_args(&args).expect("numeric value parses");
2530+
assert_eq!(value, Some(72.0));
2531+
2532+
// An absent value is a legitimate no-op None — value stays optional.
2533+
let args = serde_json::json!({"entity": "kitchen lights", "action": "turn_on"});
2534+
let (_, _, value) = parse_home_control_args(&args).expect("absent value parses");
2535+
assert_eq!(value, None);
2536+
2537+
// An explicit null is also None, not a rejection.
2538+
let args =
2539+
serde_json::json!({"entity": "kitchen lights", "action": "turn_on", "value": null});
2540+
let (_, _, value) = parse_home_control_args(&args).expect("null value parses");
2541+
assert_eq!(value, None);
2542+
2543+
// The bug: a provided but non-numeric value used to be silently dropped
2544+
// to None, so the action actuated without it. It must now be rejected.
2545+
for bad in [
2546+
serde_json::json!({"entity": "thermostat", "action": "set_temperature", "value": "72"}),
2547+
serde_json::json!({"entity": "thermostat", "action": "set_temperature", "value": true}),
2548+
serde_json::json!({"entity": "thermostat", "action": "set_temperature", "value": [72]}),
2549+
] {
2550+
let err = parse_home_control_args(&bad)
2551+
.expect_err("non-numeric value must be rejected")
2552+
.to_string();
2553+
assert!(
2554+
err.contains("home_control 'value' must be a number when provided"),
2555+
"unexpected error: {err}"
2556+
);
2557+
}
2558+
}
2559+
25112560
#[test]
25122561
fn tool_action_class_maps_side_effecting_tools() {
25132562
assert_eq!(

crates/genie-core/tests/tool_gate_integration_test.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,10 @@ async fn home_control_rejects_invalid_arguments_and_audits() {
492492
serde_json::json!({"entity": "kitchen light"}),
493493
"home_control requires string argument 'action'",
494494
),
495+
(
496+
serde_json::json!({"entity": "kitchen light", "action": "turn_on", "value": "hot"}),
497+
"home_control 'value' must be a number when provided",
498+
),
495499
];
496500
let expected_audit_count = invalid_calls.len();
497501

0 commit comments

Comments
 (0)