Files
DS4Server/src/agent.rs
2026-07-27 21:42:58 +02:00

2150 lines
77 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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 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<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"]}}}
{"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<String, Value>,
}
pub(crate) struct ActiveTools {
pub(crate) results: Receiver<String>,
pub(crate) events: Receiver<ToolEvent>,
pub(crate) cancel: Arc<AtomicBool>,
worker: Option<thread::JoinHandle<()>>,
}
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<String>,
}
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<String>,
},
Approval {
index: usize,
prompt: ApprovalPrompt,
decision: Sender<bool>,
},
}
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<u32, BashJob>,
next_job: u32,
browser: Browser,
dev_brain: Option<crate::dev_brain::DevBrain>,
}
impl Tools {
pub(crate) fn new(root: &Path, context_tokens: i32) -> Result<Self, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<String> {
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::<Vec<_>>();
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<PathBuf, String> {
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<PathBuf, String> {
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<PathBuf, String> {
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<PathBuf, String> {
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<String, String> {
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<String, String> {
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<String> {
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<String, String> {
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::<Vec<_>>();
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<String, String> {
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<String, String> {
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<String, String> {
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::<Vec<_>>();
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
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<String, String> {
let query = required_string(call, "query")?;
self.browser.google_search(query, cancel)
}
fn visit_page(&mut self, call: &ToolCall, cancel: &AtomicBool) -> Result<String, String> {
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<head -100>\n{head}\n</head>\nPage output is bounded; use more to continue.\n",
markdown.len(),
markdown.lines().count()
))
}
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(),
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<String> {
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<String, String> {
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<PathBuf>,
) -> 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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<usize>().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<Mutex<Tools>>, calls: Vec<ToolCall>) -> 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<ToolEvent>,
index: usize,
state: ToolLifecycle,
result: Option<String>,
) {
let _ = events.send(ToolEvent::State {
index,
state,
result,
});
}
fn request_approval(
events: &Sender<ToolEvent>,
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<ToolCall>), 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::<Result<Vec<_>, _>>()
.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 <tools></tools> XML tags:\n<tools>\n{schemas}\n</tools>\n\nFor a function call, output exactly: <tool_call>function-name<arg_key>key</arg_key><arg_value>value</arg_value></tool_call>\nTool calls are not allowed inside <think></think>. 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<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."
)
};
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<Option<String>, 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<ToolEvent> {
active.events.try_recv().ok()
}
pub(crate) fn visible_content(content: &str) -> &str {
[
"<DSMLtool_calls>",
"<DSMLtool_calls>",
"<tool_calls>",
"<tool_call>",
]
.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<ToolCard> {
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<String> {
let starts = result
.match_indices("Tool result ")
.map(|(index, _)| index)
.collect::<Vec<_>>();
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::<String>()
+ "";
}
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<PathBuf> {
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::<Vec<_>>();
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("</think>")
.map_or(text, |position| &text[position + "</think>".len()..]);
let Some(first) = scan.find("<tool_call>") 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("<tool_call>") {
rest = &rest["<tool_call>".len()..];
let end = rest
.find("</tool_call>")
.ok_or_else(|| "incomplete GLM tool call".to_owned())?;
let body = &rest[..end];
let name_end = body.find("<arg_key>").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, "<arg_key>", "</arg_key>")?;
let value = between(&mut args, "<arg_value>", "</arg_value>")?;
arguments.insert(key.to_owned(), Value::String(value.to_owned()));
}
calls.push((name.to_owned(), Value::Object(arguments)));
rest = rest[end + "</tool_call>".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::<Vec<_>>();
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<usize, String> {
if needle.is_empty() {
return Err(format!("{label} is empty"));
}
let matches = data
.match_indices(needle)
.map(|(index, _)| index)
.collect::<Vec<_>>();
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::<f64>().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 = "done<tool_call>read<arg_key>path</arg_key><arg_value>src/main.rs</arg_value></tool_call>";
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<DSMLtool_calls><DSMLinvoke name=\"read\"><DSMLparameter name=\"path\" string=\"true\">src/main.rs</DSMLparameter></DSMLinvoke></DSMLtool_calls>";
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,
"<DSMLtool_calls><DSMLinvoke 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::<u32>()
.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<const N: usize>(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(),
}
}
}