Skip to content

Commit 8086aa8

Browse files
authored
Merge pull request #175 from quangdang46/fix/ambient-serde-string-or-u32
fix(ambient): handle string or u32 for Claude tool parameters (#133)
2 parents b0ec6fe + d5f156c commit 8086aa8

2 files changed

Lines changed: 168 additions & 4 deletions

File tree

src/tool/ambient.rs

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use crate::safety::{self, PermissionRequest, PermissionResult, SafetySystem, Urg
88
use anyhow::Result;
99
use async_trait::async_trait;
1010
use chrono::Utc;
11-
use serde::Deserialize;
11+
use serde::{Deserialize, Deserializer};
1212
use serde_json::{Map, Value, json};
1313
use std::collections::HashSet;
1414
use std::sync::{Arc, Mutex, OnceLock};
@@ -119,10 +119,98 @@ impl EndAmbientCycleTool {
119119
}
120120
}
121121

122+
// ---------------------------------------------------------------------------
123+
// Custom deserializers: accept either a JSON number or a numeric string for
124+
// u32 fields. Claude tool calls occasionally serialize numeric arguments as
125+
// strings (e.g. {"compactions": "0"} instead of {"compactions": 0}), which
126+
// caused every ambient cycle to fail with `invalid type: string "0", expected
127+
// u32`. See issue #133 / upstream PR #173.
128+
// ---------------------------------------------------------------------------
129+
130+
fn deserialize_string_or_u32<'de, D>(deserializer: D) -> Result<u32, D::Error>
131+
where
132+
D: Deserializer<'de>,
133+
{
134+
use serde::de::{self, Visitor};
135+
struct StringOrU32;
136+
137+
impl<'de> Visitor<'de> for StringOrU32 {
138+
type Value = u32;
139+
140+
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
141+
f.write_str("u32 or string representing u32")
142+
}
143+
144+
fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
145+
where
146+
E: de::Error,
147+
{
148+
u32::try_from(v).map_err(E::custom)
149+
}
150+
151+
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
152+
where
153+
E: de::Error,
154+
{
155+
v.parse().map_err(E::custom)
156+
}
157+
}
158+
159+
deserializer.deserialize_any(StringOrU32)
160+
}
161+
162+
fn deserialize_string_or_option_u32<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
163+
where
164+
D: Deserializer<'de>,
165+
{
166+
use serde::de::{self, Visitor};
167+
struct StringOrOptionU32;
168+
169+
impl<'de> Visitor<'de> for StringOrOptionU32 {
170+
type Value = Option<u32>;
171+
172+
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
173+
f.write_str("optional u32 or string representing u32")
174+
}
175+
176+
fn visit_none<E>(self) -> Result<Self::Value, E>
177+
where
178+
E: de::Error,
179+
{
180+
Ok(None)
181+
}
182+
183+
fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
184+
where
185+
D: Deserializer<'de>,
186+
{
187+
deserialize_string_or_u32(deserializer).map(Some)
188+
}
189+
190+
fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
191+
where
192+
E: de::Error,
193+
{
194+
u32::try_from(v).map_err(E::custom).map(Some)
195+
}
196+
197+
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
198+
where
199+
E: de::Error,
200+
{
201+
v.parse().map_err(E::custom).map(Some)
202+
}
203+
}
204+
205+
deserializer.deserialize_option(StringOrOptionU32)
206+
}
207+
122208
#[derive(Deserialize)]
123209
struct EndCycleInput {
124210
summary: String,
211+
#[serde(deserialize_with = "deserialize_string_or_u32")]
125212
memories_modified: u32,
213+
#[serde(deserialize_with = "deserialize_string_or_u32")]
126214
compactions: u32,
127215
#[serde(default)]
128216
proactive_work: Option<String>,
@@ -132,7 +220,7 @@ struct EndCycleInput {
132220

133221
#[derive(Deserialize)]
134222
struct NextScheduleInput {
135-
#[serde(default)]
223+
#[serde(default, deserialize_with = "deserialize_string_or_option_u32")]
136224
wake_in_minutes: Option<u32>,
137225
#[serde(default)]
138226
context: Option<String>,
@@ -280,7 +368,7 @@ impl ScheduleAmbientTool {
280368

281369
#[derive(Deserialize)]
282370
struct ScheduleInput {
283-
#[serde(default)]
371+
#[serde(default, deserialize_with = "deserialize_string_or_option_u32")]
284372
wake_in_minutes: Option<u32>,
285373
#[serde(default)]
286374
wake_at: Option<String>,
@@ -722,7 +810,7 @@ struct ScheduleToolInput {
722810
schedule_id: Option<String>,
723811
#[serde(default)]
724812
task: Option<String>,
725-
#[serde(default)]
813+
#[serde(default, deserialize_with = "deserialize_string_or_option_u32")]
726814
wake_in_minutes: Option<u32>,
727815
#[serde(default)]
728816
wake_at: Option<String>,

src/tool/ambient/tests.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,3 +445,79 @@ async fn test_schedule_tool_requires_time() {
445445
.expect_err("should require wake_in_minutes or wake_at");
446446
assert!(err.to_string().contains("wake_in_minutes"));
447447
}
448+
449+
// ---------------------------------------------------------------------------
450+
// Regression tests for issue #133 / upstream PR #173:
451+
// Claude tool calls sometimes serialize numeric parameters as strings
452+
// (e.g. {"compactions": "0"}). Both number and string forms must deserialize.
453+
// ---------------------------------------------------------------------------
454+
455+
#[test]
456+
fn test_end_cycle_input_accepts_stringified_u32_fields() {
457+
let input = json!({
458+
"summary": "Compaction skipped",
459+
"memories_modified": "3",
460+
"compactions": "0",
461+
"next_schedule": {
462+
"wake_in_minutes": "20",
463+
"context": "Recheck stale facts",
464+
"priority": "normal"
465+
}
466+
});
467+
468+
let parsed: EndCycleInput = serde_json::from_value(input).unwrap();
469+
assert_eq!(parsed.memories_modified, 3);
470+
assert_eq!(parsed.compactions, 0);
471+
let ns = parsed.next_schedule.unwrap();
472+
assert_eq!(ns.wake_in_minutes, Some(20));
473+
}
474+
475+
#[test]
476+
fn test_end_cycle_input_still_accepts_native_u32_fields() {
477+
// Make sure the new deserializer didn't break the existing JSON-number form.
478+
let input = json!({
479+
"summary": "All good",
480+
"memories_modified": 7,
481+
"compactions": 2,
482+
"next_schedule": {
483+
"wake_in_minutes": 15
484+
}
485+
});
486+
let parsed: EndCycleInput = serde_json::from_value(input).unwrap();
487+
assert_eq!(parsed.memories_modified, 7);
488+
assert_eq!(parsed.compactions, 2);
489+
assert_eq!(parsed.next_schedule.unwrap().wake_in_minutes, Some(15));
490+
}
491+
492+
#[test]
493+
fn test_schedule_input_accepts_stringified_wake_in_minutes() {
494+
let input = json!({
495+
"wake_in_minutes": "45",
496+
"context": "Verify CI"
497+
});
498+
let parsed: ScheduleInput = serde_json::from_value(input).unwrap();
499+
assert_eq!(parsed.wake_in_minutes, Some(45));
500+
assert_eq!(parsed.context, "Verify CI");
501+
}
502+
503+
#[test]
504+
fn test_schedule_tool_input_accepts_stringified_wake_in_minutes() {
505+
let input = json!({
506+
"task": "Check on tests",
507+
"wake_in_minutes": "60"
508+
});
509+
let parsed: ScheduleToolInput = serde_json::from_value(input).unwrap();
510+
assert_eq!(parsed.wake_in_minutes, Some(60));
511+
}
512+
513+
#[test]
514+
fn test_end_cycle_input_rejects_non_numeric_string() {
515+
// Defensive: a non-numeric string must still be rejected, not silently
516+
// treated as 0.
517+
let input = json!({
518+
"summary": "Bad input",
519+
"memories_modified": "not-a-number",
520+
"compactions": 0
521+
});
522+
assert!(serde_json::from_value::<EndCycleInput>(input).is_err());
523+
}

0 commit comments

Comments
 (0)