car_server_core/assistant/
device_tools.rs1use 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}