use std::path::PathBuf;
use crate::event::{Block, RiskLevel};
use crate::tools::{Tool, ToolCtx, ToolResult};
pub struct BrowserAutomation;
#[async_trait::async_trait]
impl Tool for BrowserAutomation {
fn name(&self) -> &str {
"browser"
}
fn description(&self) -> &str {
"Control a Playwright headless browser to navigate pages, take screenshots, and interact with elements"
}
fn schema(&self) -> serde_json::Value {
crate::tools::browser_sandbox::BrowserTool.schema()
}
fn risk(&self) -> RiskLevel {
RiskLevel::Network
}
async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
crate::tools::browser_sandbox::BrowserTool
.call(args, ctx)
.await
}
}
pub struct VisionInput;
#[async_trait::async_trait]
impl Tool for VisionInput {
fn name(&self) -> &str {
"vision"
}
fn description(&self) -> &str {
"Analyze an image file using the model's vision capabilities"
}
fn schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path to image file" },
"question": { "type": "string", "description": "Question about the image" }
},
"required": ["path"]
})
}
fn risk(&self) -> RiskLevel {
RiskLevel::ReadOnly
}
async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
let path = args["path"].as_str().unwrap_or("");
let question = args["question"].as_str().unwrap_or("Describe this image");
let full_path = ctx.workspace_root.join(path);
if !full_path.exists() {
return Ok(ToolResult::error(format!("Image not found: {}", path)));
}
let data = std::fs::read(&full_path)?;
let mime = mime_guess::from_path(&full_path)
.first_or_octet_stream()
.to_string();
let _b64 = base64_encode(&data);
Ok(ToolResult::ok(vec![
Block::Text(format!(
"Image loaded: {} ({} bytes, {})\nQuestion: {}\n\nBase64 data ready for vision model.",
path,
data.len(),
mime,
question
)),
Block::Image { data, mime },
]))
}
}
fn base64_encode(data: &[u8]) -> String {
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut result = String::new();
for chunk in data.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
let triple = (b0 << 16) | (b1 << 8) | b2;
result.push(CHARS[(triple >> 18) as usize & 63] as char);
result.push(CHARS[(triple >> 12) as usize & 63] as char);
if chunk.len() > 1 {
result.push(CHARS[(triple >> 6) as usize & 63] as char);
} else {
result.push('=');
}
if chunk.len() > 2 {
result.push(CHARS[triple as usize & 63] as char);
} else {
result.push('=');
}
}
result
}
pub struct ImageGeneration;
#[async_trait::async_trait]
impl Tool for ImageGeneration {
fn name(&self) -> &str {
"image_gen"
}
fn description(&self) -> &str {
"Alias for image_generate: generate an image from a text prompt and save it into the workspace"
}
fn schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"prompt": { "type": "string", "description": "Image generation prompt" },
"size": { "type": "string", "enum": ["512x512", "1024x1024", "1792x1024"] }
},
"required": ["prompt"]
})
}
fn risk(&self) -> RiskLevel {
RiskLevel::Network
}
async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
crate::tools::media::ImageGen::new().call(args, ctx).await
}
}
pub struct TextToSpeech;
#[async_trait::async_trait]
impl Tool for TextToSpeech {
fn name(&self) -> &str {
"tts"
}
fn description(&self) -> &str {
"Alias for text_to_speech: synthesize speech from text and save an audio file into the workspace"
}
fn schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"text": { "type": "string", "description": "Text to speak" },
"voice": { "type": "string", "description": "Voice name" }
},
"required": ["text"]
})
}
fn risk(&self) -> RiskLevel {
RiskLevel::Network
}
async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
crate::tools::media::Tts::new().call(args, ctx).await
}
}
pub fn spawn_config_watcher(
config_path: PathBuf,
reload_tx: tokio::sync::mpsc::UnboundedSender<crate::config::Config>,
) {
spawn_config_watcher_every(config_path, reload_tx, tokio::time::Duration::from_secs(5));
}
fn spawn_config_watcher_every(
config_path: PathBuf,
reload_tx: tokio::sync::mpsc::UnboundedSender<crate::config::Config>,
interval: tokio::time::Duration,
) {
tokio::spawn(async move {
let mut last_mtime = std::fs::metadata(&config_path)
.and_then(|meta| meta.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
loop {
tokio::time::sleep(interval).await;
if let Ok(meta) = std::fs::metadata(&config_path) {
if let Ok(mtime) = meta.modified() {
if mtime > last_mtime {
last_mtime = mtime;
if let Ok(content) = std::fs::read_to_string(&config_path) {
if let Ok(cfg) = toml::from_str::<crate::config::Config>(&content) {
tracing::info!("Config reloaded from disk");
let _ = reload_tx.send(cfg);
}
}
}
}
}
}
});
}
pub fn ndjson_output(event: &crate::event::Event) -> String {
if !event.is_public() {
return String::new();
}
match serde_json::to_string(event) {
Ok(json) => format!("{}\n", json),
Err(e) => format!("{{\"error\":\"{}\"}}\n", e),
}
}
#[cfg(test)]
mod watcher_tests {
use super::*;
use crate::config::{Config, FsConfigStore};
use crate::event::{Event, RunId};
use std::time::Duration;
#[test]
fn ndjson_output_filters_reasoning_delta() {
let line = ndjson_output(&Event::ReasoningDelta {
run: RunId::new(),
text: " internal chain fragment".into(),
});
assert!(
line.is_empty(),
"ReasoningDelta must stay out of public NDJSON streams"
);
}
#[tokio::test]
async fn config_watcher_reloads_after_file_change() {
use crate::config::ConfigStore;
let tmp = tempfile::tempdir().expect("tmp");
let store = FsConfigStore::new(tmp.path().to_path_buf());
let mut cfg = Config {
theme: "captain".into(),
..Config::default()
};
store.save(&cfg).expect("initial config save");
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
spawn_config_watcher_every(
tmp.path().join("config.toml"),
tx,
Duration::from_millis(25),
);
tokio::time::sleep(Duration::from_millis(60)).await;
assert!(
rx.try_recv().is_err(),
"watcher should not emit a reload for an unchanged file"
);
cfg.theme = "paper".into();
store.save(&cfg).expect("updated config save");
let updated = tokio::time::timeout(Duration::from_secs(2), rx.recv())
.await
.expect("config reload should arrive")
.expect("watcher channel should stay open");
assert_eq!(updated.theme, "paper");
}
}
use std::sync::Arc;
use tokio::sync::Mutex;
pub struct SessionBridge {
pub session_id: String,
pub active_surface: Mutex<String>,
pub pending_approvals: Mutex<Vec<crate::gateway::GatewayResponse>>,
pub engine: Option<Arc<crate::engine::Engine>>,
}
impl SessionBridge {
pub fn new() -> Self {
Self {
session_id: uuid::Uuid::new_v4().to_string(),
active_surface: Mutex::new("cli".into()),
pending_approvals: Mutex::new(Vec::new()),
engine: None,
}
}
pub async fn set_surface(&self, surface: &str) {
*self.active_surface.lock().await = surface.to_string();
}
pub async fn add_approval(&self, response: crate::gateway::GatewayResponse) {
self.pending_approvals.lock().await.push(response);
}
pub async fn drain_approvals(&self) -> Vec<crate::gateway::GatewayResponse> {
self.pending_approvals.lock().await.drain(..).collect()
}
}
#[cfg(test)]
mod tests {
use super::ndjson_output;
#[test]
fn ndjson_output_suppresses_internal_reasoning_delta() {
let event = crate::event::Event::ReasoningDelta {
run: crate::event::RunId("run-1".into()),
text: " hidden provider state".into(),
};
assert_eq!(ndjson_output(&event), "");
assert!(!event.is_public());
}
#[test]
fn ndjson_output_keeps_public_events() {
let event = crate::event::Event::ThinkingDelta {
run: crate::event::RunId("run-1".into()),
text: "visible answer".into(),
};
let line = ndjson_output(&event);
assert!(line.contains("\"ThinkingDelta\""));
assert!(line.ends_with('\n'));
assert!(event.is_public());
}
}