Replace C browser client with headless Chrome

This commit is contained in:
Georg Bauer
2026-07-28 22:17:45 +02:00
parent 9683d5d10b
commit 9d1c403ae7
7 changed files with 769 additions and 1590 deletions

View File

@@ -1,12 +1,14 @@
mod web;
use self::web::Browser;
use crate::model::ModelChoice;
use serde_json::{Map, Value};
use std::collections::HashMap;
use std::ffi::{CStr, CString, c_char, c_int, c_void};
use std::fs::{self, File};
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, AtomicPtr, Ordering};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender, TryRecvError};
use std::sync::{Arc, Mutex};
use std::thread;
@@ -30,158 +32,8 @@ const SHELL_ENV_ALLOWLIST: &[&str] = &[
];
pub(crate) const COMPACTION_OBSERVATION_PREFIX: &str = "Bash job update after context compaction.";
#[repr(C)]
struct WebConfig {
profile_dir: *const c_char,
port: c_int,
confirm: Option<unsafe extern "C" fn(*mut c_void, *const c_char, *mut c_char, usize) -> c_int>,
confirm_data: *mut c_void,
log: Option<unsafe extern "C" fn(*mut c_void, *const c_char)>,
log_data: *mut c_void,
cancel: Option<unsafe extern "C" fn(*mut c_void) -> bool>,
cancel_data: *mut c_void,
}
unsafe extern "C" {
fn ds4_web_create(config: *const WebConfig) -> *mut c_void;
fn ds4_web_free(web: *mut c_void);
fn ds4_web_google_search(
web: *mut c_void,
query: *const c_char,
error: *mut c_char,
error_len: usize,
) -> *mut c_char;
fn ds4_web_visit_page(
web: *mut c_void,
url: *const c_char,
error: *mut c_char,
error_len: usize,
) -> *mut c_char;
fn free(pointer: *mut c_void);
}
struct WebCallbacks {
cancel: AtomicPtr<AtomicBool>,
}
struct Browser {
web: *mut c_void,
callbacks: Box<WebCallbacks>,
}
// The C browser is used by one agent-tools worker at a time under Tools' mutex.
unsafe impl Send for Browser {}
impl Browser {
fn new() -> Result<Self, String> {
let mut callbacks = Box::new(WebCallbacks {
cancel: AtomicPtr::new(std::ptr::null_mut()),
});
let profile = CString::new(
crate::app::browser_profile_path()
.to_string_lossy()
.as_bytes(),
)
.map_err(|_| "The browser profile path contains a NUL byte.".to_owned())?;
let data = (&mut *callbacks) as *mut WebCallbacks as *mut c_void;
let config = WebConfig {
profile_dir: profile.as_ptr(),
port: 9333,
confirm: Some(web_confirm),
confirm_data: data,
log: None,
log_data: std::ptr::null_mut(),
cancel: Some(web_cancel),
cancel_data: data,
};
let web = unsafe { ds4_web_create(&config) };
if web.is_null() {
Err("Could not initialize browser tools.".into())
} else {
Ok(Self { web, callbacks })
}
}
fn google_search(&self, query: &str, cancel: &AtomicBool) -> Result<String, String> {
self.call(query, cancel, ds4_web_google_search)
}
fn visit_page(&self, url: &str, cancel: &AtomicBool) -> Result<String, String> {
self.call(url, cancel, ds4_web_visit_page)
}
fn call(
&self,
value: &str,
cancel: &AtomicBool,
operation: unsafe extern "C" fn(
*mut c_void,
*const c_char,
*mut c_char,
usize,
) -> *mut c_char,
) -> Result<String, String> {
let value = CString::new(value).map_err(|_| "web input contains a NUL byte".to_owned())?;
let mut error = [0 as c_char; 256];
self.callbacks.cancel.store(
cancel as *const AtomicBool as *mut AtomicBool,
Ordering::Relaxed,
);
let result =
unsafe { operation(self.web, value.as_ptr(), error.as_mut_ptr(), error.len()) };
self.callbacks
.cancel
.store(std::ptr::null_mut(), Ordering::Relaxed);
if result.is_null() {
let error = unsafe { CStr::from_ptr(error.as_ptr()) }
.to_string_lossy()
.into_owned();
Err(if error.is_empty() {
"browser tool failed".into()
} else {
error
})
} else {
let output = unsafe { CStr::from_ptr(result) }
.to_string_lossy()
.into_owned();
unsafe { free(result.cast()) };
Ok(output)
}
}
}
impl Drop for Browser {
fn drop(&mut self) {
unsafe { ds4_web_free(self.web) };
}
}
unsafe extern "C" fn web_confirm(
_data: *mut c_void,
message: *const c_char,
error: *mut c_char,
error_len: usize,
) -> c_int {
let _ = message;
let _ = error;
let _ = error_len;
// Approval is obtained by the shared tool worker before entering C. Keeping
// this callback lets the reference browser retain its launch guard.
1
}
unsafe extern "C" fn web_cancel(data: *mut c_void) -> bool {
if data.is_null() {
return false;
}
let callbacks = unsafe { &*(data.cast::<WebCallbacks>()) };
let cancel = callbacks.cancel.load(Ordering::Relaxed);
!cancel.is_null() && unsafe { (*cancel).load(Ordering::Relaxed) }
}
const TOOL_SCHEMAS: &str = r#"{"type":"function","function":{"name":"google_search","description":"Search Google in a visible browser and return compact Markdown links.","parameters":{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}}}
{"type":"function","function":{"name":"visit_page","description":"Open a URL in a visible browser and return rendered page text.","parameters":{"type":"object","properties":{"url":{"type":"string"}},"required":["url"]}}}
const TOOL_SCHEMAS: &str = r#"{"type":"function","function":{"name":"google_search","description":"Search Google in a browser and return compact Markdown links.","parameters":{"type":"object","properties":{"query":{"type":"string"}},"required":["query"]}}}
{"type":"function","function":{"name":"visit_page","description":"Open a URL in a browser and return rendered page text.","parameters":{"type":"object","properties":{"url":{"type":"string"}},"required":["url"]}}}
{"type":"function","function":{"name":"bash","description":"Run a shell command.","parameters":{"type":"object","properties":{"command":{"type":"string"},"timeout_sec":{"type":"number"},"refresh_sec":{"type":"number"}},"required":["command"]}}}
{"type":"function","function":{"name":"bash_status","description":"Report current status and new output for a bash job.","parameters":{"type":"object","properties":{"job":{"type":"number"},"pid":{"type":"number"},"refresh_sec":{"type":"number"}},"required":["job"]}}}
{"type":"function","function":{"name":"bash_stop","description":"Terminate a running bash job and report its final output.","parameters":{"type":"object","properties":{"job":{"type":"number"},"pid":{"type":"number"},"refresh_sec":{"type":"number"}},"required":["job"]}}}
@@ -315,7 +167,7 @@ impl Tools {
more_text: None,
jobs: HashMap::new(),
next_job: 1,
browser: Browser::new()?,
browser: Browser::new(),
dev_brain: None,
})
}
@@ -944,8 +796,8 @@ impl Tools {
fn approval(&self, call: &ToolCall) -> Option<ApprovalPrompt> {
if matches!(call.name.as_str(), "google_search" | "visit_page") {
return Some(ApprovalPrompt {
title: "Allow visible browser?".into(),
detail: "Start a visible Chrome browser for this tool call.".into(),
title: "Allow browser?".into(),
detail: "Start headless Chrome for this tool call.".into(),
working_directory: self.root.clone(),
});
}
@@ -1345,7 +1197,7 @@ pub(crate) fn system_prompt(model: ModelChoice, extra: &str, dev_brain: bool) ->
)
} else {
format!(
"You are a coding agent running in a local workspace. Use tools for local file and system work. Avoid printing large file contents or large code blocks as answers; create or edit files with tools, then summarize results briefly.\n\n## Tools\n\nInvoke native DSML tools exactly as:\n<DSMLtool_calls>\n<DSMLinvoke name=\"$TOOL_NAME\">\n<DSMLparameter name=\"$PARAMETER_NAME\" string=\"true|false\">$PARAMETER_VALUE</DSMLparameter>\n</DSMLinvoke>\n</DSMLtool_calls>\n\nTool calls are not allowed inside <think></think>. String parameters use raw text and string=\"true\"; numbers and booleans use JSON text and string=\"false\". Read defaults to a bounded chunk; use more to continue and whole=true only when needed. Use write for new files or whole-file replacement. Use edit with path first and exact unique old text; old may contain one [upto] marker between unique head and tail anchors. For long bash commands pass refresh_sec, then use bash_status or bash_stop. The first web call asks permission to start visible Chrome.\n\n### Available Tool Schemas\n\n{schemas}\n\n# Rules\n- Always use strict DSML syntax.\n- Use read/search to get anchors before editing.\n- Preserve the current system configuration unless explicitly asked otherwise."
"You are a coding agent running in a local workspace. Use tools for local file and system work. Avoid printing large file contents or large code blocks as answers; create or edit files with tools, then summarize results briefly.\n\n## Tools\n\nInvoke native DSML tools exactly as:\n<DSMLtool_calls>\n<DSMLinvoke name=\"$TOOL_NAME\">\n<DSMLparameter name=\"$PARAMETER_NAME\" string=\"true|false\">$PARAMETER_VALUE</DSMLparameter>\n</DSMLinvoke>\n</DSMLtool_calls>\n\nTool calls are not allowed inside <think></think>. String parameters use raw text and string=\"true\"; numbers and booleans use JSON text and string=\"false\". Read defaults to a bounded chunk; use more to continue and whole=true only when needed. Use write for new files or whole-file replacement. Use edit with path first and exact unique old text; old may contain one [upto] marker between unique head and tail anchors. For long bash commands pass refresh_sec, then use bash_status or bash_stop. The first web call asks permission to start headless Chrome.\n\n### Available Tool Schemas\n\n{schemas}\n\n# Rules\n- Always use strict DSML syntax.\n- Use read/search to get anchors before editing.\n- Preserve the current system configuration unless explicitly asked otherwise."
)
};
let tools = if dev_brain {