use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use async_trait::async_trait;
use car_browser::perception::vision::VisionPerceptionPipeline;
use car_browser::{BrowserBackend, BrowserToolExecutor, ChromiumBackend, RecordingHandle};
use car_engine::ToolExecutor;
use serde_json::{json, Value};
use tokio::sync::Mutex;
use crate::coder::policy::stays_under;
const BROWSER_TOOL_TIER: &str = "full_access";
const OUTPUT_FPS: u32 = 24;
const VIEWPORT_W: u32 = 1920;
const VIEWPORT_H: u32 = 1080;
pub struct BrowserTools {
root: PathBuf,
inner: Arc<Mutex<Option<Session>>>,
recording: Arc<Mutex<Option<RecordingHandle>>>,
}
struct Session {
backend: Arc<ChromiumBackend>,
exec: BrowserToolExecutor,
}
impl BrowserTools {
pub fn new(root: PathBuf) -> Self {
Self {
root,
inner: Arc::new(Mutex::new(None)),
recording: Arc::new(Mutex::new(None)),
}
}
pub fn tool_defs(&self) -> Vec<Value> {
browser_tool_defs()
}
async fn session(&self) -> Result<Arc<ChromiumBackend>, String> {
let mut guard = self.inner.lock().await;
if guard.is_none() {
if std::env::var_os("CAR_BROWSER_PROFILE_DIR").is_none() {
if let Some(dir) = default_browser_profile_dir() {
let _ = std::fs::create_dir_all(&dir);
unsafe { std::env::set_var("CAR_BROWSER_PROFILE_DIR", &dir) };
}
}
let backend =
ChromiumBackend::launch_with_options(car_browser::chromium::LaunchOptions {
width: VIEWPORT_W,
height: VIEWPORT_H,
headless: std::env::var("CAR_BROWSER_HEADLESS")
.map(|v| v != "0" && !v.is_empty())
.unwrap_or(false),
extra_args: Vec::new(),
})
.await
.map_err(|e| format!("launch browser: {e}"))?;
let backend = Arc::new(backend);
*guard = Some(Session {
backend: Arc::clone(&backend),
exec: BrowserToolExecutor::new(
Arc::clone(&backend) as Arc<dyn car_browser::BrowserBackend>,
Arc::new(VisionPerceptionPipeline::new()),
),
});
}
Ok(Arc::clone(&guard.as_ref().expect("just set").backend))
}
async fn run_await_answer(&self, params: &Value) -> Result<Value, String> {
let backend = self.session().await?;
let page = backend
.page_handle()
.await
.map_err(|e| format!("no page: {e}"))?;
let timeout = params
.get("timeout_seconds")
.and_then(Value::as_u64)
.unwrap_or(150)
.clamp(3, 600);
let poll = Duration::from_millis(1000);
let steady_hold = Duration::from_secs(6);
let noise_band: i64 = 8;
let measure = || async {
page.evaluate("document.body ? document.body.innerText.length : 0")
.await
.ok()
.and_then(|v| v.into_value::<i64>().ok())
.unwrap_or(0)
};
let started = Instant::now();
let baseline = measure().await;
let mut last = baseline;
let mut steady_since: Option<Instant> = None;
let mut peak = baseline;
while started.elapsed() < Duration::from_secs(timeout) {
tokio::time::sleep(poll).await;
let now = measure().await;
peak = peak.max(now);
if (now - last).abs() > noise_band {
steady_since = None;
last = now;
} else {
if peak - baseline > noise_band {
let since = *steady_since.get_or_insert_with(Instant::now);
if since.elapsed() >= steady_hold {
return Ok(json!({
"settled": true,
"content_length": now,
"waited_seconds": started.elapsed().as_secs(),
}));
}
}
}
}
Ok(json!({
"settled": false,
"content_length": last,
"grew": peak - baseline > noise_band,
"note": "timed out before the page held steady. If `grew` is true the answer was \
still streaming at timeout — raise timeout_seconds. If false, the action \
produced no visible change (the submit may not have registered).",
}))
}
async fn run_await_signin(&self, params: &Value) -> Result<Value, String> {
let backend = self.session().await?;
if let Some(url) = params.get("url").and_then(|v| v.as_str()) {
if !url.trim().is_empty() {
backend
.navigate(url)
.await
.map_err(|e| format!("navigate to {url}: {e}"))?;
}
}
let expect = params
.get("success_url_contains")
.and_then(|v| v.as_str())
.map(str::to_string);
let timeout = params
.get("timeout_seconds")
.and_then(Value::as_u64)
.unwrap_or(300)
.clamp(10, 1800);
let started = Instant::now();
let mut last = String::new();
while started.elapsed() < Duration::from_secs(timeout) {
tokio::time::sleep(Duration::from_secs(2)).await;
let current = backend.get_current_url().unwrap_or_default();
last = current.clone();
let done = match &expect {
Some(needle) => current.contains(needle.as_str()),
None => {
!current.is_empty()
&& !["login", "signin", "sign-in", "auth", "oauth", "sso"]
.iter()
.any(|p| current.to_ascii_lowercase().contains(p))
}
};
if done {
return Ok(json!({
"signed_in": true,
"url": current,
"note": "Sign-in detected. The session persists in the browser profile, so \
later runs won't need this again.",
}));
}
}
Err(format!(
"timed out after {timeout}s waiting for sign-in — the browser is still at {last}. \
Ask the user to complete the login in the open browser window, then retry."
))
}
async fn run_record_start(&self, params: &Value) -> Result<Value, String> {
let mut rec = self.recording.lock().await;
if rec.is_some() {
return Err(
"a recording is already in progress — call browser_record_stop first".to_string(),
);
}
let backend = self.session().await?;
let page = backend
.page_handle()
.await
.map_err(|e| format!("no page to record: {e}"))?;
let quality = params
.get("quality")
.and_then(Value::as_i64)
.unwrap_or(80)
.clamp(1, 100);
let dir = self.root.join(format!(
".car-recording-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
));
let handle = car_browser::recorder::start(&page, &dir, quality, 1, VIEWPORT_W, VIEWPORT_H)
.await
.map_err(|e| format!("start recording: {e}"))?;
*rec = Some(handle);
Ok(json!({
"recording": true,
"note": "Recording. Drive the app with the browse_* tools, then call \
browser_record_stop. Frames are only captured when the page \
CHANGES, so a static page produces nothing — make sure \
something actually happens on screen.",
}))
}
async fn run_record_stop(&self, params: &Value) -> Result<Value, String> {
let handle = self
.recording
.lock()
.await
.take()
.ok_or("no recording in progress — call browser_record_start first")?;
let rel = params
.get("output_path")
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.unwrap_or("assets/recording.mp4")
.to_string();
if !stays_under(&self.root, &rel) {
return Err(format!("output_path '{rel}' escapes the working directory"));
}
let out = self.root.join(&rel);
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("create output dir: {e}"))?;
}
let recording = handle
.stop()
.await
.map_err(|e| format!("stop recording: {e}"))?;
let status = std::process::Command::new("ffmpeg")
.args([
"-nostdin", "-v", "error", "-f", "concat", "-safe", "0", "-i",
])
.arg(&recording.manifest)
.args([
"-vsync",
"cfr",
"-r",
&OUTPUT_FPS.to_string(),
"-pix_fmt",
"yuv420p",
"-c:v",
"libx264",
"-movflags",
"+faststart",
])
.arg(&out)
.arg("-y")
.status()
.map_err(|e| format!("run ffmpeg (is it installed?): {e}"))?;
if !status.success() {
return Err(format!("ffmpeg failed encoding the recording ({status})"));
}
let _ = std::fs::remove_dir_all(&recording.dir);
let bytes = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);
Ok(json!({
"video_path": rel,
"media_type": "video/mp4",
"bytes": bytes,
"frames": recording.frame_count,
"duration_seconds": recording.duration_seconds,
"note": format!(
"Wrote a {:.1}s screen recording ({} frames) to {rel}.",
recording.duration_seconds, recording.frame_count
),
}))
}
}
fn default_browser_profile_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(|h| PathBuf::from(h).join(".car").join("browser-profile"))
}
fn browser_tool_defs() -> Vec<Value> {
let mut defs: Vec<Value> = BrowserToolExecutor::tool_schemas()
.into_iter()
.map(|s| {
json!({
"name": s.name,
"description": s.description,
"parameters": s.parameters,
"mutating": !s.idempotent,
"tier": BROWSER_TOOL_TIER,
})
})
.collect();
defs.push(json!({
"name": "browser_await_answer",
"description": "After you submit a question or trigger an action in a web app, call this to \
WAIT until the response has finished rendering, before you screenshot or stop a \
recording. It polls the page and returns once the content stops changing. Use it every \
time between submitting and observing/recording an answer — browse_observe does NOT \
wait, so without this you capture the page mid-load (a blank or still-thinking state) \
instead of the actual answer.",
"parameters": {
"type": "object",
"properties": {
"timeout_seconds": {
"type": "integer",
"description": "Max seconds to wait for the page to settle (default 45)."
}
},
"required": []
},
"mutating": false,
"tier": BROWSER_TOOL_TIER
}));
defs.push(json!({
"name": "browser_await_signin",
"description": "Ask the USER to sign in, in the browser window that is already on screen, \
and wait until they have. Use this the moment a site needs authentication — you cannot \
and must not type someone's credentials, but the browser is headed, so they can \
complete any flow (SSO, MFA, a device prompt) themselves. TELL THE USER what to sign \
into before calling this; it blocks while they do it. The session persists in the \
browser profile, so this is a one-time cost per site rather than per run.",
"parameters": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "Optional page to navigate to first, e.g. the app's home or login URL."
},
"success_url_contains": {
"type": "string",
"description": "Optional substring identifying a signed-in URL. Omit to accept any URL that no longer looks like a login/SSO page."
},
"timeout_seconds": {
"type": "integer",
"description": "How long to wait for the user (default 300, max 1800)."
}
},
"required": []
},
"mutating": true,
"tier": BROWSER_TOOL_TIER
}));
defs.push(json!({
"name": "browser_record_start",
"description": "Start RECORDING the browser session to video. Pair it with the browse_* \
tools: start recording, drive the app (navigate, type a real question, wait for the \
answer), then call browser_record_stop to get an MP4. Use it whenever the ASK is a \
product demo, an onboarding or training clip, a bug repro, or release notes — anything \
where showing the app BEING USED beats a screenshot of its final state. Frames are \
captured only when the page actually CHANGES, so make sure something happens on \
screen; a static page records nothing.",
"parameters": {
"type": "object",
"properties": {
"quality": {"type": "integer", "description": "JPEG quality 1-100 (default 80)."}
},
"required": []
},
"mutating": true,
"tier": BROWSER_TOOL_TIER
}));
defs.push(json!({
"name": "browser_record_stop",
"description": "Stop the recording started by browser_record_start and write an MP4 under \
the working directory. Returns the path plus the real duration. Requires ffmpeg.",
"parameters": {
"type": "object",
"properties": {
"output_path": {
"type": "string",
"description": "Where to write the MP4, relative to the working directory (default assets/recording.mp4)."
}
},
"required": []
},
"mutating": true,
"tier": BROWSER_TOOL_TIER
}));
defs
}
#[async_trait]
impl ToolExecutor for BrowserTools {
async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
match tool {
"browser_await_signin" => self.run_await_signin(params).await,
"browser_await_answer" => self.run_await_answer(params).await,
"browser_record_start" => self.run_record_start(params).await,
"browser_record_stop" => self.run_record_stop(params).await,
t if t.starts_with("browse_") => {
self.session().await?;
let guard = self.inner.lock().await;
let session = guard.as_ref().ok_or("browser session unavailable")?;
session.exec.execute(tool, params).await
}
_ => Err(format!("unknown tool: {tool}")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_browser_tool_is_full_access() {
for def in browser_tool_defs() {
assert_eq!(
def["tier"], BROWSER_TOOL_TIER,
"{} must be full_access",
def["name"]
);
}
}
#[test]
fn record_tools_are_advertised_alongside_the_browse_tools() {
let names: Vec<String> = browser_tool_defs()
.iter()
.filter_map(|d| d["name"].as_str().map(str::to_string))
.collect();
assert!(names.iter().any(|n| n == "browse_navigate"));
assert!(names.iter().any(|n| n == "browser_record_start"));
assert!(names.iter().any(|n| n == "browser_record_stop"));
}
#[tokio::test]
async fn record_stop_without_start_is_an_error_not_a_panic() {
let tools = BrowserTools::new(std::env::temp_dir());
let err = tools
.execute("browser_record_stop", &json!({}))
.await
.unwrap_err();
assert!(err.contains("no recording in progress"), "got: {err}");
}
}