fix: better handling of dev brain
This commit is contained in:
208
src/agent.rs
208
src/agent.rs
@@ -190,7 +190,7 @@ const TOOL_SCHEMAS: &str = r#"{"type":"function","function":{"name":"google_sear
|
||||
{"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.","parameters":{"type":"object","properties":{"query":{"type":"string"},"path":{"type":"string"},"mode":{"type":"string"},"glob":{"type":"string"},"context":{"type":"number"},"max_results":{"type":"number"},"case_sensitive":{"type":"boolean"}},"required":["query"]}}}
|
||||
{"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)]
|
||||
@@ -343,9 +343,9 @@ impl Tools {
|
||||
"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_read" => self.dev_brain_read(call),
|
||||
"dev_brain_publish" => self.dev_brain_publish(call),
|
||||
"dev_brain_validate" => self.dev_brain_validate(),
|
||||
name => Err(format!("unknown tool: {name}")),
|
||||
};
|
||||
match result {
|
||||
@@ -373,42 +373,19 @@ impl Tools {
|
||||
.search(query, limit, authoritative)
|
||||
}
|
||||
|
||||
fn dev_brain_read(&mut self, call: &ToolCall) -> Result<String, String> {
|
||||
let path = required_string(call, "path")?;
|
||||
let authoritative = boolean(call, "authoritative", false);
|
||||
self.dev_brain
|
||||
.as_mut()
|
||||
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())?
|
||||
.read(path, authoritative)
|
||||
.info())
|
||||
}
|
||||
|
||||
fn dev_brain_publish(&mut self, call: &ToolCall) -> Result<String, String> {
|
||||
let files = call
|
||||
.arguments
|
||||
.get("files")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| "dev_brain_publish requires a files object.".to_owned())?;
|
||||
let remove = call
|
||||
.arguments
|
||||
.get("remove")
|
||||
.map(|value| {
|
||||
value
|
||||
.as_array()
|
||||
.ok_or_else(|| "remove must be an array of paths.".to_owned())?
|
||||
.iter()
|
||||
.map(|path| {
|
||||
path.as_str()
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| "remove paths must be strings.".to_owned())
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
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())?
|
||||
.publish(files, &remove)
|
||||
.validate()
|
||||
}
|
||||
|
||||
fn result_limit(&self) -> usize {
|
||||
@@ -456,7 +433,7 @@ impl Tools {
|
||||
let path = path
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("open {value}: {error}"))?;
|
||||
self.inside_project(path, value)
|
||||
self.inside_readable_root(path, value)
|
||||
}
|
||||
|
||||
fn writable_path(&self, value: &str) -> Result<PathBuf, String> {
|
||||
@@ -472,7 +449,8 @@ impl Tools {
|
||||
self.root.join(value)
|
||||
};
|
||||
if fs::symlink_metadata(&path).is_ok() {
|
||||
return self.existing_path(value);
|
||||
let path = self.existing_path(value)?;
|
||||
return self.inside_writable_root(path, value);
|
||||
}
|
||||
let mut ancestor = path.as_path();
|
||||
let mut suffix = Vec::new();
|
||||
@@ -488,21 +466,54 @@ impl Tools {
|
||||
let mut resolved = ancestor
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("open ancestor of {value}: {error}"))?;
|
||||
self.inside_project(resolved.clone(), value)?;
|
||||
self.inside_readable_root(resolved.clone(), value)?;
|
||||
for name in suffix.into_iter().rev() {
|
||||
resolved.push(name);
|
||||
}
|
||||
Ok(resolved)
|
||||
self.inside_writable_root(resolved, value)
|
||||
}
|
||||
|
||||
fn inside_project(&self, path: PathBuf, original: &str) -> Result<PathBuf, String> {
|
||||
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 outside the project: {original}"))
|
||||
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,
|
||||
@@ -628,6 +639,16 @@ impl Tools {
|
||||
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()))
|
||||
}
|
||||
@@ -640,6 +661,7 @@ impl Tools {
|
||||
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"
|
||||
@@ -654,6 +676,7 @@ impl Tools {
|
||||
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",
|
||||
@@ -676,8 +699,15 @@ impl Tools {
|
||||
.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'
|
||||
@@ -713,7 +743,12 @@ impl Tools {
|
||||
context,
|
||||
limit,
|
||||
};
|
||||
search_path(&self.root, &path, &options)
|
||||
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> {
|
||||
@@ -948,9 +983,14 @@ struct SearchOptions<'a> {
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
fn search_path(root: &Path, path: &Path, options: &SearchOptions<'_>) -> Result<String, String> {
|
||||
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, &mut files)?;
|
||||
collect_search_files(path, 0, skip_hidden, &mut files)?;
|
||||
let mut matches = 0;
|
||||
let mut body = String::new();
|
||||
for file in files {
|
||||
@@ -991,6 +1031,7 @@ fn search_path(root: &Path, path: &Path, options: &SearchOptions<'_>) -> Result<
|
||||
fn collect_search_files(
|
||||
path: &Path,
|
||||
depth: usize,
|
||||
skip_hidden: bool,
|
||||
output: &mut Vec<PathBuf>,
|
||||
) -> Result<(), String> {
|
||||
if depth > 24 {
|
||||
@@ -1015,10 +1056,12 @@ fn collect_search_files(
|
||||
.collect::<Vec<_>>();
|
||||
entries.sort_by_key(|entry| entry.file_name());
|
||||
for entry in entries {
|
||||
if entry.file_name() == ".git" {
|
||||
if entry.file_name() == ".git"
|
||||
|| skip_hidden && entry.file_name().to_string_lossy().starts_with('.')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
collect_search_files(&entry.path(), depth + 1, output)?;
|
||||
collect_search_files(&entry.path(), depth + 1, skip_hidden, output)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1748,7 +1791,11 @@ mod tests {
|
||||
.contains("[System prompt reminder follows.]")
|
||||
);
|
||||
assert!(!prompt.contains("dev_brain_search"));
|
||||
assert!(system_prompt(ModelChoice::DeepSeekV4Flash, "", true).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));
|
||||
@@ -1809,6 +1856,77 @@ mod tests {
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user