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::mpsc::{self, Receiver, RecvTimeoutError, Sender, TryRecvError}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024; const SHELL_ENV_ALLOWLIST: &[&str] = &[ "PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "LANG", "LC_ALL", "TERM", "DEVELOPER_DIR", "SDKROOT", "MACOSX_DEPLOYMENT_TARGET", "RUSTUP_TOOLCHAIN", ]; pub(crate) const COMPACTION_OBSERVATION_PREFIX: &str = "Bash job update after context compaction."; #[repr(C)] struct WebConfig { home_dir: *const c_char, port: c_int, confirm: Option c_int>, confirm_data: *mut c_void, log: Option, log_data: *mut c_void, cancel: Option 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, } struct Browser { web: *mut c_void, callbacks: Box, } // 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 { let mut callbacks = Box::new(WebCallbacks { cancel: AtomicPtr::new(std::ptr::null_mut()), }); let home = CString::new( std::env::var_os("HOME") .unwrap_or_else(|| ".".into()) .to_string_lossy() .as_bytes(), ) .map_err(|_| "The home directory contains a NUL byte.".to_owned())?; let data = (&mut *callbacks) as *mut WebCallbacks as *mut c_void; let config = WebConfig { home_dir: home.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 { self.call(query, cancel, ds4_web_google_search) } fn visit_page(&self, url: &str, cancel: &AtomicBool) -> Result { 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 { 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::()) }; 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"]}}} {"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"]}}} {"type":"function","function":{"name":"read","description":"Read a text file or a range of lines.","parameters":{"type":"object","properties":{"path":{"type":"string"},"start_line":{"type":"number"},"max_lines":{"type":"number"},"whole":{"type":"boolean"},"raw":{"type":"boolean"}},"required":["path"]}}} {"type":"function","function":{"name":"more","description":"Continue the previous read-like output.","parameters":{"type":"object","properties":{"count":{"type":"number"}}}}} {"type":"function","function":{"name":"write","description":"Create or overwrite a text file.","parameters":{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}}} {"type":"function","function":{"name":"edit","description":"Replace exactly one old text match; old may contain [upto] between unique head and tail anchors.","parameters":{"type":"object","properties":{"path":{"type":"string"},"old":{"type":"string"},"new":{"type":"string"}},"required":["path","old","new"]}}} {"type":"function","function":{"name":"search","description":"Search files and return compact edit-friendly matches. Search is literal by default; set mode to regex for patterns such as foo|bar.","parameters":{"type":"object","properties":{"query":{"type":"string"},"path":{"type":"string"},"mode":{"type":"string","enum":["literal","regex"]},"glob":{"type":"string"},"context":{"type":"number"},"max_results":{"type":"number"},"case_sensitive":{"type":"boolean"}},"required":["query"]}}} {"type":"function","function":{"name":"list","description":"List one directory compactly.","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}}}"#; #[derive(Clone, Debug, PartialEq)] pub(crate) struct ToolCall { pub(crate) name: String, pub(crate) arguments: Map, } pub(crate) struct ActiveTools { pub(crate) results: Receiver, pub(crate) events: Receiver, pub(crate) cancel: Arc, worker: Option>, } impl Drop for ActiveTools { fn drop(&mut self) { self.cancel.store(true, Ordering::Relaxed); if let Some(worker) = self.worker.take() { let _ = worker.join(); } } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum ToolLifecycle { Parsing, AwaitingApproval, Queued, Running, Completed, Failed, Stopped, } impl ToolLifecycle { pub(crate) fn label(self) -> &'static str { match self { Self::Parsing => "Parsing", Self::AwaitingApproval => "Awaiting approval", Self::Queued => "Queued", Self::Running => "Running", Self::Completed => "Completed", Self::Failed => "Failed", Self::Stopped => "Stopped", } } } #[derive(Clone, Debug)] pub(crate) struct ToolCard { pub(crate) call: ToolCall, pub(crate) state: ToolLifecycle, pub(crate) result: Option, } impl ToolCard { pub(crate) fn parsing(call: ToolCall) -> Self { Self { call, state: ToolLifecycle::Parsing, result: None, } } pub(crate) fn streaming() -> Self { Self::parsing(ToolCall { name: "Tool call".into(), arguments: Map::new(), }) } } #[derive(Clone, Debug)] pub(crate) struct ApprovalPrompt { pub(crate) title: String, pub(crate) detail: String, pub(crate) working_directory: PathBuf, } pub(crate) enum ToolEvent { State { index: usize, state: ToolLifecycle, result: Option, }, Approval { index: usize, prompt: ApprovalPrompt, decision: Sender, }, } struct BashJob { id: u32, command: String, child: Child, output: PathBuf, started: Instant, timeout: Duration, observed: usize, } pub(crate) struct Tools { root: PathBuf, context_tokens: i32, more: Option<(PathBuf, usize, bool)>, more_text: Option<(String, usize)>, jobs: HashMap, next_job: u32, browser: Browser, dev_brain: Option, } impl Tools { pub(crate) fn new(root: &Path, context_tokens: i32) -> Result { Ok(Self { root: root .canonicalize() .map_err(|error| format!("Could not open the project directory: {error}"))?, context_tokens, more: None, more_text: None, jobs: HashMap::new(), next_job: 1, browser: Browser::new()?, dev_brain: None, }) } pub(crate) fn enable_dev_brain( &mut self, config: &crate::config::DevBrainConfig, projects: &[crate::database::Project], ) -> Result<(), String> { self.dev_brain = Some(crate::dev_brain::DevBrain::open(config, projects)?); Ok(()) } fn execute(&mut self, call: &ToolCall, cancel: &AtomicBool) -> String { let result = match call.name.as_str() { "read" => self.read(call), "more" => self.more(call), "write" => self.write(call), "edit" => self.edit(call), "search" => self.search(call), "list" => self.list(call), "bash" => self.bash(call, cancel), "bash_status" => self.bash_observe(call, false, cancel), "bash_stop" => self.bash_observe(call, true, cancel), "google_search" => self.google_search(call, cancel), "visit_page" => self.visit_page(call, cancel), "dev_brain_info" => self.dev_brain_info(), "dev_brain_search" => self.dev_brain_search(call), "dev_brain_validate" => self.dev_brain_validate(), name => Err(format!("unknown tool: {name}")), }; match result { Ok(result) if result.len() <= self.result_limit() => result, Ok(result) => { let length = result.len(); self.more_text = Some((result, 0)); let chunk = self.continue_text(self.default_lines()).unwrap_or_default(); format!( "{} result is too large for this context ({length} bytes); showing a bounded chunk. Use more to continue.\n{chunk}", call.name ) } Err(error) => format!("Tool error: {error}\n"), } } fn dev_brain_search(&mut self, call: &ToolCall) -> Result { let query = required_string(call, "query")?; let limit = integer(call, "limit", 8, 1, 50); let authoritative = boolean(call, "authoritative", true); self.dev_brain .as_mut() .ok_or_else(|| "Dev Brain is disabled for this session.".to_owned())? .search(query, limit, authoritative) } fn dev_brain_info(&self) -> Result { Ok(self .dev_brain .as_ref() .ok_or_else(|| "Dev Brain is disabled for this session.".to_owned())? .info()) } fn dev_brain_validate(&mut self) -> Result { self.dev_brain .as_mut() .ok_or_else(|| "Dev Brain is disabled for this session.".to_owned())? .validate() } fn result_limit(&self) -> usize { (self.context_tokens.max(4096) as usize * 2).min(512 * 1024) } pub(crate) fn compaction_observation(&mut self) -> Option { let mut running = self .jobs .values_mut() .filter_map(|job| { job.child.try_wait().ok().flatten().is_none().then(|| { format!( "bash job={} pid={} status=running command={}\noutput_path={}\n", job.id, job.child.id(), job.command, job.output.display() ) }) }) .collect::>(); if running.is_empty() { return None; } running.sort(); Some(format!( "{COMPACTION_OBSERVATION_PREFIX} Running jobs still need explicit bash_status or bash_stop if relevant.\n{}", running.concat() )) } fn existing_path(&self, value: &str) -> Result { if Path::new(value) .components() .any(|component| matches!(component, std::path::Component::ParentDir)) { return Err(format!("path contains a parent traversal: {value}")); } let path = if Path::new(value).is_absolute() { PathBuf::from(value) } else { self.root.join(value) }; let path = path .canonicalize() .map_err(|error| format!("open {value}: {error}"))?; self.inside_readable_root(path, value) } fn writable_path(&self, value: &str) -> Result { if Path::new(value) .components() .any(|component| matches!(component, std::path::Component::ParentDir)) { return Err(format!("path contains a parent traversal: {value}")); } let path = if Path::new(value).is_absolute() { PathBuf::from(value) } else { self.root.join(value) }; if fs::symlink_metadata(&path).is_ok() { let path = self.existing_path(value)?; return self.inside_writable_root(path, value); } let mut ancestor = path.as_path(); let mut suffix = Vec::new(); while fs::symlink_metadata(ancestor).is_err() { let name = ancestor .file_name() .ok_or_else(|| format!("invalid path: {value}"))?; suffix.push(name.to_owned()); ancestor = ancestor .parent() .ok_or_else(|| format!("invalid path: {value}"))?; } let mut resolved = ancestor .canonicalize() .map_err(|error| format!("open ancestor of {value}: {error}"))?; self.inside_readable_root(resolved.clone(), value)?; for name in suffix.into_iter().rev() { resolved.push(name); } self.inside_writable_root(resolved, value) } fn inside_readable_root(&self, path: PathBuf, original: &str) -> Result { if path.starts_with(&self.root) { return Ok(path); } if let Some(brain) = &self.dev_brain && let Ok(relative) = path.strip_prefix(brain.folder()) && !relative .components() .any(|component| component.as_os_str().to_string_lossy().starts_with('.')) { return Ok(path); } Err(format!( "path is outside the project and managed Dev Brain folder: {original}" )) } fn inside_writable_root(&self, path: PathBuf, original: &str) -> Result { if path.starts_with(&self.root) || self .dev_brain .as_ref() .is_some_and(|brain| brain.allows_tool_write(&path)) { Ok(path) } else { Err(format!( "path is not a managed project or Dev Brain file: {original}" )) } } fn validate_dev_brain_content(&self, path: &Path, content: &str) -> Result<(), String> { if let Some(brain) = &self.dev_brain && path.starts_with(brain.folder()) { brain.validate_tool_content(path, content)?; } Ok(()) } fn default_lines(&self) -> usize { match self.context_tokens { ..=8192 => 120, 8193..=16384 => 240, _ => 500, } } fn read(&mut self, call: &ToolCall) -> Result { let path = required_string(call, "path")?; let start = integer(call, "start_line", 1, 1, usize::MAX); let count = integer(call, "max_lines", self.default_lines(), 1, usize::MAX); self.read_range( &self.existing_path(path)?, start, count, boolean(call, "whole", false), boolean(call, "raw", false), ) } fn more(&mut self, call: &ToolCall) -> Result { if self.more_text.is_some() { let count = integer(call, "count", self.default_lines(), 1, usize::MAX); return Ok(self.continue_text(count).unwrap()); } let (path, start, raw) = self .more .clone() .ok_or_else(|| "no previous output to continue".to_owned())?; let count = integer(call, "count", self.default_lines(), 1, usize::MAX); self.read_range(&path, start, count, false, raw) } fn continue_text(&mut self, count: usize) -> Option { let (text, start) = self.more_text.take()?; let byte_limit = count.saturating_mul(80).min(self.result_limit() / 2); let rest = &text[start.min(text.len())..]; let end = rest .char_indices() .map(|(index, _)| index) .find(|index| *index >= byte_limit) .unwrap_or(rest.len()); let output = rest[..end].to_owned(); if start + end < text.len() { self.more_text = Some((text, start + end)); } Some(output) } fn read_range( &mut self, path: &Path, start: usize, count: usize, whole: bool, raw: bool, ) -> Result { self.more_text = None; let metadata = path.metadata().map_err(|error| error.to_string())?; if metadata.len() > MAX_FILE_BYTES { return Err(format!( "file too large: {} exceeds {MAX_FILE_BYTES} bytes", path.display() )); } let data = fs::read_to_string(path) .map_err(|error| format!("read {}: {error}", path.display()))?; let lines = data.lines().collect::>(); let first = start.saturating_sub(1).min(lines.len()); let last = if whole { lines.len() } else { first.saturating_add(count).min(lines.len()) }; self.more = (last < lines.len()).then(|| (path.to_owned(), last + 1, raw)); let mut output = String::new(); if !raw { if last < lines.len() { output.push_str(&format!( "{}: lines {}-{} of {}; continue_offset={}; call more with count={} to read the next chunk\n", path.display(), if lines.is_empty() { 0 } else { first + 1 }, last, lines.len(), last + 1, count )); } else { output.push_str(&format!( "{}: lines {}-{} of {}\n", path.display(), if lines.is_empty() { 0 } else { first + 1 }, last, lines.len() )); } } for (index, line) in lines[first..last].iter().enumerate() { if raw { output.push_str(line); } else { output.push_str(&format!("{} {line}", first + index + 1)); } output.push('\n'); } if raw && last < lines.len() { output.push_str(&format!( "[Read truncated at line {} of {}. continue_offset={}. Call more with count={} to read the next chunk.]\n", last, lines.len(), last + 1, count )); } Ok(output) } fn write(&self, call: &ToolCall) -> Result { let display = required_string(call, "path")?; let content = required_string(call, "content")?; if content.len() as u64 > MAX_FILE_BYTES { return Err(format!("content exceeds {MAX_FILE_BYTES} bytes")); } let path = self.writable_path(display)?; self.validate_dev_brain_content(&path, content)?; if self .dev_brain .as_ref() .is_some_and(|brain| path.starts_with(brain.folder())) && let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|error| format!("create parent for {display}: {error}"))?; } fs::write(&path, content).map_err(|error| format!("write {display}: {error}"))?; Ok(format!("Wrote {} bytes to {display}\n", content.len())) } fn edit(&self, call: &ToolCall) -> Result { let display = required_string(call, "path")?; let old = required_string(call, "old")?; let new = required_string(call, "new")?; if old.is_empty() { return Err("edit requires non-empty old text".into()); } let path = self.existing_path(display)?; self.inside_writable_root(path.clone(), display)?; if path.metadata().map_err(|error| error.to_string())?.len() > MAX_FILE_BYTES { return Err(format!( "file too large: {display} exceeds {MAX_FILE_BYTES} bytes" )); } let data = fs::read_to_string(&path).map_err(|error| format!("read {display}: {error}"))?; let (start, end, anchored) = edit_span(&data, old)?; if data.len() - (end - start) + new.len() > MAX_FILE_BYTES as usize { return Err(format!("edited file would exceed {MAX_FILE_BYTES} bytes")); } let mut output = String::with_capacity(data.len() - (end - start) + new.len()); output.push_str(&data[..start]); output.push_str(new); output.push_str(&data[end..]); self.validate_dev_brain_content(&path, &output)?; fs::write(&path, output).map_err(|error| format!("write {display}: {error}"))?; Ok(format!( "Edited {display} using {} replacement\n", if anchored { "anchored old/new" } else { "old/new" } )) } fn list(&self, call: &ToolCall) -> Result { let display = string(call, "path").unwrap_or("."); let path = self.existing_path(display)?; if !path.is_dir() { return Err(format!("not a directory: {display}")); } let mut entries = fs::read_dir(&path) .map_err(|error| format!("list {display}: {error}"))? .filter_map(Result::ok) .collect::>(); entries.sort_by_key(|entry| entry.file_name()); let dev_brain_path = self .dev_brain .as_ref() .is_some_and(|brain| path.starts_with(brain.folder())); let mut output = format!("{display}:\n"); for entry in entries.iter().take(300) { if dev_brain_path && entry.file_name().to_string_lossy().starts_with('.') { continue; } let metadata = fs::symlink_metadata(entry.path()).map_err(|error| error.to_string())?; let kind = if metadata.file_type().is_symlink() { 'l' } else if metadata.is_dir() { 'd' } else { '-' }; let suffix = if metadata.is_dir() { "/" } else { "" }; output.push_str(&format!( "{kind} {:>10} {}{suffix}\n", metadata.len(), entry.file_name().to_string_lossy() )); } if entries.len() > 300 { output.push_str("... more entries omitted ...\n"); } Ok(output) } fn search(&self, call: &ToolCall) -> Result { let query = required_string(call, "query")?; let display = string(call, "path").unwrap_or("."); let path = self.existing_path(display)?; let context = integer(call, "context", 0, 0, 5); let limit = integer(call, "max_results", 50, 1, 500); let options = SearchOptions { query, glob: string(call, "glob"), regex: string(call, "mode") == Some("regex"), case_sensitive: boolean(call, "case_sensitive", true), context, limit, }; let root = self .dev_brain .as_ref() .filter(|brain| path.starts_with(brain.folder())) .map_or(self.root.as_path(), |brain| brain.folder()); search_path(root, &path, &options, root != self.root.as_path()) } fn bash(&mut self, call: &ToolCall, cancel: &AtomicBool) -> Result { let command = required_string(call, "command")?.to_owned(); let timeout = integer(call, "timeout_sec", 3600, 1, 24 * 3600); let refresh = integer(call, "refresh_sec", 60, 1, 3600); let id = self.next_job; self.next_job = self.next_job.saturating_add(1).max(1); let output = std::env::temp_dir().join(format!( "ds4_agent_output_{}_{}_{}", std::process::id(), id, SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos() )); let stdout = File::create(&output).map_err(|error| error.to_string())?; let output = output.canonicalize().map_err(|error| error.to_string())?; let stderr = stdout.try_clone().map_err(|error| error.to_string())?; let mut process = Command::new("/bin/sh"); process .arg("-c") .arg(format!( "ulimit -f {}; exec /bin/sh -c \"$1\"", MAX_FILE_BYTES / 512 )) .arg("ds4-agent") .arg(&command) .current_dir(&self.root) .stdin(Stdio::null()) .stdout(stdout) .stderr(stderr) .process_group(0) .env_clear(); for name in SHELL_ENV_ALLOWLIST { if let Some(value) = std::env::var_os(name) { process.env(name, value); } } process.env("PWD", &self.root); let child = process .spawn() .map_err(|error| format!("bash failed to start: {error}"))?; self.jobs.insert( id, BashJob { id, command, child, output, started: Instant::now(), timeout: Duration::from_secs(timeout as u64), observed: 0, }, ); self.wait_job(id, refresh, cancel, true) } fn bash_observe( &mut self, call: &ToolCall, stop: bool, cancel: &AtomicBool, ) -> Result { let requested_id = integer(call, "job", 0, 0, u32::MAX as usize) as u32; let requested_pid = integer(call, "pid", 0, 0, u32::MAX as usize) as u32; let id = if self.jobs.contains_key(&requested_id) { requested_id } else { self.jobs .iter() .find_map(|(id, job)| (job.child.id() == requested_pid).then_some(*id)) .unwrap_or(requested_id) }; let refresh = integer(call, "refresh_sec", 60, 1, 3600); if !self.jobs.contains_key(&id) { return Err(format!( "bash job not found: job={requested_id} pid={requested_pid}" )); } if stop { stop_job(self.jobs.get_mut(&id).unwrap()); } self.wait_job(id, if stop { 1 } else { refresh }, cancel, stop) } fn wait_job( &mut self, id: u32, refresh: usize, cancel: &AtomicBool, _remove_done: bool, ) -> Result { let deadline = Instant::now() + Duration::from_secs(refresh as u64); loop { let job = self.jobs.get_mut(&id).unwrap(); let done = job.child.try_wait().map_err(|error| error.to_string())?; if done.is_some() || Instant::now() >= deadline { break; } if job.started.elapsed() >= job.timeout { stop_job(job); break; } if cancel.load(Ordering::Relaxed) { stop_job(job); return Err("interrupted".into()); } thread::sleep(Duration::from_millis(100)); } let output_limit = self.result_limit().saturating_sub(1024).max(1024); let job = self.jobs.get_mut(&id).unwrap(); let status = job.child.try_wait().map_err(|error| error.to_string())?; let bytes = fs::read(&job.output) .map_err(|error| format!("read {}: {error}", job.output.display()))?; let first_observation = job.observed == 0; let new = &bytes[job.observed.min(bytes.len())..]; job.observed = bytes.len(); let truncated = new.len() > output_limit; let mut result = format!( "bash job={} pid={} status={} command={}\noutput_path={}\n", job.id, job.child.id(), status.map_or("running".into(), |status| status.to_string()), job.command, job.output.display() ); if truncated && status.is_some() { let half = output_limit / 2; result.push_str(&String::from_utf8_lossy(&new[..half])); result.push_str("\n... middle output omitted; open output_path to inspect it ...\n"); result.push_str(&String::from_utf8_lossy(&new[new.len() - half..])); } else { result.push_str(&String::from_utf8_lossy( &new[..new.len().min(output_limit)], )); } if truncated && status.is_none() { if first_observation { result.push_str("\n... output truncated; open output_path or use bash_status for new output ...\n"); } else { result.push_str( "\n... output truncated; open output_path for the complete output ...\n", ); } } if !result.ends_with('\n') { result.push('\n'); } if status.is_some() && !truncated { let job = self.jobs.remove(&id).unwrap(); if let Err(error) = fs::remove_file(&job.output) { result.push_str(&format!( "Tool warning: could not remove {}: {error}\n", job.output.display() )); } } Ok(result) } fn google_search(&mut self, call: &ToolCall, cancel: &AtomicBool) -> Result { let query = required_string(call, "query")?; self.browser.google_search(query, cancel) } fn visit_page(&mut self, call: &ToolCall, cancel: &AtomicBool) -> Result { let url = required_string(call, "url")?; if !matches!( url.split_once(':').map(|part| part.0), Some("http" | "https") ) { return Err("visit_page requires an HTTP or HTTPS URL".into()); } let markdown = self.browser.visit_page(url, cancel)?; let line_end: usize = markdown.split_inclusive('\n').take(100).map(str::len).sum(); let mut head_end = line_end.min(8 * 1024).min(markdown.len()); while !markdown.is_char_boundary(head_end) { head_end -= 1; } let head = &markdown[..head_end]; if head_end < markdown.len() { self.more = None; self.more_text = Some((markdown.clone(), head_end)); } Ok(format!( "visit_page url={url} ({} bytes, {} lines)\n\n{head}\n\nPage output is bounded; use more to continue.\n", markdown.len(), markdown.lines().count() )) } fn approval(&self, call: &ToolCall) -> Option { 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(), working_directory: self.root.clone(), }); } let command = (call.name == "bash").then(|| string(call, "command"))??; risky_shell_reason(command, &self.root).map(|reason| ApprovalPrompt { title: "Allow shell command?".into(), detail: format!("{reason}\n\n{command}"), working_directory: self.root.clone(), }) } pub(crate) fn stop_all_jobs(&mut self) -> Vec { let mut failures = Vec::new(); for job in self.jobs.values_mut() { stop_job(job); if let Err(error) = fs::remove_file(&job.output) { failures.push(format!( "Could not remove {}: {error}", job.output.display() )); } } self.jobs.clear(); failures } } struct SearchOptions<'a> { query: &'a str, glob: Option<&'a str>, regex: bool, case_sensitive: bool, context: usize, limit: usize, } fn search_path( root: &Path, path: &Path, options: &SearchOptions<'_>, skip_hidden: bool, ) -> Result { let mut files = Vec::new(); collect_search_files(path, 0, skip_hidden, &mut files)?; let mut matches = 0; let mut body = String::new(); for file in files { if matches >= options.limit { break; } let relative = file.strip_prefix(root).unwrap_or(&file); if options.glob.is_some_and(|glob| { !wildcard_match(glob, &relative.to_string_lossy()) && !wildcard_match( glob, &file.file_name().unwrap_or_default().to_string_lossy(), ) }) { continue; } let remaining = options.limit - matches; let (count, text) = if options.regex { regex_search_file(&file, options, remaining)? } else { literal_search_file(&file, options, remaining)? }; if count > 0 { body.push_str(&format!("{}\n{text}\n", relative.display())); matches += count; } } if matches == 0 { Ok("No matches\n".into()) } else { Ok(format!( "{matches} match{} shown\n\n{body}", if matches == 1 { "" } else { "es" } )) } } fn collect_search_files( path: &Path, depth: usize, skip_hidden: bool, output: &mut Vec, ) -> Result<(), String> { if depth > 24 { return Ok(()); } let metadata = fs::symlink_metadata(path).map_err(|error| error.to_string())?; if metadata.file_type().is_symlink() { return Ok(()); } if metadata.is_file() { if metadata.len() <= MAX_FILE_BYTES { output.push(path.to_owned()); } return Ok(()); } if !metadata.is_dir() { return Ok(()); } let mut entries = fs::read_dir(path) .map_err(|error| error.to_string())? .filter_map(Result::ok) .collect::>(); entries.sort_by_key(|entry| entry.file_name()); for entry in entries { if entry.file_name() == ".git" || skip_hidden && entry.file_name().to_string_lossy().starts_with('.') { continue; } collect_search_files(&entry.path(), depth + 1, skip_hidden, output)?; } Ok(()) } fn literal_search_file( path: &Path, options: &SearchOptions<'_>, limit: usize, ) -> Result<(usize, String), String> { let bytes = fs::read(path).map_err(|error| error.to_string())?; if bytes.contains(&0) { return Ok((0, String::new())); } let data = String::from_utf8_lossy(&bytes); let lines = data.lines().collect::>(); let query = (!options.case_sensitive).then(|| options.query.to_lowercase()); let found = lines .iter() .enumerate() .filter_map(|(index, line)| { let matched = query.as_ref().map_or_else( || line.contains(options.query), |query| line.to_lowercase().contains(query), ); matched.then_some(index) }) .take(limit) .collect::>(); let mut output = String::new(); let mut last = None; for match_index in &found { let start = match_index.saturating_sub(options.context); let end = match_index .saturating_add(options.context + 1) .min(lines.len()); for (index, line) in lines.iter().enumerate().take(end).skip(start) { if last.is_none_or(|last| index > last) { output.push_str(&format!(" {} {line}\n", index + 1)); last = Some(index); } } } Ok((found.len(), output)) } fn regex_search_file( path: &Path, options: &SearchOptions<'_>, limit: usize, ) -> Result<(usize, String), String> { let mut command = Command::new("/usr/bin/grep"); command.arg("-n").arg("-I").arg("-E"); if !options.case_sensitive { command.arg("-i"); } if options.context > 0 { command.arg("-C").arg(options.context.to_string()); } command.arg("-m").arg(limit.to_string()); command.arg("--").arg(options.query).arg(path); let output = command.output().map_err(|error| error.to_string())?; if output.status.code() == Some(1) { return Ok((0, String::new())); } if !output.status.success() { return Err(format!( "invalid regex: {}", String::from_utf8_lossy(&output.stderr).trim() )); } let output = String::from_utf8_lossy(&output.stdout).into_owned(); let count = output .lines() .filter(|line| { line.split_once(':') .is_some_and(|(number, _)| number.parse::().is_ok()) }) .count(); Ok((count, output)) } fn wildcard_match(pattern: &str, value: &str) -> bool { let (pattern, value) = (pattern.as_bytes(), value.as_bytes()); let (mut p, mut v, mut star, mut retry) = (0, 0, None, 0); while v < value.len() { if p < pattern.len() && (pattern[p] == b'?' || pattern[p] == value[v]) { p += 1; v += 1; } else if p < pattern.len() && pattern[p] == b'*' { star = Some(p); p += 1; retry = v; } else if let Some(star) = star { p = star + 1; retry += 1; v = retry; } else { return false; } } while p < pattern.len() && pattern[p] == b'*' { p += 1; } p == pattern.len() } impl Drop for Tools { fn drop(&mut self) { for failure in self.stop_all_jobs() { eprintln!("DS4Server tool cleanup: {failure}"); } } } pub(crate) fn execute_async(tools: Arc>, calls: Vec) -> ActiveTools { let cancel = Arc::new(AtomicBool::new(false)); let worker_cancel = Arc::clone(&cancel); let (sender, results) = mpsc::channel(); let (event_sender, events) = mpsc::channel(); let worker = thread::Builder::new() .name("agent-tools".into()) .spawn(move || { let mut output = String::new(); let mut tools = tools .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); for index in 0..calls.len() { send_state(&event_sender, index, ToolLifecycle::Queued, None); } for (index, call) in calls.iter().enumerate() { if worker_cancel.load(Ordering::Relaxed) { output.push_str("Tool error: interrupted\n"); send_state(&event_sender, index, ToolLifecycle::Stopped, None); break; } if let Some(prompt) = tools.approval(call) { match request_approval(&event_sender, index, prompt, &worker_cancel) { Ok(()) => {} Err(error) => { let result = format!("Tool error: {error}\n"); output.push_str(&format!( "Tool result {} ({}):\n{result}", index + 1, call.name )); send_state( &event_sender, index, if worker_cancel.load(Ordering::Relaxed) { ToolLifecycle::Stopped } else { ToolLifecycle::Failed }, Some(result), ); if worker_cancel.load(Ordering::Relaxed) { break; } continue; } } } send_state(&event_sender, index, ToolLifecycle::Running, None); output.push_str(&format!("Tool result {} ({}):\n", index + 1, call.name)); let result = tools.execute(call, &worker_cancel); let state = if worker_cancel.load(Ordering::Relaxed) || result.contains("Tool error: interrupted") { ToolLifecycle::Stopped } else if result.starts_with("Tool error:") { ToolLifecycle::Failed } else { ToolLifecycle::Completed }; output.push_str(&result); send_state(&event_sender, index, state, Some(result)); if !output.ends_with('\n') { output.push('\n'); } } if worker_cancel.load(Ordering::Relaxed) { for failure in tools.stop_all_jobs() { output.push_str(&format!("Tool warning: {failure}\n")); } } let _ = sender.send(output); }) .expect("agent tool worker must start"); ActiveTools { results, events, cancel, worker: Some(worker), } } pub(crate) fn error_async(error: String) -> ActiveTools { let cancel = Arc::new(AtomicBool::new(false)); let (sender, results) = mpsc::channel(); let (_event_sender, events) = mpsc::channel(); let _ = sender.send(format!( "Tool error: invalid tool call: {error}\nRetry using the exact tool syntax from the system prompt.\n" )); ActiveTools { results, events, cancel, worker: None, } } fn send_state( events: &Sender, index: usize, state: ToolLifecycle, result: Option, ) { let _ = events.send(ToolEvent::State { index, state, result, }); } fn request_approval( events: &Sender, index: usize, prompt: ApprovalPrompt, cancel: &AtomicBool, ) -> Result<(), String> { let (decision, response) = mpsc::channel(); events .send(ToolEvent::Approval { index, prompt, decision, }) .map_err(|_| "approval UI is unavailable".to_owned())?; loop { if cancel.load(Ordering::Relaxed) { return Err("interrupted while awaiting approval".into()); } match response.recv_timeout(Duration::from_millis(50)) { Ok(true) => return Ok(()), Ok(false) => return Err("user denied this action".into()), Err(RecvTimeoutError::Timeout) => {} Err(RecvTimeoutError::Disconnected) => { return Err("approval UI closed without a decision".into()); } } } } pub(crate) fn parse_tool_calls( model: ModelChoice, text: &str, ) -> Result<(String, Vec), String> { let (content, calls) = if model == ModelChoice::Glm52 { parse_glm_calls(text)? } else { crate::server::parse_dsml_tool_calls(text)? }; calls .into_iter() .map(|(name, arguments)| match arguments { Value::Object(arguments) => Ok(ToolCall { name, arguments }), _ => Err(format!("tool {name} arguments are not an object")), }) .collect::, _>>() .map(|calls| (content, calls)) } pub(crate) fn system_prompt(model: ModelChoice, extra: &str, dev_brain: bool) -> String { let schemas = if dev_brain { format!("{TOOL_SCHEMAS}\n{}", crate::dev_brain::TOOL_SCHEMAS) } else { TOOL_SCHEMAS.to_owned() }; let tools = if model == ModelChoice::Glm52 { 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\nYou are provided with function signatures within XML tags:\n\n{schemas}\n\n\nFor a function call, output exactly: function-namekeyvalue\nTool calls are not allowed inside . Use read/search for focused context, edit with exact unique old text, and [upto] only between unique head and tail anchors. Use refresh_sec for long bash jobs and poll with bash_status or stop with bash_stop. Preserve the current system configuration unless the user explicitly asks otherwise." ) } 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<|DSML|tool_calls>\n<|DSML|invoke name=\"$TOOL_NAME\">\n<|DSML|parameter name=\"$PARAMETER_NAME\" string=\"true|false\">$PARAMETER_VALUE\n\n\n\nTool calls are not allowed inside . 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." ) }; let tools = if dev_brain { format!("{tools}\n\n{}", crate::dev_brain::PROMPT) } else { tools }; if extra.trim().is_empty() { tools } else { format!("{tools}\n\n{extra}") } } pub(crate) fn system_prompt_reminder(model: ModelChoice, dev_brain: bool) -> String { format!( "[System prompt reminder follows.]\n{}\n[End system prompt reminder.]", system_prompt(model, "", dev_brain) ) } pub(crate) fn prompt_reminder_due(used: u32, last: u32) -> bool { used.saturating_sub(last) >= 50_000 } pub(crate) fn datetime_context() -> String { let when = Command::new("/bin/date") .arg("+%Y-%m-%d %H:%M:%S %Z") .output() .ok() .filter(|output| output.status.success()) .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned()) .filter(|output| !output.is_empty()) .unwrap_or_else(|| { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() .to_string() }); format!( "Current local date and time at session start: {when}. Use this only when date or time matters." ) } pub(crate) fn try_tool_result(active: &ActiveTools) -> Result, String> { match active.results.try_recv() { Ok(result) => Ok(Some(result)), Err(TryRecvError::Empty) => Ok(None), Err(TryRecvError::Disconnected) => { Err("The agent tool worker stopped unexpectedly.".into()) } } } pub(crate) fn try_tool_event(active: &ActiveTools) -> Option { active.events.try_recv().ok() } pub(crate) fn visible_content(content: &str) -> &str { [ "<|DSML|tool_calls>", "", "", "", ] .into_iter() .filter_map(|marker| content.find(marker)) .min() .map_or(content, |end| content[..end].trim_end()) } pub(crate) fn has_tool_markup(content: &str) -> bool { visible_content(content).len() < content.len() } pub(crate) fn stored_tool_cards( model: ModelChoice, assistant: &str, result: Option<&str>, ) -> Vec { let calls = parse_tool_calls(model, assistant) .map(|(_, calls)| calls) .unwrap_or_default(); let results = result.map(split_tool_results).unwrap_or_default(); calls .into_iter() .enumerate() .map(|(index, call)| { let result = results.get(index).cloned(); let state = result.as_deref().map_or(ToolLifecycle::Stopped, |result| { if result.starts_with("Tool error:") { ToolLifecycle::Failed } else { ToolLifecycle::Completed } }); ToolCard { call, state, result, } }) .collect() } fn split_tool_results(result: &str) -> Vec { let starts = result .match_indices("Tool result ") .map(|(index, _)| index) .collect::>(); starts .iter() .enumerate() .map(|(index, start)| { let body = &result[*start..starts.get(index + 1).copied().unwrap_or(result.len())]; body.split_once("\n") .map_or(body, |(_, result)| result) .trim_end() .to_owned() }) .collect() } pub(crate) fn bounded_tool_text(text: &str, limit: usize) -> String { let mut output = text.replace('\n', " "); if output.chars().count() > limit { output = output .chars() .take(limit.saturating_sub(1)) .collect::() + "…"; } output } pub(crate) fn tool_parameters(call: &ToolCall) -> String { ["path", "command", "query", "url"] .into_iter() .find_map(|name| string(call, name).map(|value| (name, value))) .map_or_else( || bounded_tool_text(&Value::Object(call.arguments.clone()).to_string(), 240), |(name, value)| format!("{name}: {}", bounded_tool_text(value, 240)), ) } pub(crate) fn tool_call_text(call: &ToolCall) -> String { let mut tool = Map::new(); tool.insert("name".into(), Value::String(call.name.clone())); tool.insert("arguments".into(), Value::Object(call.arguments.clone())); serde_json::to_string_pretty(&Value::Object(tool)).unwrap_or_default() } pub(crate) fn tool_output_path(result: &str) -> Option { result .lines() .find_map(|line| line.strip_prefix("output_path=")) .map(PathBuf::from) .filter(|path| path.is_absolute() && path.is_file()) } fn risky_shell_reason(command: &str, root: &Path) -> Option<&'static str> { let words = command .split_whitespace() .map(|word| { word.trim_matches(|character: char| { matches!( character, '\'' | '"' | ';' | '|' | '&' | '(' | ')' | '{' | '}' | '<' | '>' ) }) .to_ascii_lowercase() }) .collect::>(); if words .iter() .any(|word| matches!(word.as_str(), "sudo" | "doas" | "su")) { return Some("This command elevates privileges."); } if words .iter() .any(|word| matches!(word.as_str(), "open" | "osascript")) { return Some("This command launches or controls another application."); } if words.iter().any(|word| { matches!( word.as_str(), "rm" | "rmdir" | "unlink" | "shred" | "truncate" | "dd" | "mkfs" | "diskutil" | "kill" | "killall" | "pkill" ) }) || (words.iter().any(|word| word == "git") && words .iter() .any(|word| matches!(word.as_str(), "clean" | "reset"))) { return Some("This command can delete data or discard state."); } if words.iter().any(|word| { matches!( word.as_str(), "curl" | "wget" | "ssh" | "scp" | "sftp" | "ftp" | "nc" | "ncat" | "telnet" ) }) || (words.iter().any(|word| word == "git") && words .iter() .any(|word| matches!(word.as_str(), "push" | "pull" | "fetch" | "clone"))) || (words.iter().any(|word| { matches!( word.as_str(), "cargo" | "npm" | "pnpm" | "yarn" | "pip" | "pip3" ) }) && words .iter() .any(|word| matches!(word.as_str(), "install" | "publish" | "login"))) { return Some("This command can create a network side effect."); } let root = root.to_string_lossy(); if words.iter().any(|word| { (word.contains("../") || word == ".." || word.starts_with("~/") || word.contains("$home") || word.starts_with('/')) && !word.starts_with(root.as_ref()) && !matches!( word.as_str(), "/bin/sh" | "/bin/bash" | "/usr/bin/env" | "/usr/bin/make" ) }) || words.iter().any(|word| { matches!( word.as_str(), "brew" | "launchctl" | "defaults" | "mount" | "umount" | "chown" ) }) { return Some("This command can access or change state outside the project."); } None } fn parse_glm_calls(text: &str) -> Result<(String, Vec<(String, Value)>), String> { let scan = text .rfind("") .map_or(text, |position| &text[position + "".len()..]); let Some(first) = scan.find("") else { return Ok((text.to_owned(), Vec::new())); }; let visible_len = text.len() - scan.len() + first; let mut rest = &scan[first..]; let mut calls = Vec::new(); while rest.starts_with("") { rest = &rest["".len()..]; let end = rest .find("") .ok_or_else(|| "incomplete GLM tool call".to_owned())?; let body = &rest[..end]; let name_end = body.find("").unwrap_or(body.len()); let name = body[..name_end].trim(); if name.is_empty() { return Err("GLM tool call without function name".into()); } let mut arguments = Map::new(); let mut args = &body[name_end..]; while !args.is_empty() { let key = between(&mut args, "", "")?; let value = between(&mut args, "", "")?; arguments.insert(key.to_owned(), Value::String(value.to_owned())); } calls.push((name.to_owned(), Value::Object(arguments))); rest = rest[end + "".len()..].trim_start(); } Ok((text[..visible_len].trim_end().to_owned(), calls)) } fn between<'a>(input: &mut &'a str, open: &str, close: &str) -> Result<&'a str, String> { let body = input .strip_prefix(open) .ok_or_else(|| format!("expected {open}"))?; let end = body .find(close) .ok_or_else(|| format!("expected {close}"))?; *input = &body[end + close.len()..]; Ok(&body[..end]) } fn edit_span(data: &str, old: &str) -> Result<(usize, usize, bool), String> { let markers = old.match_indices("[upto]").collect::>(); if markers.len() > 1 { return Err("old text contains more than one [upto] marker".into()); } if let Some((marker, _)) = markers.first() { let head = &old[..*marker]; let tail = old[*marker + "[upto]".len()..].trim_start_matches(['\r', '\n']); if tail.trim().is_empty() { return Err("old text after [upto] must include a unique tail anchor".into()); } let start = unique_match(data, head, "old head")?; let after_head = start + head.len(); let tail_offset = unique_match(&data[after_head..], tail, "old tail")?; return Ok((start, after_head + tail_offset + tail.len(), true)); } let start = unique_match(data, old, "old text")?; Ok((start, start + old.len(), false)) } fn unique_match(data: &str, needle: &str, label: &str) -> Result { if needle.is_empty() { return Err(format!("{label} is empty")); } let matches = data .match_indices(needle) .map(|(index, _)| index) .collect::>(); match matches.as_slice() { [] => Err(format!("{label} was not found")), [index] => Ok(*index), _ => Err(format!("{label} is ambiguous ({} matches)", matches.len())), } } fn stop_job(job: &mut BashJob) { if job.child.try_wait().ok().flatten().is_some() { return; } let pid = job.child.id(); let _ = Command::new("/bin/kill") .arg("-TERM") .arg(format!("-{pid}")) .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); let deadline = Instant::now() + Duration::from_secs(1); while Instant::now() < deadline { if job.child.try_wait().ok().flatten().is_some() { return; } thread::sleep(Duration::from_millis(20)); } let _ = Command::new("/bin/kill") .arg("-KILL") .arg(format!("-{pid}")) .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); let _ = job.child.wait(); } fn string<'a>(call: &'a ToolCall, name: &str) -> Option<&'a str> { call.arguments.get(name).and_then(Value::as_str) } fn required_string<'a>(call: &'a ToolCall, name: &str) -> Result<&'a str, String> { string(call, name).ok_or_else(|| format!("{} requires {name}", call.name)) } fn integer(call: &ToolCall, name: &str, default: usize, min: usize, max: usize) -> usize { call.arguments .get(name) .and_then(|value| { value .as_u64() .or_else(|| value.as_i64().map(|value| value.max(0) as u64)) .or_else(|| { value .as_f64() .filter(|value| value.is_finite()) .map(|value| value.max(0.0) as u64) }) .or_else(|| { value .as_str() .and_then(|value| value.parse::().ok()) .filter(|value| value.is_finite()) .map(|value| value.max(0.0) as u64) }) }) .and_then(|value| usize::try_from(value).ok()) .unwrap_or(default) .clamp(min, max) } fn boolean(call: &ToolCall, name: &str, default: bool) -> bool { call.arguments .get(name) .and_then(|value| { value.as_bool().or_else(|| { value.as_str().and_then(|value| { if value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("yes") || value == "1" { Some(true) } else if value.eq_ignore_ascii_case("false") || value.eq_ignore_ascii_case("no") || value == "0" { Some(false) } else { None } }) }) }) .unwrap_or(default) } #[cfg(test)] mod tests { use super::*; #[test] fn prompts_and_parsers_expose_the_reference_tool_set() { let prompt = system_prompt(ModelChoice::DeepSeekV4Flash, "extra", false); for name in [ "google_search", "visit_page", "bash", "bash_status", "bash_stop", "read", "more", "write", "edit", "search", "list", ] { assert!(prompt.contains(&format!("\"name\":\"{name}\""))); } assert!(prompt.ends_with("extra")); assert!( system_prompt_reminder(ModelChoice::DeepSeekV4Flash, false) .contains("[System prompt reminder follows.]") ); assert!(!prompt.contains("dev_brain_search")); let dev_brain_prompt = system_prompt(ModelChoice::DeepSeekV4Flash, "", true); for name in ["dev_brain_info", "dev_brain_search", "dev_brain_validate"] { assert!(dev_brain_prompt.contains(name)); } assert!(!dev_brain_prompt.contains("dev_brain_publish")); assert!(datetime_context().starts_with("Current local date and time at session start:")); assert!(!prompt_reminder_due(49_999, 0)); assert!(prompt_reminder_due(50_000, 0)); assert!(!prompt_reminder_due(80_000, 50_000)); let text = "donereadpathsrc/main.rs"; let (visible, calls) = parse_tool_calls(ModelChoice::Glm52, text).unwrap(); assert_eq!(visible, "done"); assert_eq!(calls[0].name, "read"); assert_eq!(calls[0].arguments["path"], "src/main.rs"); let dsml = "done<|DSML|tool_calls><|DSML|invoke name=\"read\"><|DSML|parameter name=\"path\" string=\"true\">src/main.rs"; let (visible, calls) = parse_tool_calls(ModelChoice::DeepSeekV4Flash, dsml).unwrap(); assert_eq!(visible, "done"); assert_eq!(calls[0].arguments["path"], "src/main.rs"); assert!( parse_tool_calls( ModelChoice::DeepSeekV4Flash, "<|DSML|tool_calls><|DSML|invoke name=\"read\">" ) .is_err() ); } #[test] fn anchored_edits_require_unique_head_and_tail() { let data = "start\nold one\nold two\nfinish\nother\n"; assert_eq!( edit_span(data, "start\n[upto]\nfinish\n").unwrap(), (0, 29, true) ); assert!(edit_span("same same", "same").is_err()); assert!(edit_span(data, "start\n[upto]\n").is_err()); } #[test] fn project_boundary_rejects_parent_paths() { let directory = std::env::temp_dir().join(format!( "ds4-agent-test-{}", SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() )); fs::create_dir_all(&directory).unwrap(); let outside = directory.with_extension("outside"); fs::create_dir_all(&outside).unwrap(); fs::write(outside.join("secret.txt"), "secret").unwrap(); std::os::unix::fs::symlink(&outside, directory.join("escape")).unwrap(); let tools = Tools::new(&directory, 32_768).unwrap(); assert!(tools.writable_path("inside.txt").is_ok()); assert!(tools.writable_path("missing/inside.txt").is_ok()); assert!(tools.writable_path("../outside.txt").is_err()); assert!(tools.writable_path("missing/../../outside.txt").is_err()); assert!(tools.existing_path("escape/secret.txt").is_err()); assert!(tools.writable_path("escape/new.txt").is_err()); fs::remove_dir_all(directory).unwrap(); fs::remove_dir_all(outside).unwrap(); } #[test] fn standard_file_tools_can_maintain_only_managed_dev_brain_pages() { let directory = std::env::temp_dir().join(format!( "ds4-agent-brain-{}", SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() )); let project = directory.join("project"); let vault = directory.join("vault"); fs::create_dir_all(&project).unwrap(); fs::create_dir_all(vault.join(".obsidian")).unwrap(); fs::write(project.join("source.rs"), "source\n").unwrap(); let mut tools = Tools::new(&project, 4096).unwrap(); tools .enable_dev_brain( &crate::config::DevBrainConfig { enabled: true, vault_path: Some(vault.to_string_lossy().into_owned()), }, &[crate::database::Project { id: 1, name: "Fixture".into(), path: project.to_string_lossy().into_owned(), collapsed: false, }], ) .unwrap(); assert!( tools .existing_path(vault.join("schema.md").to_str().unwrap()) .is_ok() ); assert!( tools .writable_path(vault.join("concepts/new.md").to_str().unwrap()) .is_ok() ); let nested = vault.join("subsystems/inference/modes.md"); let mut arguments = Map::new(); arguments.insert( "path".into(), Value::String(nested.to_string_lossy().into_owned()), ); arguments.insert( "content".into(), Value::String("---\ndev_brain: true\n---\n# Modes\n".into()), ); tools .write(&ToolCall { name: "write".into(), arguments, }) .unwrap(); assert!(nested.is_file()); assert!( tools .existing_path(vault.join(".obsidian").to_str().unwrap()) .is_err() ); fs::write(vault.join("concepts/private.md"), "# Private\n").unwrap(); assert!( tools .writable_path(vault.join("concepts/private.md").to_str().unwrap()) .is_err() ); fs::remove_dir_all(directory).unwrap(); } #[test] fn risky_shell_commands_require_one_time_approval() { let root = Path::new("/tmp/project"); assert!(risky_shell_reason("cargo test --all-features", root).is_none()); assert!(risky_shell_reason("git status --short", root).is_none()); for command in [ "rm -rf target", "sudo make install", "curl https://example.com", "open report.html", "touch ../outside", "git push origin main", ] { assert!(risky_shell_reason(command, root).is_some(), "{command}"); } assert!(!SHELL_ENV_ALLOWLIST.contains(&"GITHUB_TOKEN")); assert!(!SHELL_ENV_ALLOWLIST.contains(&"AWS_SECRET_ACCESS_KEY")); assert!(!SHELL_ENV_ALLOWLIST.contains(&"SSH_AUTH_SOCK")); } #[test] fn denial_and_stop_cancel_commands_awaiting_approval() { let directory = std::env::temp_dir().join(format!( "ds4-agent-approval-{}", SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() )); fs::create_dir_all(&directory).unwrap(); fs::write(directory.join("keep.txt"), "keep").unwrap(); let tools = Arc::new(Mutex::new(Tools::new(&directory, 4096).unwrap())); let active = execute_async( Arc::clone(&tools), vec![call("bash", [("command", "rm keep.txt")])], ); let decision = loop { match active.events.recv_timeout(Duration::from_secs(2)).unwrap() { ToolEvent::Approval { prompt, decision, .. } => { assert!(prompt.detail.contains("rm keep.txt")); assert_eq!(prompt.working_directory, directory.canonicalize().unwrap()); break decision; } ToolEvent::State { .. } => {} } }; decision.send(false).unwrap(); assert!( active .results .recv_timeout(Duration::from_secs(2)) .unwrap() .contains("user denied") ); assert!(directory.join("keep.txt").exists()); let active = execute_async(tools, vec![call("google_search", [("query", "DS4")])]); let _decision = loop { match active.events.recv_timeout(Duration::from_secs(2)).unwrap() { ToolEvent::Approval { prompt, decision, .. } => { assert!(prompt.title.contains("browser")); break decision; } ToolEvent::State { .. } => {} } }; active.cancel.store(true, Ordering::Relaxed); assert!( active .results .recv_timeout(Duration::from_secs(2)) .unwrap() .contains("interrupted") ); fs::remove_dir_all(directory).unwrap(); } #[test] fn oversized_shell_output_is_bounded_and_retained_until_cleanup() { let directory = std::env::temp_dir().join(format!( "ds4-agent-output-{}", SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() )); fs::create_dir_all(&directory).unwrap(); let mut tools = Tools::new(&directory, 4096).unwrap(); let result = tools.execute( &call( "bash", [("command", "/usr/bin/yes x | /usr/bin/head -c 20000")], ), &AtomicBool::new(false), ); assert!(result.len() <= tools.result_limit()); assert!(result.contains("middle output omitted")); let output = tool_output_path(&result).unwrap(); assert_eq!(fs::metadata(&output).unwrap().len(), 20_000); fs::write(directory.join("large.txt"), "match xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n".repeat(500)).unwrap(); let search = tools.execute( &call("search", [("query", "match"), ("max_results", "500")]), &AtomicBool::new(false), ); assert!(search.contains("Use more to continue")); assert!( !tools .execute(&call("more", []), &AtomicBool::new(false)) .is_empty() ); drop(tools); assert!(!output.exists()); fs::remove_dir_all(directory).unwrap(); } #[test] fn dropping_session_tools_stops_live_jobs_and_removes_output() { let directory = std::env::temp_dir().join(format!( "ds4-agent-switch-{}", SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() )); fs::create_dir_all(&directory).unwrap(); let mut tools = Tools::new(&directory, 4096).unwrap(); let result = tools.execute( &call("bash", [("command", "sleep 30"), ("refresh_sec", "1")]), &AtomicBool::new(false), ); assert!(result.contains("status=running")); let pid = result .split_whitespace() .find_map(|field| field.strip_prefix("pid=")) .unwrap() .parse::() .unwrap(); let output = tool_output_path(&result).unwrap(); drop(tools); assert!( !Command::new("/bin/kill") .args(["-0", &pid.to_string()]) .status() .unwrap() .success() ); assert!(!output.exists()); fs::remove_dir_all(directory).unwrap(); } #[test] fn local_file_and_bash_tools_execute_in_the_project() { let directory = std::env::temp_dir().join(format!( "ds4-agent-tools-{}", SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() )); fs::create_dir_all(&directory).unwrap(); let mut tools = Tools::new(&directory, 4096).unwrap(); let cancel = AtomicBool::new(false); let write = call("write", [("path", "note.txt"), ("content", "one\ntwo\n")]); assert!(tools.execute(&write, &cancel).starts_with("Wrote 8 bytes")); let read = call("read", [("path", "note.txt"), ("max_lines", "1")]); assert!(tools.execute(&read, &cancel).contains("1 one")); assert!(tools.execute(&call("more", []), &cancel).contains("2 two")); let edit = call( "edit", [("path", "note.txt"), ("old", "two"), ("new", "three")], ); assert!(tools.execute(&edit, &cancel).starts_with("Edited note.txt")); let search = call("search", [("query", "three"), ("glob", "*.txt")]); assert!(tools.execute(&search, &cancel).contains("2 three")); assert!( tools .execute(&call("list", [("path", ".")]), &cancel) .contains("note.txt") ); let bash = call( "bash", [("command", "printf shell-ok"), ("refresh_sec", "1")], ); assert!(tools.execute(&bash, &cancel).contains("shell-ok")); let running = call( "bash", [ ("command", "printf started; sleep 5; printf finished"), ("refresh_sec", "1"), ], ); assert!(tools.execute(&running, &cancel).contains("status=running")); let observation = tools.compaction_observation().unwrap(); assert!(observation.contains("bash job=2")); assert!(observation.contains("status=running")); let stopped = tools.execute(&call("bash_stop", [("job", "2")]), &cancel); assert!(!stopped.contains("status=running")); assert_eq!( fs::read_to_string(directory.join("note.txt")).unwrap(), "one\nthree\n" ); fs::remove_dir_all(directory).unwrap(); } fn call(name: &str, arguments: [(&str, &str); N]) -> ToolCall { ToolCall { name: name.to_owned(), arguments: arguments .into_iter() .map(|(name, value)| (name.to_owned(), Value::String(value.to_owned()))) .collect(), } } }