Skip to main content

sparrow/tools/
extras.rs

1use std::path::PathBuf;
2
3// ─── Additional tools for Hermes-parity (§15) ──────────────────────────────────
4
5use crate::event::{Block, RiskLevel};
6use crate::tools::{Tool, ToolCtx, ToolResult};
7
8// ─── Browser automation tool ────────────────────────────────────────────────────
9
10pub struct BrowserAutomation;
11
12#[async_trait::async_trait]
13impl Tool for BrowserAutomation {
14    fn name(&self) -> &str {
15        "browser"
16    }
17    fn description(&self) -> &str {
18        "Control a Playwright headless browser to navigate pages, take screenshots, and interact with elements"
19    }
20    fn schema(&self) -> serde_json::Value {
21        crate::tools::browser_sandbox::BrowserTool.schema()
22    }
23    fn risk(&self) -> RiskLevel {
24        RiskLevel::Network
25    }
26    async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
27        crate::tools::browser_sandbox::BrowserTool
28            .call(args, ctx)
29            .await
30    }
31}
32
33// ─── Vision input tool ──────────────────────────────────────────────────────────
34
35pub struct VisionInput;
36
37#[async_trait::async_trait]
38impl Tool for VisionInput {
39    fn name(&self) -> &str {
40        "vision"
41    }
42    fn description(&self) -> &str {
43        "Analyze an image file using the model's vision capabilities"
44    }
45    fn schema(&self) -> serde_json::Value {
46        serde_json::json!({
47            "type": "object",
48            "properties": {
49                "path": { "type": "string", "description": "Path to image file" },
50                "question": { "type": "string", "description": "Question about the image" }
51            },
52            "required": ["path"]
53        })
54    }
55    fn risk(&self) -> RiskLevel {
56        RiskLevel::ReadOnly
57    }
58    async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
59        let path = args["path"].as_str().unwrap_or("");
60        let question = args["question"].as_str().unwrap_or("Describe this image");
61        let full_path = ctx.workspace_root.join(path);
62
63        if !full_path.exists() {
64            return Ok(ToolResult::error(format!("Image not found: {}", path)));
65        }
66
67        // Read and base64-encode for vision models
68        let data = std::fs::read(&full_path)?;
69        let mime = mime_guess::from_path(&full_path)
70            .first_or_octet_stream()
71            .to_string();
72        let _b64 = base64_encode(&data);
73
74        Ok(ToolResult::ok(vec![
75            Block::Text(format!(
76                "Image loaded: {} ({} bytes, {})\nQuestion: {}\n\nBase64 data ready for vision model.",
77                path,
78                data.len(),
79                mime,
80                question
81            )),
82            Block::Image { data, mime },
83        ]))
84    }
85}
86
87fn base64_encode(data: &[u8]) -> String {
88    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
89    let mut result = String::new();
90    for chunk in data.chunks(3) {
91        let b0 = chunk[0] as u32;
92        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
93        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
94        let triple = (b0 << 16) | (b1 << 8) | b2;
95        result.push(CHARS[(triple >> 18) as usize & 63] as char);
96        result.push(CHARS[(triple >> 12) as usize & 63] as char);
97        if chunk.len() > 1 {
98            result.push(CHARS[(triple >> 6) as usize & 63] as char);
99        } else {
100            result.push('=');
101        }
102        if chunk.len() > 2 {
103            result.push(CHARS[triple as usize & 63] as char);
104        } else {
105            result.push('=');
106        }
107    }
108    result
109}
110
111// ─── Backward-compatible media aliases ──────────────────────────────────────────
112
113pub struct ImageGeneration;
114
115#[async_trait::async_trait]
116impl Tool for ImageGeneration {
117    fn name(&self) -> &str {
118        "image_gen"
119    }
120    fn description(&self) -> &str {
121        "Alias for image_generate: generate an image from a text prompt and save it into the workspace"
122    }
123    fn schema(&self) -> serde_json::Value {
124        serde_json::json!({
125            "type": "object",
126            "properties": {
127                "prompt": { "type": "string", "description": "Image generation prompt" },
128                "size": { "type": "string", "enum": ["512x512", "1024x1024", "1792x1024"] }
129            },
130            "required": ["prompt"]
131        })
132    }
133    fn risk(&self) -> RiskLevel {
134        RiskLevel::Network
135    }
136    async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
137        crate::tools::media::ImageGen::new().call(args, ctx).await
138    }
139}
140
141pub struct TextToSpeech;
142
143#[async_trait::async_trait]
144impl Tool for TextToSpeech {
145    fn name(&self) -> &str {
146        "tts"
147    }
148    fn description(&self) -> &str {
149        "Alias for text_to_speech: synthesize speech from text and save an audio file into the workspace"
150    }
151    fn schema(&self) -> serde_json::Value {
152        serde_json::json!({
153            "type": "object",
154            "properties": {
155                "text": { "type": "string", "description": "Text to speak" },
156                "voice": { "type": "string", "description": "Voice name" }
157            },
158            "required": ["text"]
159        })
160    }
161    fn risk(&self) -> RiskLevel {
162        RiskLevel::Network
163    }
164    async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
165        crate::tools::media::Tts::new().call(args, ctx).await
166    }
167}
168
169// ─── Hot-reload config watcher ──────────────────────────────────────────────────
170
171pub fn spawn_config_watcher(
172    config_path: PathBuf,
173    reload_tx: tokio::sync::mpsc::UnboundedSender<crate::config::Config>,
174) {
175    spawn_config_watcher_every(config_path, reload_tx, tokio::time::Duration::from_secs(5));
176}
177
178fn spawn_config_watcher_every(
179    config_path: PathBuf,
180    reload_tx: tokio::sync::mpsc::UnboundedSender<crate::config::Config>,
181    interval: tokio::time::Duration,
182) {
183    tokio::spawn(async move {
184        let mut last_mtime = std::fs::metadata(&config_path)
185            .and_then(|meta| meta.modified())
186            .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
187        loop {
188            tokio::time::sleep(interval).await;
189            if let Ok(meta) = std::fs::metadata(&config_path) {
190                if let Ok(mtime) = meta.modified() {
191                    if mtime > last_mtime {
192                        last_mtime = mtime;
193                        if let Ok(content) = std::fs::read_to_string(&config_path) {
194                            if let Ok(cfg) = toml::from_str::<crate::config::Config>(&content) {
195                                tracing::info!("Config reloaded from disk");
196                                let _ = reload_tx.send(cfg);
197                            }
198                        }
199                    }
200                }
201            }
202        }
203    });
204}
205
206// ─── NDJSON output helper ───────────────────────────────────────────────────────
207
208pub fn ndjson_output(event: &crate::event::Event) -> String {
209    if !event.is_public() {
210        return String::new();
211    }
212    match serde_json::to_string(event) {
213        Ok(json) => format!("{}\n", json),
214        Err(e) => format!("{{\"error\":\"{}\"}}\n", e),
215    }
216}
217
218#[cfg(test)]
219mod watcher_tests {
220    use super::*;
221    use crate::config::{Config, FsConfigStore};
222    use crate::event::{Event, RunId};
223    use std::time::Duration;
224
225    #[test]
226    fn ndjson_output_filters_reasoning_delta() {
227        let line = ndjson_output(&Event::ReasoningDelta {
228            run: RunId::new(),
229            text: " internal chain fragment".into(),
230        });
231
232        assert!(
233            line.is_empty(),
234            "ReasoningDelta must stay out of public NDJSON streams"
235        );
236    }
237
238    #[tokio::test]
239    async fn config_watcher_reloads_after_file_change() {
240        use crate::config::ConfigStore;
241
242        let tmp = tempfile::tempdir().expect("tmp");
243        let store = FsConfigStore::new(tmp.path().to_path_buf());
244        let mut cfg = Config {
245            theme: "captain".into(),
246            ..Config::default()
247        };
248        store.save(&cfg).expect("initial config save");
249
250        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
251        spawn_config_watcher_every(
252            tmp.path().join("config.toml"),
253            tx,
254            Duration::from_millis(25),
255        );
256
257        tokio::time::sleep(Duration::from_millis(60)).await;
258        assert!(
259            rx.try_recv().is_err(),
260            "watcher should not emit a reload for an unchanged file"
261        );
262
263        cfg.theme = "paper".into();
264        store.save(&cfg).expect("updated config save");
265
266        let updated = tokio::time::timeout(Duration::from_secs(2), rx.recv())
267            .await
268            .expect("config reload should arrive")
269            .expect("watcher channel should stay open");
270
271        assert_eq!(updated.theme, "paper");
272    }
273}
274
275// ─── Multi-surface session bridge ───────────────────────────────────────────────
276
277use std::sync::Arc;
278use tokio::sync::Mutex;
279
280pub struct SessionBridge {
281    pub session_id: String,
282    pub active_surface: Mutex<String>,
283    pub pending_approvals: Mutex<Vec<crate::gateway::GatewayResponse>>,
284    pub engine: Option<Arc<crate::engine::Engine>>,
285}
286
287impl SessionBridge {
288    pub fn new() -> Self {
289        Self {
290            session_id: uuid::Uuid::new_v4().to_string(),
291            active_surface: Mutex::new("cli".into()),
292            pending_approvals: Mutex::new(Vec::new()),
293            engine: None,
294        }
295    }
296
297    pub async fn set_surface(&self, surface: &str) {
298        *self.active_surface.lock().await = surface.to_string();
299    }
300
301    pub async fn add_approval(&self, response: crate::gateway::GatewayResponse) {
302        self.pending_approvals.lock().await.push(response);
303    }
304
305    pub async fn drain_approvals(&self) -> Vec<crate::gateway::GatewayResponse> {
306        self.pending_approvals.lock().await.drain(..).collect()
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::ndjson_output;
313
314    #[test]
315    fn ndjson_output_suppresses_internal_reasoning_delta() {
316        let event = crate::event::Event::ReasoningDelta {
317            run: crate::event::RunId("run-1".into()),
318            text: " hidden provider state".into(),
319        };
320
321        assert_eq!(ndjson_output(&event), "");
322        assert!(!event.is_public());
323    }
324
325    #[test]
326    fn ndjson_output_keeps_public_events() {
327        let event = crate::event::Event::ThinkingDelta {
328            run: crate::event::RunId("run-1".into()),
329            text: "visible answer".into(),
330        };
331
332        let line = ndjson_output(&event);
333        assert!(line.contains("\"ThinkingDelta\""));
334        assert!(line.ends_with('\n'));
335        assert!(event.is_public());
336    }
337}