Skip to main content

car_server_core/assistant/
device_tools.rs

1//! Linked-device tools for Parslee Core.
2//!
3//! This is intentionally a read-only capability surface. It lets the flagship
4//! assistant see which user devices are linked and what they advertise, without
5//! becoming a generic phone sensor or remote-exec API.
6
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use car_engine::ToolExecutor;
11use serde_json::{json, Value};
12
13#[async_trait]
14pub trait DeviceProvider: Send + Sync {
15    async fn devices(&self) -> Result<Value, String>;
16    async fn notify_device(
17        &self,
18        device_id: Option<String>,
19        title: String,
20        body: String,
21    ) -> Result<Value, String>;
22}
23
24pub struct DeviceTools {
25    provider: Arc<dyn DeviceProvider>,
26}
27
28impl DeviceTools {
29    pub fn new(provider: Arc<dyn DeviceProvider>) -> Self {
30        Self { provider }
31    }
32
33    pub fn tool_defs() -> Vec<Value> {
34        vec![
35            json!({
36                "name": "linked_devices",
37                "description": "List the user's linked CAR host devices and their advertised consumer capabilities, such as iPhone chat, approvals, notifications, and push-to-talk. Read-only; does not access contacts, location, photos, microphone, or other private phone data.",
38                "parameters": {
39                    "type": "object",
40                    "properties": {},
41                    "additionalProperties": false
42                }
43            }),
44            json!({
45                "name": "notify_linked_device",
46                "description": "Send a short CAR notification to a linked consumer device that advertises notifications.deliver, such as the user's iPhone. This only delivers title/body notification text through the CAR host event channel; it does not access contacts, location, photos, microphone, files, or arbitrary phone actions.",
47                "tier": "full_access",
48                "parameters": {
49                    "type": "object",
50                    "properties": {
51                        "device_id": {
52                            "type": "string",
53                            "description": "Optional linked device id from linked_devices. If omitted, CAR chooses the first online device that can deliver notifications."
54                        },
55                        "title": {
56                            "type": "string",
57                            "description": "Short notification title."
58                        },
59                        "body": {
60                            "type": "string",
61                            "description": "Notification body shown on the linked device."
62                        }
63                    },
64                    "required": ["title", "body"],
65                    "additionalProperties": false
66                }
67            }),
68        ]
69    }
70}
71
72#[async_trait]
73impl ToolExecutor for DeviceTools {
74    async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
75        match tool {
76            "linked_devices" => self.provider.devices().await,
77            "notify_linked_device" => self.notify_linked_device(params).await,
78            _ => Err(format!("unknown tool: {tool}")),
79        }
80    }
81}
82
83impl DeviceTools {
84    async fn notify_linked_device(&self, params: &Value) -> Result<Value, String> {
85        let title = required_string(params, "title")?;
86        let body = required_string(params, "body")?;
87        let device_id = params
88            .get("device_id")
89            .and_then(Value::as_str)
90            .map(str::to_string);
91        let devices = self.provider.devices().await?;
92        let chosen = choose_notification_device(&devices, device_id.as_deref())?;
93        let chosen_id = chosen
94            .get("id")
95            .and_then(Value::as_str)
96            .map(str::to_string)
97            .or(device_id);
98        self.provider
99            .notify_device(chosen_id.clone(), title.clone(), body.clone())
100            .await?;
101        Ok(json!({
102            "delivered": true,
103            "device_id": chosen_id,
104            "title": title,
105            "body": body
106        }))
107    }
108}
109
110fn required_string(params: &Value, key: &str) -> Result<String, String> {
111    let value = params
112        .get(key)
113        .and_then(Value::as_str)
114        .map(str::trim)
115        .filter(|s| !s.is_empty())
116        .ok_or_else(|| format!("{key} is required"))?;
117    Ok(value.to_string())
118}
119
120fn choose_notification_device<'a>(
121    devices: &'a Value,
122    requested_id: Option<&str>,
123) -> Result<&'a Value, String> {
124    let devices = devices
125        .as_array()
126        .ok_or_else(|| "linked_devices returned a non-array response".to_string())?;
127    if let Some(id) = requested_id {
128        let device = devices
129            .iter()
130            .find(|device| device.get("id").and_then(Value::as_str) == Some(id))
131            .ok_or_else(|| format!("linked device '{id}' was not found"))?;
132        if !device_can_notify(device) {
133            return Err(format!(
134                "linked device '{id}' does not advertise notifications.deliver"
135            ));
136        }
137        return Ok(device);
138    }
139    devices
140        .iter()
141        .find(|device| device_can_notify(device))
142        .ok_or_else(|| "no linked device advertises notifications.deliver".to_string())
143}
144
145fn device_can_notify(device: &Value) -> bool {
146    let is_online = device
147        .get("status")
148        .and_then(Value::as_str)
149        .map(|status| status == "online")
150        .unwrap_or(true);
151    let can_deliver = device
152        .get("capabilities")
153        .and_then(Value::as_array)
154        .map(|caps| {
155            caps.iter()
156                .any(|cap| cap.as_str() == Some("notifications.deliver"))
157        })
158        .unwrap_or(false);
159    is_online && can_deliver
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use tokio::sync::Mutex;
166
167    struct StaticProvider {
168        devices: Value,
169        notifications: Mutex<Vec<Value>>,
170    }
171
172    impl StaticProvider {
173        fn new(devices: Value) -> Self {
174            Self {
175                devices,
176                notifications: Mutex::new(Vec::new()),
177            }
178        }
179    }
180
181    #[async_trait]
182    impl DeviceProvider for StaticProvider {
183        async fn devices(&self) -> Result<Value, String> {
184            Ok(self.devices.clone())
185        }
186
187        async fn notify_device(
188            &self,
189            device_id: Option<String>,
190            title: String,
191            body: String,
192        ) -> Result<Value, String> {
193            let event = json!({
194                "device_id": device_id,
195                "title": title,
196                "body": body
197            });
198            self.notifications.lock().await.push(event.clone());
199            Ok(event)
200        }
201    }
202
203    #[tokio::test]
204    async fn linked_devices_returns_provider_snapshot() {
205        let tools = DeviceTools::new(Arc::new(StaticProvider::new(json!([
206            {"name": "iPhone", "platform": "ios", "status": "online"}
207        ]))));
208        let out = tools.execute("linked_devices", &json!({})).await.unwrap();
209        assert_eq!(out[0]["platform"], "ios");
210    }
211
212    #[test]
213    fn linked_devices_def_is_read_only_phone_context() {
214        let defs = DeviceTools::tool_defs();
215        let desc = defs[0]["description"].as_str().unwrap();
216        assert!(desc.contains("Read-only"));
217        assert!(desc.contains("does not access contacts"));
218    }
219
220    #[tokio::test]
221    async fn notify_linked_device_requires_notification_capability() {
222        let tools = DeviceTools::new(Arc::new(StaticProvider::new(json!([
223            {
224                "id": "phone-1",
225                "name": "Mia's iPhone",
226                "platform": "ios",
227                "status": "online",
228                "capabilities": ["notifications.deliver"]
229            }
230        ]))));
231        let out = tools
232            .execute(
233                "notify_linked_device",
234                &json!({"title": "CAR", "body": "Done"}),
235            )
236            .await
237            .unwrap();
238        assert_eq!(out["delivered"], true);
239        assert_eq!(out["device_id"], "phone-1");
240    }
241
242    #[tokio::test]
243    async fn notify_linked_device_rejects_devices_without_notification_capability() {
244        let tools = DeviceTools::new(Arc::new(StaticProvider::new(json!([
245            {
246                "id": "phone-1",
247                "name": "Mia's iPhone",
248                "platform": "ios",
249                "status": "online",
250                "capabilities": ["assistant.chat"]
251            }
252        ]))));
253        let err = tools
254            .execute(
255                "notify_linked_device",
256                &json!({"device_id": "phone-1", "title": "CAR", "body": "Done"}),
257            )
258            .await
259            .unwrap_err();
260        assert!(err.contains("does not advertise notifications.deliver"));
261    }
262
263    #[test]
264    fn notify_linked_device_def_describes_limited_phone_access_and_tier() {
265        let defs = DeviceTools::tool_defs();
266        let notify = defs
267            .iter()
268            .find(|def| def["name"].as_str() == Some("notify_linked_device"))
269            .unwrap();
270        assert_eq!(notify["tier"], "full_access");
271        let desc = notify["description"].as_str().unwrap();
272        assert!(desc.contains("title/body notification text"));
273        assert!(desc.contains("does not access contacts"));
274    }
275}