Skip to main content

gemini_live_tools/
timer.rs

1//! Reusable timer tool family for Gemini Live hosts.
2//!
3//! The timer tool is intentionally simple: it waits for a caller-specified
4//! duration and then returns a completion payload. When wrapped by the harness
5//! with a short inline budget, longer waits naturally spill into a durable
6//! background task and later passive notification, which makes this tool a good
7//! end-to-end probe for the harness lifecycle.
8
9use std::time::Duration;
10
11use futures_util::future::BoxFuture;
12use gemini_live::types::{FunctionCallRequest, FunctionDeclaration, FunctionResponse, Tool};
13use gemini_live_harness::{
14    ToolCapability, ToolDescriptor, ToolExecutionError, ToolExecutor, ToolKind, ToolProvider,
15    ToolSpecification,
16};
17use serde::{Deserialize, Serialize};
18use serde_json::{Map, Value, json};
19
20const MAX_TIMER_DURATION_SECS: u64 = 366 * 24 * 60 * 60;
21const MAX_LABEL_CHARS: usize = 200;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum TimerToolId {
25    SetTimer,
26}
27
28impl TimerToolId {
29    pub const ALL: [Self; 1] = [Self::SetTimer];
30
31    pub fn key(self) -> &'static str {
32        match self {
33            Self::SetTimer => "timer",
34        }
35    }
36
37    pub fn summary(self) -> &'static str {
38        match self {
39            Self::SetTimer => {
40                "wait for a duration and notify later when it exceeds the inline budget"
41            }
42        }
43    }
44
45    pub fn function_name(self) -> &'static str {
46        match self {
47            Self::SetTimer => "set_timer",
48        }
49    }
50
51    pub fn from_function_name(name: &str) -> Option<Self> {
52        match name {
53            "set_timer" => Some(Self::SetTimer),
54            _ => None,
55        }
56    }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase")]
61#[serde(default)]
62pub struct TimerToolSelection {
63    pub timer: bool,
64}
65
66impl Default for TimerToolSelection {
67    fn default() -> Self {
68        Self { timer: true }
69    }
70}
71
72impl TimerToolSelection {
73    pub fn is_enabled(self, tool: TimerToolId) -> bool {
74        match tool {
75            TimerToolId::SetTimer => self.timer,
76        }
77    }
78
79    pub fn set(&mut self, tool: TimerToolId, enabled: bool) -> bool {
80        let slot = match tool {
81            TimerToolId::SetTimer => &mut self.timer,
82        };
83        let changed = *slot != enabled;
84        *slot = enabled;
85        changed
86    }
87
88    pub fn function_declarations(self) -> Vec<FunctionDeclaration> {
89        let mut functions = Vec::new();
90        if self.timer {
91            functions.push(set_timer_declaration());
92        }
93        functions
94    }
95
96    pub fn build_live_tool(self) -> Option<Tool> {
97        let functions = self.function_declarations();
98        (!functions.is_empty()).then_some(Tool::FunctionDeclarations(functions))
99    }
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub struct TimerToolAdapter {
104    selection: TimerToolSelection,
105}
106
107impl TimerToolAdapter {
108    pub fn new(selection: TimerToolSelection) -> Self {
109        Self { selection }
110    }
111
112    pub fn selection(&self) -> TimerToolSelection {
113        self.selection
114    }
115
116    pub async fn execute_call(&self, call: FunctionCallRequest) -> FunctionResponse {
117        let result = match TimerToolId::from_function_name(call.name.as_str()) {
118            Some(tool) if self.selection.is_enabled(tool) => {
119                self.execute_enabled_call(call.args.as_object()).await
120            }
121            Some(_) => Err(format!(
122                "tool `{}` is not enabled in the active profile",
123                call.name
124            )),
125            None => Err(format!("unknown timer tool `{}`", call.name)),
126        };
127
128        function_response(call, result)
129    }
130
131    async fn execute_enabled_call(
132        &self,
133        args: Option<&Map<String, Value>>,
134    ) -> Result<Value, String> {
135        let request = TimerRequest::from_args(args)?;
136        tokio::time::sleep(request.duration()).await;
137
138        let duration_text = format_duration(request.total_secs);
139        let message = match request.label.as_deref() {
140            Some(label) => format!("Timer finished after {duration_text}: {label}"),
141            None => format!("Timer finished after {duration_text}."),
142        };
143
144        Ok(json!({
145            "finished": true,
146            "durationSecs": request.total_secs,
147            "durationText": duration_text,
148            "label": request.label,
149            "message": message,
150        }))
151    }
152}
153
154impl ToolProvider for TimerToolAdapter {
155    fn advertised_tools(&self) -> Option<Vec<Tool>> {
156        self.selection.build_live_tool().map(|tool| vec![tool])
157    }
158
159    fn descriptors(&self) -> Vec<ToolDescriptor> {
160        TimerToolId::ALL
161            .into_iter()
162            .map(|tool| ToolDescriptor {
163                key: tool.key().to_string(),
164                summary: tool.summary().to_string(),
165                kind: ToolKind::Local,
166            })
167            .collect()
168    }
169
170    fn specifications(&self) -> Vec<ToolSpecification> {
171        TimerToolId::ALL
172            .into_iter()
173            .filter(|tool| self.selection.is_enabled(*tool))
174            .map(|tool| {
175                ToolSpecification::new(tool.function_name(), ToolCapability::BACKGROUND_CONTINUABLE)
176            })
177            .collect()
178    }
179}
180
181impl ToolExecutor for TimerToolAdapter {
182    fn execute<'a>(
183        &'a self,
184        call: FunctionCallRequest,
185    ) -> BoxFuture<'a, Result<FunctionResponse, ToolExecutionError>> {
186        Box::pin(async move { Ok(self.execute_call(call).await) })
187    }
188}
189
190#[derive(Debug, Clone, PartialEq, Eq)]
191struct TimerRequest {
192    total_secs: u64,
193    label: Option<String>,
194}
195
196impl TimerRequest {
197    fn from_args(args: Option<&Map<String, Value>>) -> Result<Self, String> {
198        let args = args.ok_or("tool arguments must be a JSON object")?;
199        let days = resolve_optional_u64(args, "days")?.unwrap_or(0);
200        let hours = resolve_optional_u64(args, "hours")?.unwrap_or(0);
201        let minutes = resolve_optional_u64(args, "minutes")?.unwrap_or(0);
202        let seconds = resolve_optional_u64(args, "seconds")?.unwrap_or(0);
203        let label = resolve_optional_label(args, "label")?;
204
205        let total_secs = checked_component_to_secs(days, 24 * 60 * 60, "days")?
206            .checked_add(checked_component_to_secs(hours, 60 * 60, "hours")?)
207            .and_then(|value| {
208                value.checked_add(checked_component_to_secs(minutes, 60, "minutes").ok()?)
209            })
210            .and_then(|value| value.checked_add(seconds))
211            .ok_or("timer duration is too large")?;
212
213        if total_secs == 0 {
214            return Err(
215                "set_timer requires at least one positive duration field (`days`, `hours`, `minutes`, or `seconds`)"
216                    .into(),
217            );
218        }
219        if total_secs > MAX_TIMER_DURATION_SECS {
220            return Err(format!(
221                "timer duration may not exceed {MAX_TIMER_DURATION_SECS} seconds"
222            ));
223        }
224
225        Ok(Self { total_secs, label })
226    }
227
228    fn duration(&self) -> Duration {
229        Duration::from_secs(self.total_secs)
230    }
231}
232
233fn checked_component_to_secs(value: u64, multiplier: u64, name: &str) -> Result<u64, String> {
234    value
235        .checked_mul(multiplier)
236        .ok_or_else(|| format!("`{name}` is too large"))
237}
238
239fn resolve_optional_u64(args: &Map<String, Value>, key: &str) -> Result<Option<u64>, String> {
240    match args.get(key) {
241        None => Ok(None),
242        Some(Value::Number(value)) => value
243            .as_u64()
244            .map(Some)
245            .ok_or_else(|| format!("`{key}` must be a non-negative integer")),
246        Some(_) => Err(format!("`{key}` must be an integer")),
247    }
248}
249
250fn resolve_optional_label(args: &Map<String, Value>, key: &str) -> Result<Option<String>, String> {
251    match args.get(key) {
252        None | Some(Value::Null) => Ok(None),
253        Some(Value::String(value)) => {
254            let trimmed = value.trim();
255            if trimmed.is_empty() {
256                return Ok(None);
257            }
258            if trimmed.chars().count() > MAX_LABEL_CHARS {
259                return Err(format!(
260                    "`{key}` may contain at most {MAX_LABEL_CHARS} characters"
261                ));
262            }
263            Ok(Some(trimmed.to_string()))
264        }
265        Some(_) => Err(format!("`{key}` must be a string")),
266    }
267}
268
269fn function_response(call: FunctionCallRequest, result: Result<Value, String>) -> FunctionResponse {
270    match result {
271        Ok(response) => FunctionResponse {
272            id: call.id,
273            name: call.name,
274            response: json!({
275                "ok": true,
276                "result": response,
277            }),
278        },
279        Err(message) => FunctionResponse {
280            id: call.id,
281            name: call.name,
282            response: json!({
283                "ok": false,
284                "error": {
285                    "message": message,
286                },
287            }),
288        },
289    }
290}
291
292fn set_timer_declaration() -> FunctionDeclaration {
293    FunctionDeclaration {
294        name: TimerToolId::SetTimer.function_name().into(),
295        description: "Wait for a fixed duration and then report completion. Use this for reminders, alerts, or to deliberately exercise the harness background-task and passive-notification path when the requested wait exceeds the inline budget.".into(),
296        parameters: json!({
297            "type": "object",
298            "properties": {
299                "days": {
300                    "type": "integer",
301                    "minimum": 0,
302                    "description": "Whole days to wait."
303                },
304                "hours": {
305                    "type": "integer",
306                    "minimum": 0,
307                    "description": "Whole hours to wait."
308                },
309                "minutes": {
310                    "type": "integer",
311                    "minimum": 0,
312                    "description": "Whole minutes to wait."
313                },
314                "seconds": {
315                    "type": "integer",
316                    "minimum": 0,
317                    "description": "Whole seconds to wait."
318                },
319                "label": {
320                    "type": "string",
321                    "description": "Optional short reminder text to include when the timer finishes."
322                }
323            }
324        }),
325        scheduling: None,
326        behavior: None,
327    }
328}
329
330fn format_duration(total_secs: u64) -> String {
331    let days = total_secs / 86_400;
332    let hours = (total_secs % 86_400) / 3_600;
333    let minutes = (total_secs % 3_600) / 60;
334    let seconds = total_secs % 60;
335
336    let mut parts = Vec::new();
337    if days > 0 {
338        parts.push(format_unit(days, "day"));
339    }
340    if hours > 0 {
341        parts.push(format_unit(hours, "hour"));
342    }
343    if minutes > 0 {
344        parts.push(format_unit(minutes, "minute"));
345    }
346    if seconds > 0 {
347        parts.push(format_unit(seconds, "second"));
348    }
349    if parts.is_empty() {
350        "0 seconds".into()
351    } else {
352        parts.join(" ")
353    }
354}
355
356fn format_unit(value: u64, singular: &str) -> String {
357    if value == 1 {
358        format!("1 {singular}")
359    } else {
360        format!("{value} {singular}s")
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn timer_selection_builds_declared_functions() {
370        let tool = TimerToolSelection { timer: true }
371            .build_live_tool()
372            .expect("timer tool");
373        let Tool::FunctionDeclarations(functions) = tool else {
374            panic!("expected function declarations");
375        };
376        assert_eq!(functions.len(), 1);
377        assert_eq!(functions[0].name, "set_timer");
378    }
379
380    #[test]
381    fn timer_request_requires_positive_duration() {
382        let error = TimerRequest::from_args(Some(
383            &serde_json::from_value::<Map<String, Value>>(json!({})).expect("args"),
384        ))
385        .expect_err("missing duration should fail");
386        assert!(error.contains("requires at least one positive duration field"));
387    }
388
389    #[tokio::test]
390    async fn timer_tool_returns_completion_message_after_wait() {
391        let adapter = TimerToolAdapter::new(TimerToolSelection { timer: true });
392        let response = adapter
393            .execute_call(FunctionCallRequest {
394                id: "call_1".into(),
395                name: "set_timer".into(),
396                args: json!({
397                    "seconds": 1,
398                    "label": "stretch"
399                }),
400            })
401            .await;
402        assert_eq!(response.response["ok"], true);
403        assert_eq!(response.response["result"]["durationSecs"], 1);
404        assert_eq!(
405            response.response["result"]["message"],
406            "Timer finished after 1 second: stretch"
407        );
408    }
409
410    #[test]
411    fn timer_specification_is_background_continuable() {
412        let adapter = TimerToolAdapter::new(TimerToolSelection { timer: true });
413        let specs = adapter.specifications();
414        assert_eq!(specs.len(), 1);
415        assert!(specs[0].capability.can_continue_async_after_timeout);
416    }
417
418    #[test]
419    fn format_duration_normalizes_units() {
420        assert_eq!(format_duration(90), "1 minute 30 seconds");
421        assert_eq!(format_duration(3_661), "1 hour 1 minute 1 second");
422    }
423}