1use 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}