Add hierarchical workspace instructions

This commit is contained in:
Georg Bauer
2026-08-29 21:57:11 +02:00
parent e35f57bc83
commit 1597ddbc11
11 changed files with 1431 additions and 213 deletions

View File

@@ -1233,12 +1233,17 @@ pub(crate) struct ToolCall {
}
pub(crate) struct ActiveTools {
pub(crate) results: Receiver<String>,
pub(crate) results: Receiver<ToolRunResult>,
pub(crate) events: Receiver<ToolEvent>,
pub(crate) cancel: Arc<AtomicBool>,
worker: Option<thread::JoinHandle<()>>,
}
pub(crate) struct ToolRunResult {
pub(crate) content: String,
pub(crate) touched_paths: Vec<PathBuf>,
}
impl Drop for ActiveTools {
fn drop(&mut self) {
self.cancel.store(true, Ordering::Relaxed);
@@ -2153,6 +2158,17 @@ impl Tools {
Ok(())
}
fn successful_touch(&self, tool: &ToolSpec, call: &ToolCall) -> Option<PathBuf> {
matches!(
tool.handler,
ToolHandler::Read | ToolHandler::Write | ToolHandler::Edit
)
.then(|| string(call, "path"))
.flatten()
.and_then(|path| self.existing_path(path).ok())
.filter(|path| path.starts_with(&self.root))
}
fn default_lines(&self) -> usize {
match self.context_tokens {
..=8192 => 120,
@@ -2896,6 +2912,7 @@ pub(crate) fn execute_async(
.name("agent-tools".into())
.spawn(move || {
let mut output = String::new();
let mut touched_paths = Vec::new();
let mut tools = tools
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
@@ -3024,6 +3041,12 @@ pub(crate) fn execute_async(
} else {
ToolLifecycle::Completed
};
if state == ToolLifecycle::Completed
&& let Some(path) = tools.successful_touch(tool, call)
&& !touched_paths.contains(&path)
{
touched_paths.push(path);
}
output.push_str(&result);
send_state(&event_sender, index, state, Some(result));
if !output.ends_with('\n') {
@@ -3035,7 +3058,10 @@ pub(crate) fn execute_async(
output.push_str(&format!("Tool warning: {failure}\n"));
}
}
let _ = sender.send(output);
let _ = sender.send(ToolRunResult {
content: output,
touched_paths,
});
})
.expect("agent tool worker must start");
ActiveTools {
@@ -3050,17 +3076,20 @@ 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!(
"{}Retry using the exact tool transport syntax from the system prompt.\n",
ToolFailure::new(
"<transport>",
"malformed_syntax",
"$",
"complete DSML or GLM tool call",
bounded_tool_text(&error, 512),
)
.render()
));
let _ = sender.send(ToolRunResult {
content: format!(
"{}Retry using the exact tool transport syntax from the system prompt.\n",
ToolFailure::new(
"<transport>",
"malformed_syntax",
"$",
"complete DSML or GLM tool call",
bounded_tool_text(&error, 512),
)
.render()
),
touched_paths: Vec::new(),
});
ActiveTools {
results,
events,
@@ -3198,7 +3227,7 @@ pub(crate) fn datetime_context() -> String {
)
}
pub(crate) fn try_tool_result(active: &ActiveTools) -> Result<Option<String>, String> {
pub(crate) fn try_tool_result(active: &ActiveTools) -> Result<Option<ToolRunResult>, String> {
match active.results.try_recv() {
Ok(result) => Ok(Some(result)),
Err(TryRecvError::Empty) => Ok(None),
@@ -3897,6 +3926,51 @@ mod tests {
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn successful_structured_file_tools_report_project_paths() {
let directory = std::env::temp_dir().join(format!(
"ds4-agent-touches-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let nested = directory.join("nested");
fs::create_dir_all(&nested).unwrap();
fs::write(nested.join("read.txt"), "read me").unwrap();
let tools = Arc::new(Mutex::new(Tools::new(&directory, 4096).unwrap()));
let active = execute_async(
tools,
vec![
call("read", [("path", "nested/read.txt")]),
call(
"write",
[("path", "nested/write.txt"), ("content", "before")],
),
call(
"edit",
[
("path", "nested/write.txt"),
("old_text", "before"),
("new_text", "after"),
],
),
call("read", [("path", "nested/missing.txt")]),
],
ShellApprovalMode::Heuristic,
);
let result = active.results.recv_timeout(Duration::from_secs(2)).unwrap();
assert_eq!(
result.touched_paths,
vec![
nested.join("read.txt").canonicalize().unwrap(),
nested.join("write.txt").canonicalize().unwrap(),
]
);
assert!(result.content.contains("code=execution_failed"));
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn model_adapters_preserve_primitives_and_report_recoverable_syntax_errors() {
let glm = "<tool_call>bash<arg_key>command</arg_key><arg_value>pwd</arg_value><arg_key>timeout_sec</arg_key><arg_value>3</arg_value></tool_call>";
@@ -3919,8 +3993,16 @@ mod tests {
let active = error_async("incomplete GLM tool call".into());
let result = active.results.recv_timeout(Duration::from_secs(1)).unwrap();
assert!(result.contains("tool=<transport> code=malformed_syntax"));
assert!(result.contains("Retry using the exact tool transport syntax"));
assert!(
result
.content
.contains("tool=<transport> code=malformed_syntax")
);
assert!(
result
.content
.contains("Retry using the exact tool transport syntax")
);
}
#[test]
@@ -4578,6 +4660,7 @@ mod tests {
.results
.recv_timeout(Duration::from_secs(2))
.unwrap()
.content
.contains("code=policy_denied")
);
assert!(directory.join("keep.txt").exists());
@@ -4605,6 +4688,7 @@ mod tests {
.results
.recv_timeout(Duration::from_secs(2))
.unwrap()
.content
.contains("interrupted")
);
fs::remove_dir_all(directory).unwrap();