From f0acaccaeb9d48fe63018204e5ea9b1088defd0b Mon Sep 17 00:00:00 2001 From: velga111 <191950256+velga111@users.noreply.github.com> Date: Tue, 26 May 2026 10:20:27 +0800 Subject: [PATCH] feat(apply-patch): add built-in apply_patch tool --- .../src-tauri/src/commands/built_in_tools.rs | 11 +- apps/desktop/src-tauri/src/commands/mod.rs | 1 + .../src/core/built_in_tools/apply_patch.rs | 816 +++++++++++++ .../src-tauri/src/core/built_in_tools/mod.rs | 7 +- .../src/core/built_in_tools/types.rs | 52 + .../src/database/artifacts/runtime/seed.sql | 11 + apps/desktop/src/database/queries/messages.ts | 5 + .../AgentService/contracts/tooling.ts | 1 + .../services/AgentService/prompt/builtin.ts | 3 + .../services/AgentService/session/history.ts | 2 +- .../task/projection/projection.ts | 2 +- .../services/BuiltInToolService/registry.ts | 2 + .../services/BuiltInToolService/service.ts | 3 +- .../tools/applyPatch/constants.ts | 62 + .../tools/applyPatch/helper.ts | 146 +++ .../tools/applyPatch/index.ts | 190 +++ .../tools/bash/constants.ts | 14 +- .../BuiltInToolService/tools/bash/helper.ts | 112 +- .../BuiltInToolService/tools/bash/index.ts | 33 +- .../src/services/BuiltInToolService/types.ts | 2 + .../services/NativeService/builtInTools.ts | 12 +- .../src/services/NativeService/index.ts | 5 + .../src/services/NativeService/types.ts | 30 + .../BuiltInApplyPatchToolCallItem.vue | 1035 +++++++++++++++++ .../components/ToolCallItem.vue | 5 +- .../components/BuiltInTools/types.ts | 5 + .../tools/applyPatch/helper.test.ts | 47 + .../tools/applyPatch/index.test.ts | 148 +++ .../tools/bash/constants.test.ts | 37 +- .../tools/bash/helper.test.ts | 74 +- .../tools/bash/index.test.ts | 64 +- .../tests/services/native-service.test.ts | 39 + .../BuiltInApplyPatchToolCallItem.test.ts | 107 ++ 33 files changed, 3067 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/src-tauri/src/core/built_in_tools/apply_patch.rs create mode 100644 apps/desktop/src/services/BuiltInToolService/tools/applyPatch/constants.ts create mode 100644 apps/desktop/src/services/BuiltInToolService/tools/applyPatch/helper.ts create mode 100644 apps/desktop/src/services/BuiltInToolService/tools/applyPatch/index.ts create mode 100644 apps/desktop/src/views/SearchView/components/ConversationPanel/components/BuiltInApplyPatchToolCallItem.vue create mode 100644 apps/desktop/tests/services/BuiltInToolService/tools/applyPatch/helper.test.ts create mode 100644 apps/desktop/tests/services/BuiltInToolService/tools/applyPatch/index.test.ts create mode 100644 apps/desktop/tests/views/SearchView/components/ConversationPanel/components/BuiltInApplyPatchToolCallItem.test.ts diff --git a/apps/desktop/src-tauri/src/commands/built_in_tools.rs b/apps/desktop/src-tauri/src/commands/built_in_tools.rs index 1826370d..8773e7eb 100644 --- a/apps/desktop/src-tauri/src/commands/built_in_tools.rs +++ b/apps/desktop/src-tauri/src/commands/built_in_tools.rs @@ -3,10 +3,19 @@ //! 内置工具原生命令。 use crate::core::built_in_tools::{ - self, BashExecutionRegistry, BuiltInBashExecutionRequest, BuiltInBashExecutionResponse, + self, BashExecutionRegistry, BuiltInApplyPatchExecutionRequest, + BuiltInApplyPatchExecutionResponse, BuiltInBashExecutionRequest, BuiltInBashExecutionResponse, }; use tauri::State; +/// Apply a structured patch in the configured workspace. +#[tauri::command] +pub fn built_in_tools_apply_patch( + request: BuiltInApplyPatchExecutionRequest, +) -> Result { + built_in_tools::apply_patch(request) +} + /// 执行内置 Bash 工具请求。 /// /// 命令层保持薄封装,避免把参数校验、平台分支和进程生命周期管理 diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index 75a11a9d..33bac816 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -52,6 +52,7 @@ pub fn invoke_handler( database::database_import_backup, paths::get_app_directory_path, paths::get_runtime_info, + built_in_tools::built_in_tools_apply_patch, built_in_tools::built_in_tools_execute_bash, built_in_tools::built_in_tools_cancel_bash, mcp::mcp_connect_server, diff --git a/apps/desktop/src-tauri/src/core/built_in_tools/apply_patch.rs b/apps/desktop/src-tauri/src/core/built_in_tools/apply_patch.rs new file mode 100644 index 00000000..176d1669 --- /dev/null +++ b/apps/desktop/src-tauri/src/core/built_in_tools/apply_patch.rs @@ -0,0 +1,816 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3. + +use std::{ + collections::HashMap, + fs, + path::{Component, Path, PathBuf}, +}; + +use super::types::{ + BuiltInApplyPatchExecutionRequest, BuiltInApplyPatchExecutionResponse, + BuiltInApplyPatchFileChange, BuiltInApplyPatchFilePreview, BuiltInApplyPatchOperation, +}; + +const MAX_PREVIEW_CHARS: usize = 4000; +const MAX_DELETE_PREVIEW_BYTES: u64 = 256 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PatchOperation { + Add { + path: String, + lines: Vec, + }, + Delete { + path: String, + }, + Update { + path: String, + move_to: Option, + hunks: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PatchHunk { + lines: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum HunkLine { + Context(String), + Remove(String), + Add(String), +} + +#[derive(Debug, Clone)] +struct ResolvedPatchPath { + absolute: PathBuf, + display: String, +} + +pub fn apply_patch( + request: BuiltInApplyPatchExecutionRequest, +) -> Result { + let root = resolve_workspace_root(&request.working_directory)?; + let operations = parse_patch(&request.patch)?; + if operations.is_empty() { + return Err("Patch contains no operations".to_string()); + } + + let mut staged_files = HashMap::>::new(); + let mut changed_files = Vec::::new(); + + for operation in operations { + match operation { + PatchOperation::Add { path, lines } => { + let target = resolve_patch_path(&root, &path)?; + if virtual_file_exists(&staged_files, &target.absolute) { + return Err(format!("Target file already exists: {}", target.display)); + } + + let next_content = format_added_file_content(&lines); + let preview = build_text_preview(None, Some(next_content.as_str())); + staged_files.insert(target.absolute, Some(next_content)); + changed_files.push(BuiltInApplyPatchFileChange { + path: target.display, + new_path: None, + operation: BuiltInApplyPatchOperation::Add, + preview: Some(preview), + }); + } + PatchOperation::Delete { path } => { + let target = resolve_patch_path(&root, &path)?; + ensure_virtual_file_exists(&staged_files, &target)?; + let preview = build_delete_preview(&staged_files, &target)?; + staged_files.insert(target.absolute, None); + changed_files.push(BuiltInApplyPatchFileChange { + path: target.display, + new_path: None, + operation: BuiltInApplyPatchOperation::Delete, + preview: Some(preview), + }); + } + PatchOperation::Update { + path, + move_to, + hunks, + } => { + let target = resolve_patch_path(&root, &path)?; + ensure_virtual_file_exists(&staged_files, &target)?; + let current_content = read_virtual_file(&staged_files, &target)?; + let next_content = apply_hunks(¤t_content, &hunks, &target.display)?; + let preview = + build_text_preview(Some(current_content.as_str()), Some(next_content.as_str())); + + if let Some(next_path) = move_to { + let destination = resolve_patch_path(&root, &next_path)?; + if destination.absolute != target.absolute + && virtual_file_exists(&staged_files, &destination.absolute) + { + return Err(format!( + "Move destination already exists: {}", + destination.display + )); + } + + staged_files.insert(target.absolute, None); + staged_files.insert(destination.absolute, Some(next_content)); + changed_files.push(BuiltInApplyPatchFileChange { + path: target.display, + new_path: Some(destination.display), + operation: BuiltInApplyPatchOperation::Move, + preview: Some(preview), + }); + } else { + staged_files.insert(target.absolute, Some(next_content)); + changed_files.push(BuiltInApplyPatchFileChange { + path: target.display, + new_path: None, + operation: BuiltInApplyPatchOperation::Update, + preview: Some(preview), + }); + } + } + } + } + + write_staged_files(&staged_files)?; + let working_directory = format_workspace_display_path(&root); + let summary = format_patch_summary(&working_directory, &changed_files); + + Ok(BuiltInApplyPatchExecutionResponse { + success: true, + working_directory, + changed_files, + summary, + }) +} + +fn parse_patch(input: &str) -> Result, String> { + let normalized = input.replace("\r\n", "\n").replace('\r', "\n"); + let mut lines: Vec<&str> = normalized.split('\n').collect(); + while lines.last() == Some(&"") { + lines.pop(); + } + + if lines.first() != Some(&"*** Begin Patch") { + return Err("Invalid patch: expected '*** Begin Patch'".to_string()); + } + + if lines.last() != Some(&"*** End Patch") { + return Err("Invalid patch: expected '*** End Patch'".to_string()); + } + + let mut operations = Vec::new(); + let mut index = 1; + while index + 1 < lines.len() { + let line = lines[index]; + if let Some(path) = line.strip_prefix("*** Add File: ") { + index += 1; + let mut content = Vec::new(); + while index + 1 < lines.len() && !is_operation_marker(lines[index]) { + let content_line = lines[index]; + let Some(line_content) = content_line.strip_prefix('+') else { + return Err(format!( + "Invalid Add File line for {}: expected '+' prefix", + path + )); + }; + content.push(line_content.to_string()); + index += 1; + } + operations.push(PatchOperation::Add { + path: path.trim().to_string(), + lines: content, + }); + continue; + } + + if let Some(path) = line.strip_prefix("*** Delete File: ") { + operations.push(PatchOperation::Delete { + path: path.trim().to_string(), + }); + index += 1; + continue; + } + + if let Some(path) = line.strip_prefix("*** Update File: ") { + index += 1; + let mut move_to = None; + if index + 1 < lines.len() { + if let Some(next_path) = lines[index].strip_prefix("*** Move to: ") { + move_to = Some(next_path.trim().to_string()); + index += 1; + } + } + + let mut hunks = Vec::new(); + while index + 1 < lines.len() && !is_operation_marker(lines[index]) { + let hunk_header = lines[index]; + if !hunk_header.starts_with("@@") { + return Err(format!( + "Invalid Update File block for {}: expected '@@' hunk header", + path + )); + } + index += 1; + + let mut hunk_lines = Vec::new(); + while index + 1 < lines.len() + && !is_operation_marker(lines[index]) + && !lines[index].starts_with("@@") + { + let hunk_line = lines[index]; + if hunk_line == "*** End of File" { + index += 1; + continue; + } + + let Some(prefix) = hunk_line.chars().next() else { + return Err(format!( + "Invalid hunk line for {}: empty lines must use a prefix", + path + )); + }; + let text = hunk_line[prefix.len_utf8()..].to_string(); + match prefix { + ' ' => hunk_lines.push(HunkLine::Context(text)), + '-' => hunk_lines.push(HunkLine::Remove(text)), + '+' => hunk_lines.push(HunkLine::Add(text)), + _ => { + return Err(format!( + "Invalid hunk line for {}: expected ' ', '-' or '+' prefix", + path + )); + } + } + index += 1; + } + + if hunk_lines.is_empty() { + return Err(format!("Invalid hunk for {}: hunk is empty", path)); + } + hunks.push(PatchHunk { lines: hunk_lines }); + } + + if move_to.is_none() && hunks.is_empty() { + return Err(format!( + "Invalid Update File block for {}: missing hunks", + path + )); + } + + operations.push(PatchOperation::Update { + path: path.trim().to_string(), + move_to, + hunks, + }); + continue; + } + + return Err(format!("Invalid patch operation header: {}", line)); + } + + Ok(operations) +} + +fn is_operation_marker(line: &str) -> bool { + line.starts_with("*** Add File: ") + || line.starts_with("*** Update File: ") + || line.starts_with("*** Delete File: ") +} + +fn resolve_workspace_root(working_directory: &str) -> Result { + let trimmed = working_directory.trim(); + if trimmed.is_empty() { + return Err("ApplyPatch requires a workingDirectory".to_string()); + } + + fs::canonicalize(trimmed) + .map_err(|error| format!("Failed to resolve workingDirectory {}: {}", trimmed, error)) +} + +fn resolve_patch_path(root: &Path, raw_path: &str) -> Result { + let relative_path = validate_relative_patch_path(raw_path)?; + let candidate = root.join(&relative_path); + + let boundary_path = if candidate.exists() { + candidate.as_path() + } else { + nearest_existing_parent(&candidate).ok_or_else(|| { + format!( + "No existing parent directory inside workspace for path: {}", + format_display_path(raw_path) + ) + })? + }; + let canonical_boundary = fs::canonicalize(boundary_path).map_err(|error| { + format!( + "Failed to resolve path boundary for {}: {}", + format_display_path(raw_path), + error + ) + })?; + + if !canonical_boundary.starts_with(root) { + return Err(format!( + "Unsafe path outside workspace: {}", + format_display_path(raw_path) + )); + } + + let absolute = if candidate.exists() { + fs::canonicalize(&candidate).map_err(|error| { + format!( + "Failed to resolve path {}: {}", + format_display_path(raw_path), + error + ) + })? + } else { + candidate + }; + + if absolute.exists() { + let metadata = fs::metadata(&absolute).map_err(|error| { + format!( + "Failed to inspect path {}: {}", + format_display_path(raw_path), + error + ) + })?; + if metadata.is_dir() { + return Err(format!( + "Patch path points to a directory, not a file: {}", + format_display_path(raw_path) + )); + } + } + + Ok(ResolvedPatchPath { + absolute, + display: format_display_path(raw_path), + }) +} + +fn validate_relative_patch_path(raw_path: &str) -> Result { + let trimmed = raw_path.trim(); + if trimmed.is_empty() { + return Err("Patch path cannot be empty".to_string()); + } + + let path = Path::new(trimmed); + if path.is_absolute() { + return Err(format!( + "Patch paths must be relative to workingDirectory: {}", + format_display_path(trimmed) + )); + } + + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(segment) => normalized.push(segment), + Component::CurDir => {} + Component::ParentDir | Component::Prefix(_) | Component::RootDir => { + return Err(format!( + "Unsafe patch path outside workspace: {}", + format_display_path(trimmed) + )); + } + } + } + + if normalized.as_os_str().is_empty() { + return Err("Patch path cannot be empty".to_string()); + } + + Ok(normalized) +} + +fn nearest_existing_parent(path: &Path) -> Option<&Path> { + let mut current = path.parent(); + while let Some(parent) = current { + if parent.exists() { + return Some(parent); + } + current = parent.parent(); + } + None +} + +fn format_display_path(path: &str) -> String { + path.trim().replace('\\', "/") +} + +fn format_workspace_display_path(path: &Path) -> String { + normalize_windows_verbatim_path(&path.to_string_lossy()) +} + +fn normalize_windows_verbatim_path(path: &str) -> String { + if let Some(rest) = path.strip_prefix(r"\\?\UNC\") { + return format!(r"\\{}", rest); + } + + if let Some(rest) = path.strip_prefix(r"\\?\") { + return rest.to_string(); + } + + path.to_string() +} + +fn virtual_file_exists(staged_files: &HashMap>, path: &Path) -> bool { + match staged_files.get(path) { + Some(Some(_)) => true, + Some(None) => false, + None => path.exists(), + } +} + +fn ensure_virtual_file_exists( + staged_files: &HashMap>, + target: &ResolvedPatchPath, +) -> Result<(), String> { + if virtual_file_exists(staged_files, &target.absolute) { + return Ok(()); + } + + Err(format!("Target file not found: {}", target.display)) +} + +fn read_virtual_file( + staged_files: &HashMap>, + target: &ResolvedPatchPath, +) -> Result { + match staged_files.get(&target.absolute) { + Some(Some(content)) => Ok(content.clone()), + Some(None) => Err(format!("Target file not found: {}", target.display)), + None => fs::read_to_string(&target.absolute) + .map_err(|error| format!("Failed to read {}: {}", target.display, error)), + } +} + +fn build_delete_preview( + staged_files: &HashMap>, + target: &ResolvedPatchPath, +) -> Result { + if let Some(Some(content)) = staged_files.get(&target.absolute) { + return Ok(build_text_preview(Some(content.as_str()), None)); + } + + let metadata = fs::metadata(&target.absolute) + .map_err(|error| format!("Failed to inspect {}: {}", target.display, error))?; + if metadata.len() > MAX_DELETE_PREVIEW_BYTES { + return Ok(BuiltInApplyPatchFilePreview { + before_content: None, + after_content: None, + before_truncated: false, + after_truncated: false, + is_binary: false, + omitted: true, + }); + } + + let bytes = fs::read(&target.absolute) + .map_err(|error| format!("Failed to read {}: {}", target.display, error))?; + if bytes.contains(&0) { + return Ok(BuiltInApplyPatchFilePreview { + before_content: None, + after_content: None, + before_truncated: false, + after_truncated: false, + is_binary: true, + omitted: false, + }); + } + + match String::from_utf8(bytes) { + Ok(content) => Ok(build_text_preview(Some(content.as_str()), None)), + Err(_) => Ok(BuiltInApplyPatchFilePreview { + before_content: None, + after_content: None, + before_truncated: false, + after_truncated: false, + is_binary: true, + omitted: false, + }), + } +} + +fn build_text_preview(before: Option<&str>, after: Option<&str>) -> BuiltInApplyPatchFilePreview { + let (before_content, before_truncated) = truncate_preview_text(before); + let (after_content, after_truncated) = truncate_preview_text(after); + + BuiltInApplyPatchFilePreview { + before_content, + after_content, + before_truncated, + after_truncated, + is_binary: false, + omitted: false, + } +} + +fn truncate_preview_text(content: Option<&str>) -> (Option, bool) { + let Some(content) = content else { + return (None, false); + }; + + let total_chars = content.chars().count(); + if total_chars <= MAX_PREVIEW_CHARS { + return (Some(content.to_string()), false); + } + + ( + Some(content.chars().take(MAX_PREVIEW_CHARS).collect()), + true, + ) +} + +fn format_added_file_content(lines: &[String]) -> String { + if lines.is_empty() { + String::new() + } else { + format!("{}\n", lines.join("\n")) + } +} + +fn apply_hunks(content: &str, hunks: &[PatchHunk], display_path: &str) -> Result { + if hunks.is_empty() { + return Ok(content.to_string()); + } + + let (mut lines, has_final_newline) = split_content_lines(content); + let mut search_start = 0; + + for hunk in hunks { + let old_lines = hunk_old_lines(hunk); + if old_lines.is_empty() { + return Err(format!( + "Invalid hunk for {}: at least one context or removed line is required", + display_path + )); + } + + let new_lines = hunk_new_lines(hunk); + let new_len = new_lines.len(); + let Some(match_index) = find_sequence(&lines, &old_lines, search_start) + .or_else(|| find_sequence(&lines, &old_lines, 0)) + else { + return Err(format!("Hunk context not found in {}", display_path)); + }; + + lines.splice(match_index..match_index + old_lines.len(), new_lines); + search_start = match_index + new_len; + } + + Ok(join_content_lines(&lines, has_final_newline)) +} + +fn hunk_old_lines(hunk: &PatchHunk) -> Vec { + hunk.lines + .iter() + .filter_map(|line| match line { + HunkLine::Context(text) | HunkLine::Remove(text) => Some(text.clone()), + HunkLine::Add(_) => None, + }) + .collect() +} + +fn hunk_new_lines(hunk: &PatchHunk) -> Vec { + hunk.lines + .iter() + .filter_map(|line| match line { + HunkLine::Context(text) | HunkLine::Add(text) => Some(text.clone()), + HunkLine::Remove(_) => None, + }) + .collect() +} + +fn split_content_lines(content: &str) -> (Vec, bool) { + let normalized = content.replace("\r\n", "\n").replace('\r', "\n"); + let has_final_newline = normalized.ends_with('\n'); + let body = normalized.strip_suffix('\n').unwrap_or(&normalized); + if body.is_empty() { + return (Vec::new(), has_final_newline); + } + + ( + body.split('\n').map(|line| line.to_string()).collect(), + has_final_newline, + ) +} + +fn join_content_lines(lines: &[String], has_final_newline: bool) -> String { + let mut content = lines.join("\n"); + if has_final_newline && !content.is_empty() { + content.push('\n'); + } + content +} + +fn find_sequence(lines: &[String], needle: &[String], start: usize) -> Option { + if needle.is_empty() || needle.len() > lines.len() { + return None; + } + + let max_start = lines.len() - needle.len(); + let start_index = start.min(max_start); + (start_index..=max_start).find(|index| lines[*index..*index + needle.len()] == *needle) +} + +fn write_staged_files(staged_files: &HashMap>) -> Result<(), String> { + for (path, content) in staged_files { + if let Some(content) = content { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "Failed to create parent directory for {:?}: {}", + path, error + ) + })?; + } + fs::write(path, content) + .map_err(|error| format!("Failed to write {:?}: {}", path, error))?; + } + } + + for (path, content) in staged_files { + if content.is_none() && path.exists() { + fs::remove_file(path) + .map_err(|error| format!("Failed to delete {:?}: {}", path, error))?; + } + } + + Ok(()) +} + +fn format_patch_summary( + working_directory: &str, + changes: &[BuiltInApplyPatchFileChange], +) -> String { + let mut lines = vec![format!("已在 {} 应用补丁", working_directory)]; + for change in changes { + match change.operation { + BuiltInApplyPatchOperation::Move => lines.push(format!( + "- 移动 {} 到 {}", + change.path, + change.new_path.as_deref().unwrap_or("") + )), + BuiltInApplyPatchOperation::Add => lines.push(format!("- 新增 {}", change.path)), + BuiltInApplyPatchOperation::Update => lines.push(format!("- 修改 {}", change.path)), + BuiltInApplyPatchOperation::Delete => lines.push(format!("- 删除 {}", change.path)), + } + } + lines.join("\n") +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::*; + + fn request(root: &Path, patch: &str) -> BuiltInApplyPatchExecutionRequest { + BuiltInApplyPatchExecutionRequest { + patch: patch.to_string(), + working_directory: root.to_string_lossy().to_string(), + } + } + + #[test] + fn adds_file() { + let temp = tempdir().unwrap(); + let response = apply_patch(request( + temp.path(), + "*** Begin Patch\n*** Add File: src/new.txt\n+hello\n+world\n*** End Patch", + )) + .unwrap(); + + assert_eq!( + fs::read_to_string(temp.path().join("src/new.txt")).unwrap(), + "hello\nworld\n" + ); + let preview = response.changed_files[0].preview.as_ref().unwrap(); + assert_eq!(preview.before_content, None); + assert_eq!(preview.after_content.as_deref(), Some("hello\nworld\n")); + } + + #[test] + fn updates_file() { + let temp = tempdir().unwrap(); + fs::write(temp.path().join("file.txt"), "one\ntwo\nthree\n").unwrap(); + + let response = apply_patch(request( + temp.path(), + "*** Begin Patch\n*** Update File: file.txt\n@@\n one\n-two\n+TWO\n three\n*** End Patch", + )) + .unwrap(); + + assert_eq!( + fs::read_to_string(temp.path().join("file.txt")).unwrap(), + "one\nTWO\nthree\n" + ); + let preview = response.changed_files[0].preview.as_ref().unwrap(); + assert_eq!(preview.before_content.as_deref(), Some("one\ntwo\nthree\n")); + assert_eq!(preview.after_content.as_deref(), Some("one\nTWO\nthree\n")); + } + + #[test] + fn deletes_file() { + let temp = tempdir().unwrap(); + fs::write(temp.path().join("file.txt"), "delete me\n").unwrap(); + + let response = apply_patch(request( + temp.path(), + "*** Begin Patch\n*** Delete File: file.txt\n*** End Patch", + )) + .unwrap(); + + assert!(!temp.path().join("file.txt").exists()); + let preview = response.changed_files[0].preview.as_ref().unwrap(); + assert_eq!(preview.before_content.as_deref(), Some("delete me\n")); + assert_eq!(preview.after_content, None); + } + + #[test] + fn moves_file() { + let temp = tempdir().unwrap(); + fs::write(temp.path().join("old.txt"), "old\n").unwrap(); + + let response = apply_patch(request( + temp.path(), + "*** Begin Patch\n*** Update File: old.txt\n*** Move to: nested/new.txt\n*** End Patch", + )) + .unwrap(); + + assert!(!temp.path().join("old.txt").exists()); + assert_eq!( + fs::read_to_string(temp.path().join("nested/new.txt")).unwrap(), + "old\n" + ); + let preview = response.changed_files[0].preview.as_ref().unwrap(); + assert_eq!(preview.before_content.as_deref(), Some("old\n")); + assert_eq!(preview.after_content.as_deref(), Some("old\n")); + } + + #[test] + fn rejects_invalid_syntax() { + let temp = tempdir().unwrap(); + let error = + apply_patch(request(temp.path(), "*** Begin Patch\nbad\n*** End Patch")).unwrap_err(); + + assert!(error.contains("Invalid patch operation header")); + } + + #[test] + fn rejects_missing_update_target() { + let temp = tempdir().unwrap(); + let error = apply_patch(request( + temp.path(), + "*** Begin Patch\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch", + )) + .unwrap_err(); + + assert!(error.contains("Target file not found")); + } + + #[test] + fn rejects_conflicting_hunk() { + let temp = tempdir().unwrap(); + fs::write(temp.path().join("file.txt"), "actual\n").unwrap(); + + let error = apply_patch(request( + temp.path(), + "*** Begin Patch\n*** Update File: file.txt\n@@\n-expected\n+new\n*** End Patch", + )) + .unwrap_err(); + + assert!(error.contains("Hunk context not found")); + } + + #[test] + fn rejects_unsafe_parent_path() { + let temp = tempdir().unwrap(); + let error = apply_patch(request( + temp.path(), + "*** Begin Patch\n*** Add File: ../outside.txt\n+nope\n*** End Patch", + )) + .unwrap_err(); + + assert!(error.contains("Unsafe patch path")); + } + + #[test] + fn normalizes_windows_verbatim_display_path() { + assert_eq!(normalize_windows_verbatim_path(r"\\?\E:\Temp"), r"E:\Temp"); + } + + #[test] + fn normalizes_windows_verbatim_unc_display_path() { + assert_eq!( + normalize_windows_verbatim_path(r"\\?\UNC\server\share\repo"), + r"\\server\share\repo" + ); + } +} diff --git a/apps/desktop/src-tauri/src/core/built_in_tools/mod.rs b/apps/desktop/src-tauri/src/core/built_in_tools/mod.rs index 37e8dba0..27f11782 100644 --- a/apps/desktop/src-tauri/src/core/built_in_tools/mod.rs +++ b/apps/desktop/src-tauri/src/core/built_in_tools/mod.rs @@ -2,12 +2,17 @@ //! 内置工具原生能力。 +mod apply_patch; mod bash; #[cfg(target_os = "windows")] mod process_utils; mod registry; mod types; +pub use apply_patch::apply_patch; pub use bash::execute_bash; pub use registry::{BashExecutionRegistry, BuiltInProcessExecutionRegistry}; -pub use types::{BuiltInBashExecutionRequest, BuiltInBashExecutionResponse}; +pub use types::{ + BuiltInApplyPatchExecutionRequest, BuiltInApplyPatchExecutionResponse, + BuiltInBashExecutionRequest, BuiltInBashExecutionResponse, +}; diff --git a/apps/desktop/src-tauri/src/core/built_in_tools/types.rs b/apps/desktop/src-tauri/src/core/built_in_tools/types.rs index 8159b1ea..b8843b46 100644 --- a/apps/desktop/src-tauri/src/core/built_in_tools/types.rs +++ b/apps/desktop/src-tauri/src/core/built_in_tools/types.rs @@ -4,6 +4,58 @@ use serde::{Deserialize, Serialize}; +/// Built-in ApplyPatch tool request. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltInApplyPatchExecutionRequest { + /// Patch text using the supported apply_patch grammar. + pub patch: String, + /// Workspace root used to resolve relative patch paths. + pub working_directory: String, +} + +/// File changed by a built-in ApplyPatch request. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltInApplyPatchFilePreview { + pub before_content: Option, + pub after_content: Option, + pub before_truncated: bool, + pub after_truncated: bool, + pub is_binary: bool, + pub omitted: bool, +} + +/// File changed by a built-in ApplyPatch request. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltInApplyPatchFileChange { + pub path: String, + pub new_path: Option, + pub operation: BuiltInApplyPatchOperation, + pub preview: Option, +} + +/// Supported built-in ApplyPatch operations. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BuiltInApplyPatchOperation { + Add, + Update, + Delete, + Move, +} + +/// Built-in ApplyPatch tool response. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BuiltInApplyPatchExecutionResponse { + pub success: bool, + pub working_directory: String, + pub changed_files: Vec, + pub summary: String, +} + /// 内置 Bash 工具的执行请求。 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/apps/desktop/src/database/artifacts/runtime/seed.sql b/apps/desktop/src/database/artifacts/runtime/seed.sql index de27a2c4..dc7c5518 100644 --- a/apps/desktop/src/database/artifacts/runtime/seed.sql +++ b/apps/desktop/src/database/artifacts/runtime/seed.sql @@ -101,6 +101,17 @@ SELECT '{"approvalMode":"high_risk","timeoutMs":15000,"maxOutputChars":12000}' WHERE NOT EXISTS (SELECT 1 FROM built_in_tools WHERE tool_id = 'bash'); +INSERT INTO built_in_tools ( + tool_id, display_name, description, enabled, risk_level, config_json +) +SELECT 'apply_patch', 'ApplyPatch', '使用补丁语法修改本地文件', 1, 'high', NULL +WHERE NOT EXISTS (SELECT 1 FROM built_in_tools WHERE tool_id = 'apply_patch'); + +UPDATE built_in_tools +SET description = '使用补丁语法修改本地文件' +WHERE tool_id = 'apply_patch' + AND (description IS NULL OR description = 'Apply structured file edits with patch syntax'); + INSERT INTO built_in_tools ( tool_id, display_name, description, enabled, risk_level, config_json ) diff --git a/apps/desktop/src/database/queries/messages.ts b/apps/desktop/src/database/queries/messages.ts index fb78ea85..d72127d6 100644 --- a/apps/desktop/src/database/queries/messages.ts +++ b/apps/desktop/src/database/queries/messages.ts @@ -14,6 +14,7 @@ export interface ToolLogHistoryRow { tool_call_id: string; tool_name: string; tool_input: string; + tool_output: string | null; message_id: number | null; created_at: string; tool_status: PersistedToolLogStatus; @@ -26,6 +27,7 @@ export interface MessageRow extends MessageEntity { tool_call_id: string | null; tool_name: string | null; tool_input: string | null; + tool_output: string | null; tool_log_ref_id: number | null; tool_status: PersistedToolLogStatus | null; tool_duration_ms: number | null; @@ -57,6 +59,7 @@ function buildMessageRow( tool_call_id: toolLog?.tool_call_id ?? null, tool_name: toolLog ? toNamespacedToolName(toolLog) : null, tool_input: toolLog?.tool_input ?? null, + tool_output: toolLog?.tool_output ?? null, tool_log_ref_id: toolLog?.log_id ?? null, tool_status: toolLog?.tool_status ?? null, tool_duration_ms: toolLog?.tool_duration_ms ?? null, @@ -208,6 +211,7 @@ export const findToolLogRowsBySessionId = async ( tool_call_id: mcpToolLogs.tool_call_id, tool_name: mcpToolLogs.tool_name, tool_input: mcpToolLogs.input, + tool_output: mcpToolLogs.output, message_id: mcpToolLogs.message_id, created_at: mcpToolLogs.created_at, tool_status: mcpToolLogs.status, @@ -223,6 +227,7 @@ export const findToolLogRowsBySessionId = async ( tool_call_id: builtInToolLogs.tool_call_id, tool_name: builtInToolLogs.tool_id, tool_input: builtInToolLogs.input, + tool_output: builtInToolLogs.output, message_id: builtInToolLogs.message_id, created_at: builtInToolLogs.created_at, tool_status: builtInToolLogs.status, diff --git a/apps/desktop/src/services/AgentService/contracts/tooling.ts b/apps/desktop/src/services/AgentService/contracts/tooling.ts index 4be3d2be..0c1b1e6c 100644 --- a/apps/desktop/src/services/AgentService/contracts/tooling.ts +++ b/apps/desktop/src/services/AgentService/contracts/tooling.ts @@ -122,6 +122,7 @@ export type ToolEvent = type: 'call_end'; callId: string; result: string; + displayResult?: string; isError: boolean; durationMs: number; finalStatus?: 'completed' | 'error' | 'rejected'; diff --git a/apps/desktop/src/services/AgentService/prompt/builtin.ts b/apps/desktop/src/services/AgentService/prompt/builtin.ts index 16c9ccac..3ee008be 100644 --- a/apps/desktop/src/services/AgentService/prompt/builtin.ts +++ b/apps/desktop/src/services/AgentService/prompt/builtin.ts @@ -36,6 +36,9 @@ You and the user share the same machine and the same workspace. Your job is not # Tool Use Discipline - Use tools to inspect reality. Read files when you need file contents. Search when you need search results. Run commands when you need command output. Fetch pages when you need external content. +- For local workspace file mutations, use ApplyPatch by default. File mutations include creating, editing, deleting, renaming, or moving files. +- Use Bash, Read, and FileSearch to inspect files and verify results. Do not use Bash or shell redirection/cmdlets to mutate local workspace files unless the user explicitly asks for shell-based file operations. +- If a Bash file mutation is blocked, retry the change with ApplyPatch instead of trying another shell write command. - Do not say you “checked”, “read”, “ran”, “verified”, “searched”, “looked up”, or “confirmed” something unless you actually did. - Do not fabricate command output, file contents, web content, search results, image contents, test results, system state, path existence, or generated artifacts. - If a tool result is incomplete, unclear, stale, or failed, say so and continue with the best verifiable next step. diff --git a/apps/desktop/src/services/AgentService/session/history.ts b/apps/desktop/src/services/AgentService/session/history.ts index 770cab25..c0fc3e4c 100644 --- a/apps/desktop/src/services/AgentService/session/history.ts +++ b/apps/desktop/src/services/AgentService/session/history.ts @@ -269,7 +269,7 @@ async function buildPersistedEntries( const restoredStatus = mapPersistedToolResultStatus(row.tool_status, row.content); currentEntry.toolResult = { callId: row.tool_call_id, - result: row.content, + result: row.tool_output ?? row.content, status: restoredStatus, durationMs: row.tool_duration_ms ?? undefined, isError: isPersistedToolResultErrorStatus(row.tool_status, row.content), diff --git a/apps/desktop/src/services/AgentService/task/projection/projection.ts b/apps/desktop/src/services/AgentService/task/projection/projection.ts index 1e232ab7..c2eaf097 100644 --- a/apps/desktop/src/services/AgentService/task/projection/projection.ts +++ b/apps/desktop/src/services/AgentService/task/projection/projection.ts @@ -738,7 +738,7 @@ export class SessionTaskProjection { message.id, toolEvent.callId, (toolCall) => { - toolCall.result = toolEvent.result; + toolCall.result = toolEvent.displayResult || toolEvent.result; toolCall.isError = toolEvent.isError; if (toolEvent.finalStatus === 'rejected') { toolCall.status = 'rejected'; diff --git a/apps/desktop/src/services/BuiltInToolService/registry.ts b/apps/desktop/src/services/BuiltInToolService/registry.ts index 234253f1..0cff7d7c 100644 --- a/apps/desktop/src/services/BuiltInToolService/registry.ts +++ b/apps/desktop/src/services/BuiltInToolService/registry.ts @@ -1,5 +1,6 @@ // Copyright (c) 2026. 千诚. Licensed under GPL v3 +import { builtInTools as applyPatchTools } from './tools/applyPatch'; import { builtInTools as bashTools } from './tools/bash'; import { builtInTools as fileSearchTools } from './tools/fileSearch'; import { builtInTools as readTools } from './tools/read'; @@ -53,6 +54,7 @@ class BuiltInToolRegistry { export const builtInToolRegistry = new BuiltInToolRegistry(); builtInToolRegistry.register(bashTools); +builtInToolRegistry.register(applyPatchTools); builtInToolRegistry.register(fileSearchTools); builtInToolRegistry.register(readTools); builtInToolRegistry.register(settingTools); diff --git a/apps/desktop/src/services/BuiltInToolService/service.ts b/apps/desktop/src/services/BuiltInToolService/service.ts index c8534d4e..9835b57a 100644 --- a/apps/desktop/src/services/BuiltInToolService/service.ts +++ b/apps/desktop/src/services/BuiltInToolService/service.ts @@ -437,13 +437,14 @@ class BuiltInToolService { type: 'call_end', callId: options.toolCall.id, result: toolResult.result, + ...(toolResult.displayResult ? { displayResult: toolResult.displayResult } : {}), isError: toolResult.isError, durationMs, finalStatus: toolResult.status === 'success' ? 'completed' : 'error', }); await updateBuiltInToolLogByCallId(options.toolCall.id, { - output: toolResult.result, + output: toolResult.displayResult ?? toolResult.result, status: toolResult.status === 'success' ? 'success' diff --git a/apps/desktop/src/services/BuiltInToolService/tools/applyPatch/constants.ts b/apps/desktop/src/services/BuiltInToolService/tools/applyPatch/constants.ts new file mode 100644 index 00000000..d786bc8a --- /dev/null +++ b/apps/desktop/src/services/BuiltInToolService/tools/applyPatch/constants.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +import type { AiToolDefinition } from '@/services/AgentService/contracts/tooling'; + +import { + nonEmptyTrimmedStringSchema, + optionalTrimmedStringSchema, + z, +} from '../../utils/toolSchema'; + +export const APPLY_PATCH_TOOL_NAME = 'ApplyPatch'; + +export const applyPatchArgsSchema = z.object({ + patch: nonEmptyTrimmedStringSchema, + workingDirectory: nonEmptyTrimmedStringSchema, + reason: optionalTrimmedStringSchema, + description: optionalTrimmedStringSchema, +}); + +export const APPLY_PATCH_TOOL_DESCRIPTION = [ + 'Apply structured, reviewable file edits inside a local workspace.', + 'When the user asks to create, edit, delete, rename, or move local workspace files, use this tool by default.', + 'Use this for coding changes and other local file mutations instead of shell-driven file mutation.', + 'Use Bash, Read, and FileSearch only to inspect files or verify results unless the user explicitly requests shell-based file operations.', + 'The patch must use the supported apply_patch grammar with *** Begin Patch and *** End Patch markers.', + 'Paths must be relative to workingDirectory. Absolute paths and parent-directory traversal are rejected.', + 'Supported operations: Add File, Update File, Delete File, and Update File with Move to.', +].join(' '); + +export const APPLY_PATCH_TOOL_INPUT_SCHEMA: AiToolDefinition['input_schema'] = { + type: 'object', + properties: { + patch: { + type: 'string', + description: [ + 'Patch text using this grammar:', + '*** Begin Patch', + '*** Add File: path', + '+new line', + '*** Update File: path', + '*** Move to: new/path', + '@@', + ' context line', + '-old line', + '+new line', + '*** Delete File: path', + '*** End Patch', + ].join('\n'), + }, + workingDirectory: { + type: 'string', + description: + 'Required workspace root. Every patch path is resolved relative to this directory.', + }, + reason: { + type: 'string', + description: + 'Required user-facing explanation for approval: explain what files will change and why.', + }, + }, + required: ['patch', 'workingDirectory', 'reason'], +}; diff --git a/apps/desktop/src/services/BuiltInToolService/tools/applyPatch/helper.ts b/apps/desktop/src/services/BuiltInToolService/tools/applyPatch/helper.ts new file mode 100644 index 00000000..645141f4 --- /dev/null +++ b/apps/desktop/src/services/BuiltInToolService/tools/applyPatch/helper.ts @@ -0,0 +1,146 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +import type { + BuiltInApplyPatchExecutionResponse, + BuiltInApplyPatchFileChange, + BuiltInApplyPatchFilePreview, +} from '@services/NativeService'; + +const APPLY_PATCH_RESULT_START = '<< ({ + ...change, + preview: normalizePreview(change.preview), + })), + }; + + const payloadText = JSON.stringify(payload); + const summary = response.summary.trim(); + if (!summary) { + return [APPLY_PATCH_RESULT_START, payloadText, APPLY_PATCH_RESULT_END].join('\n'); + } + + return [summary, '', APPLY_PATCH_RESULT_START, payloadText, APPLY_PATCH_RESULT_END].join('\n'); +} + +export function parseApplyPatchToolResult(result?: string): ParsedApplyPatchToolResult { + const raw = result?.trim(); + if (!raw) { + return { + summary: null, + workingDirectory: null, + changedFiles: [], + }; + } + + const startIndex = raw.indexOf(APPLY_PATCH_RESULT_START); + if (startIndex < 0) { + return { + summary: raw, + workingDirectory: null, + changedFiles: [], + }; + } + + const endIndex = raw.indexOf( + APPLY_PATCH_RESULT_END, + startIndex + APPLY_PATCH_RESULT_START.length + ); + if (endIndex < 0) { + return { + summary: raw, + workingDirectory: null, + changedFiles: [], + }; + } + + const summary = raw.slice(0, startIndex).trim() || null; + const payloadText = raw.slice(startIndex + APPLY_PATCH_RESULT_START.length, endIndex).trim(); + + try { + const payload = JSON.parse(payloadText) as unknown; + if (!isApplyPatchToolResultPayload(payload)) { + return { + summary: raw, + workingDirectory: null, + changedFiles: [], + }; + } + + return { + summary, + workingDirectory: payload.workingDirectory, + changedFiles: payload.changedFiles.map((change) => ({ + ...change, + preview: normalizePreview(change.preview), + })), + }; + } catch { + return { + summary: raw, + workingDirectory: null, + changedFiles: [], + }; + } +} + +function isApplyPatchToolResultPayload(value: unknown): value is ApplyPatchToolResultPayload { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as Partial; + return ( + typeof candidate.workingDirectory === 'string' && + Array.isArray(candidate.changedFiles) && + candidate.changedFiles.every(isApplyPatchFileChange) + ); +} + +function isApplyPatchFileChange(value: unknown): value is BuiltInApplyPatchFileChange { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as Partial; + return ( + typeof candidate.path === 'string' && + (candidate.newPath === null || typeof candidate.newPath === 'string') && + (candidate.operation === 'add' || + candidate.operation === 'update' || + candidate.operation === 'delete' || + candidate.operation === 'move') + ); +} + +function normalizePreview( + preview: BuiltInApplyPatchFilePreview | null | undefined +): BuiltInApplyPatchFilePreview | null { + if (!preview) { + return null; + } + + return { + beforeContent: preview.beforeContent ?? null, + afterContent: preview.afterContent ?? null, + beforeTruncated: preview.beforeTruncated === true, + afterTruncated: preview.afterTruncated === true, + isBinary: preview.isBinary === true, + omitted: preview.omitted === true, + }; +} diff --git a/apps/desktop/src/services/BuiltInToolService/tools/applyPatch/index.ts b/apps/desktop/src/services/BuiltInToolService/tools/applyPatch/index.ts new file mode 100644 index 00000000..8e9d5371 --- /dev/null +++ b/apps/desktop/src/services/BuiltInToolService/tools/applyPatch/index.ts @@ -0,0 +1,190 @@ +// Copyright (c) 2026. Qian Cheng. Licensed under GPL v3 + +import { native } from '@services/NativeService'; + +import type { ToolApprovalRequest } from '@/services/AgentService/contracts/tooling'; +import { normalizeOptionalString, truncateText } from '@/utils/text'; + +import { + type BaseBuiltInToolExecutionContext, + BuiltInTool, + type BuiltInToolConversationSemantic, + type BuiltInToolExecutionResult, + type BuiltInToolGroup, +} from '../../types'; +import { parseToolArguments } from '../../utils/toolSchema'; +import { + APPLY_PATCH_TOOL_DESCRIPTION, + APPLY_PATCH_TOOL_INPUT_SCHEMA, + APPLY_PATCH_TOOL_NAME, + applyPatchArgsSchema, +} from './constants'; +import { formatApplyPatchToolResult } from './helper'; + +type ApplyPatchArgs = ReturnType; + +function parseApplyPatchArgs(args: Record) { + return parseToolArguments(APPLY_PATCH_TOOL_NAME, applyPatchArgsSchema, args); +} + +function normalizePatchPath(path: string): string { + return path.trim().replace(/\\/g, '/'); +} + +function collectPatchTargets(patch: string): string[] { + const targets: string[] = []; + const lines = patch.replace(/\r\n/g, '\n').split('\n'); + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (line === undefined) { + continue; + } + + const addTarget = line.match(/^\*\*\* Add File: (.+)$/)?.[1]; + if (addTarget?.trim()) { + targets.push(`新增 ${normalizePatchPath(addTarget)}`); + continue; + } + + const updateTarget = line.match(/^\*\*\* Update File: (.+)$/)?.[1]; + if (updateTarget?.trim()) { + const sourcePath = normalizePatchPath(updateTarget); + const moveTarget = lines[index + 1]?.match(/^\*\*\* Move to: (.+)$/)?.[1]; + if (moveTarget?.trim()) { + targets.push(`移动 ${sourcePath} → ${normalizePatchPath(moveTarget)}`); + index += 1; + } else { + targets.push(`修改 ${sourcePath}`); + } + continue; + } + + const deleteTarget = line.match(/^\*\*\* Delete File: (.+)$/)?.[1]; + if (deleteTarget?.trim()) { + targets.push(`删除 ${normalizePatchPath(deleteTarget)}`); + } + } + + return [...new Set(targets)]; +} + +function buildApplyPatchSummary(args: ApplyPatchArgs): string { + const targets = collectPatchTargets(args.patch); + if (targets.length === 0) { + return '文件'; + } + + return truncateText(targets.join(', '), 160); +} + +function buildApplyPatchConversationSemantic( + args: Record +): BuiltInToolConversationSemantic { + try { + const parsedArgs = parseApplyPatchArgs(args); + return { + action: 'update', + target: buildApplyPatchSummary(parsedArgs), + }; + } catch { + return { + action: 'update', + target: '文件', + }; + } +} + +export function createApplyPatchApprovalRequest( + args: Record +): ToolApprovalRequest | null { + let parsedArgs: ApplyPatchArgs; + try { + parsedArgs = parseApplyPatchArgs(args); + } catch { + return null; + } + + const requestedReason = + normalizeOptionalString(parsedArgs.reason, { collapseWhitespace: true }) ?? + normalizeOptionalString(parsedArgs.description, { collapseWhitespace: true }) ?? + ''; + + return { + title: '文件修改确认', + description: requestedReason, + command: [ + `工作目录: ${parsedArgs.workingDirectory}`, + `变更目标: ${buildApplyPatchSummary(parsedArgs)}`, + ].join('\n'), + riskLabel: '', + reason: '此操作会通过结构化补丁修改本地工作区文件。', + commandLabel: '', + approveLabel: '批准', + rejectLabel: '拒绝', + enterHint: 'Enter', + escHint: 'Esc', + keyboardApproveDelayMs: 450, + }; +} + +export async function executeApplyPatchTool( + args: Record, + config: Record, + context: BaseBuiltInToolExecutionContext +): Promise { + void config; + void context.signal; + + const parsedArgs = parseApplyPatchArgs(args); + try { + const response = await native.builtInTools.applyPatch({ + patch: parsedArgs.patch, + workingDirectory: parsedArgs.workingDirectory, + }); + + return { + result: response.summary, + displayResult: formatApplyPatchToolResult(response), + isError: false, + status: 'success', + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + result: `补丁应用失败:${errorMessage}`, + isError: true, + status: 'error', + errorMessage, + }; + } +} + +class ApplyPatchTool extends BuiltInTool> { + readonly id = 'apply_patch' as const; + readonly displayName = 'ApplyPatch'; + readonly description = APPLY_PATCH_TOOL_DESCRIPTION; + readonly inputSchema = APPLY_PATCH_TOOL_INPUT_SCHEMA; + readonly defaultConfig = {}; + + override buildApprovalRequest(args: Record) { + return createApplyPatchApprovalRequest(args); + } + + override buildConversationSemantic(args: Record) { + return buildApplyPatchConversationSemantic(args); + } + + override execute( + args: Record, + config: Record, + context: BaseBuiltInToolExecutionContext + ) { + return executeApplyPatchTool(args, config, context); + } +} + +export const applyPatchTool = new ApplyPatchTool(); +export const builtInTools: BuiltInToolGroup = [applyPatchTool]; + +export { parseApplyPatchToolResult } from './helper'; diff --git a/apps/desktop/src/services/BuiltInToolService/tools/bash/constants.ts b/apps/desktop/src/services/BuiltInToolService/tools/bash/constants.ts index 46826b72..7d14aef1 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/bash/constants.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/bash/constants.ts @@ -37,6 +37,7 @@ export interface BashCommandContext { command: string; workingDirectory: string; rawOutput: boolean; + allowFileMutation: boolean; } /** @@ -64,6 +65,7 @@ export const bashCommandContextSchema = z.object({ command: nonEmptyTrimmedStringSchema, workingDirectory: optionalTrimmedStringSchema, rawOutput: z.boolean().optional(), + allowFileMutation: z.boolean().optional(), }); export const bashApprovalPayloadSchema = bashCommandContextSchema.extend({ @@ -109,7 +111,8 @@ export const HIGH_RISK_RULES: Array<{ pattern: RegExp; reason: string }> = [ { pattern: /\b(remove-item|del|erase|rm)\b/i, reason: '命令可能删除文件或目录。' }, { pattern: /\b(git\s+reset|git\s+clean)\b/i, reason: '命令可能重置或清理 Git 工作区。' }, { - pattern: /\b(copy-item|move-item|rename-item|new-item|set-content|add-content|out-file)\b/i, + pattern: + /\b(copy-item|move-item|rename-item|new-item|set-content|add-content|clear-content|out-file)\b/i, reason: '命令可能修改或覆盖文件内容。', }, { pattern: />\s*[^>]/, reason: '命令包含输出重定向,可能覆写文件。' }, @@ -127,6 +130,9 @@ export const BASH_TOOL_DESCRIPTION = [ 'Platform: Windows.', 'Shell: Windows PowerShell (`powershell.exe` run with `-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command`).', 'Use PowerShell syntax, cmdlets, and Windows paths instead of bash/sh syntax.', + 'Use this tool for inspection, search, command output, and verification.', + 'Do not use this tool to create, edit, delete, rename, move, overwrite, or append local workspace files. Use ApplyPatch for local file mutations by default.', + 'Only perform file mutations through Bash when the user explicitly asks for shell-based file operations, and set allowFileMutation to true.', 'For multiline text in PowerShell, prefer here-strings such as `@\'...\'@` or `@"..."@` instead of `\\n` escape sequences.', '`rg` (ripgrep) is available on PATH. For content/code search, ALWAYS use `rg` instead of `Select-String`, `findstr`, or reading files manually — ripgrep is faster, respects .gitignore, and supports regex.', 'Common rg examples: `rg "pattern" src/`, `rg -t ts "pattern"`, `rg --json "pattern" src/`, `rg -g "*.ts" "pattern"`.', @@ -158,6 +164,12 @@ export const BASH_TOOL_INPUT_SCHEMA: AiToolDefinition['input_schema'] = { 'Default false: command output is compressed to essential information. Set to true if the compressed output is missing information you need — this returns the raw, unfiltered result (WILL RERUN).', default: false, }, + allowFileMutation: { + type: 'boolean', + description: + 'Default false. Only set true when the user explicitly requested shell-based file operations. For normal local file creation, edits, deletion, rename, or move, use ApplyPatch instead.', + default: false, + }, }, required: ['command', 'reason'], }; diff --git a/apps/desktop/src/services/BuiltInToolService/tools/bash/helper.ts b/apps/desktop/src/services/BuiltInToolService/tools/bash/helper.ts index 81cf4e6d..cf0e9cf8 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/bash/helper.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/bash/helper.ts @@ -15,6 +15,11 @@ import { DEFAULT_BASH_TOOL_CONFIG, } from './constants'; +export interface BashFileMutationDetection { + isMutation: boolean; + reason: string | null; +} + function normalizeDirectoryPath(path: string): string { return path.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase(); } @@ -78,7 +83,112 @@ export async function resolveCommandContext(args: Record, confi ); } - return { command, workingDirectory, rawOutput: parsedArgs.rawOutput ?? false }; + return { + command, + workingDirectory, + rawOutput: parsedArgs.rawOutput ?? false, + allowFileMutation: parsedArgs.allowFileMutation ?? false, + }; +} + +function stripQuotedPowerShellText(command: string): string { + let result = ''; + let quote: "'" | '"' | null = null; + + for (let index = 0; index < command.length; index += 1) { + const char = command[index]; + if (quote) { + if (char === '`') { + result += ' '; + index += 1; + if (index < command.length) { + result += ' '; + } + continue; + } + + if (char === quote) { + if (quote === "'" && command[index + 1] === "'") { + result += ' '; + index += 1; + continue; + } + + quote = null; + } + + result += ' '; + continue; + } + + if (char === "'" || char === '"') { + quote = char; + result += ' '; + continue; + } + + if (char === '`') { + result += ' '; + index += 1; + if (index < command.length) { + result += ' '; + } + continue; + } + + result += char; + } + + return result; +} + +export function detectBashFileMutation(command: string): BashFileMutationDetection { + const unquotedCommand = stripQuotedPowerShellText(command); + + if ( + /\b(set-content|add-content|clear-content|out-file|new-item|remove-item|move-item|copy-item|rename-item)\b/i.test( + unquotedCommand + ) + ) { + return { isMutation: true, reason: 'PowerShell file mutation cmdlet' }; + } + + if ( + /\b(del|erase|rm|rmdir|rd|mv|move|copy|cp|ren|mkdir|md|ni|ri|mi|cpi|rni|sc|ac|clc)\b/i.test( + unquotedCommand + ) + ) { + return { isMutation: true, reason: 'PowerShell file mutation alias' }; + } + + if (/(^|[\s;|&])(?:\d+|\*)?>>?\s*(?!&\d)\S/.test(unquotedCommand)) { + return { isMutation: true, reason: 'shell output redirection' }; + } + + if (/\bsed\b[\s\S]*?(^|\s)-i(\s|$)/i.test(unquotedCommand)) { + return { isMutation: true, reason: 'in-place sed edit' }; + } + + if (/\bperl\b[\s\S]*?(^|\s)-p?i(\s|$)/i.test(unquotedCommand)) { + return { isMutation: true, reason: 'in-place perl edit' }; + } + + if ( + /\btee-object\b/i.test(unquotedCommand) || + /\btee\b[\s\S]*?(^|\s)(-file(path)?|-a)\b/i.test(unquotedCommand) + ) { + return { isMutation: true, reason: 'tee file output' }; + } + + if (/\bgit\s+(mv|rm|restore|clean|reset)\b/i.test(unquotedCommand)) { + return { isMutation: true, reason: 'Git working tree mutation' }; + } + + if (/\bgit\s+checkout\s+--\s+\S/i.test(unquotedCommand)) { + return { isMutation: true, reason: 'Git working tree checkout' }; + } + + return { isMutation: false, reason: null }; } export function truncateOutput(output: string, maxLength: number): string { diff --git a/apps/desktop/src/services/BuiltInToolService/tools/bash/index.ts b/apps/desktop/src/services/BuiltInToolService/tools/bash/index.ts index 8eae42d3..82c5fb72 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/bash/index.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/bash/index.ts @@ -27,7 +27,18 @@ import { type FormattedBashExecution, HIGH_RISK_RULES, } from './constants'; -import { formatBashToolResult, parseBashToolConfig, resolveCommandContext } from './helper'; +import { + detectBashFileMutation, + formatBashToolResult, + parseBashToolConfig, + resolveCommandContext, +} from './helper'; + +const FILE_MUTATION_BLOCKED_RESULT = [ + 'Bash file mutation blocked.', + 'Use ApplyPatch for local workspace file mutations so the user can review structured changes.', + 'Only retry with Bash if the user explicitly asked for shell-based file operations; set allowFileMutation to true in that case.', +].join('\n'); function buildBashConversationSemantic( args: Record @@ -110,6 +121,11 @@ export function createBashApprovalRequest( return resolveCommandContext(args, config).then((commandContext) => { const requestedReason = parsedApprovalPayload.reason ?? parsedApprovalPayload.description ?? ''; + const mutationDetection = detectBashFileMutation(commandContext.command); + if (mutationDetection.isMutation && !commandContext.allowFileMutation) { + return null; + } + if (config.approvalMode === 'never') { return null; } @@ -153,6 +169,19 @@ export async function executeBashTool( context: BaseBuiltInToolExecutionContext ): Promise { const commandContext = await resolveCommandContext(args, config); + const mutationDetection = detectBashFileMutation(commandContext.command); + if (mutationDetection.isMutation && !commandContext.allowFileMutation) { + const result = mutationDetection.reason + ? `${FILE_MUTATION_BLOCKED_RESULT}\nDetected: ${mutationDetection.reason}` + : FILE_MUTATION_BLOCKED_RESULT; + return { + result, + isError: true, + status: 'error', + errorMessage: FILE_MUTATION_BLOCKED_RESULT, + }; + } + const response = await executeCancelableBash( { executionId: context.callId, @@ -230,5 +259,5 @@ export const bashTool = new BashTool(); export const builtInTools: BuiltInToolGroup = [bashTool]; export { DEFAULT_BASH_TOOL_CONFIG } from './constants'; -export { parseBashToolConfig, parseBashToolResult } from './helper'; +export { detectBashFileMutation, parseBashToolConfig, parseBashToolResult } from './helper'; export type { BashApprovalMode, BashCommandContext, BashToolConfig, FormattedBashExecution }; diff --git a/apps/desktop/src/services/BuiltInToolService/types.ts b/apps/desktop/src/services/BuiltInToolService/types.ts index 08908bc6..fe2bfa09 100644 --- a/apps/desktop/src/services/BuiltInToolService/types.ts +++ b/apps/desktop/src/services/BuiltInToolService/types.ts @@ -16,6 +16,7 @@ import type { AttachmentIndex } from '@/services/AgentService/infrastructure/att * 当前内置工具体系允许暴露给模型的稳定工具标识。 */ export type BuiltInToolId = + | 'apply_patch' | 'bash' | 'file_search' | 'read' @@ -58,6 +59,7 @@ export type BuiltInToolControlSignal = UpgradeModelControlSignal; */ export interface BuiltInToolExecutionResult { result: string; + displayResult?: string | null; isError: boolean; status: 'success' | 'error' | 'timeout'; errorMessage?: string | null; diff --git a/apps/desktop/src/services/NativeService/builtInTools.ts b/apps/desktop/src/services/NativeService/builtInTools.ts index 28db1556..fb5b89b2 100644 --- a/apps/desktop/src/services/NativeService/builtInTools.ts +++ b/apps/desktop/src/services/NativeService/builtInTools.ts @@ -1,11 +1,21 @@ import { invoke } from '@tauri-apps/api/core'; -import type { BuiltInBashExecutionRequest, BuiltInBashExecutionResponse } from './types'; +import type { + BuiltInApplyPatchExecutionRequest, + BuiltInApplyPatchExecutionResponse, + BuiltInBashExecutionRequest, + BuiltInBashExecutionResponse, +} from './types'; /** * 原生内置工具桥接层。 */ export const builtInTools = { + applyPatch( + request: BuiltInApplyPatchExecutionRequest + ): Promise { + return invoke('built_in_tools_apply_patch', { request }); + }, executeBash(request: BuiltInBashExecutionRequest): Promise { return invoke('built_in_tools_execute_bash', { request }); }, diff --git a/apps/desktop/src/services/NativeService/index.ts b/apps/desktop/src/services/NativeService/index.ts index b6b6f7fb..d0c9e6eb 100644 --- a/apps/desktop/src/services/NativeService/index.ts +++ b/apps/desktop/src/services/NativeService/index.ts @@ -27,6 +27,11 @@ export type { AppUpdateDownload, AppUpdateInfo, AppUpdateRequirement, + BuiltInApplyPatchExecutionRequest, + BuiltInApplyPatchExecutionResponse, + BuiltInApplyPatchFileChange, + BuiltInApplyPatchFilePreview, + BuiltInApplyPatchOperation, BuiltInBashExecutionRequest, BuiltInBashExecutionResponse, ClipboardPayload, diff --git a/apps/desktop/src/services/NativeService/types.ts b/apps/desktop/src/services/NativeService/types.ts index 48afc9eb..4c2df7d0 100644 --- a/apps/desktop/src/services/NativeService/types.ts +++ b/apps/desktop/src/services/NativeService/types.ts @@ -34,6 +34,36 @@ export interface BuiltInBashExecutionResponse { compressed?: boolean; } +export interface BuiltInApplyPatchExecutionRequest { + patch: string; + workingDirectory: string; +} + +export type BuiltInApplyPatchOperation = 'add' | 'update' | 'delete' | 'move'; + +export interface BuiltInApplyPatchFilePreview { + beforeContent: string | null; + afterContent: string | null; + beforeTruncated: boolean; + afterTruncated: boolean; + isBinary: boolean; + omitted: boolean; +} + +export interface BuiltInApplyPatchFileChange { + path: string; + newPath: string | null; + operation: BuiltInApplyPatchOperation; + preview?: BuiltInApplyPatchFilePreview | null; +} + +export interface BuiltInApplyPatchExecutionResponse { + success: boolean; + workingDirectory: string; + changedFiles: BuiltInApplyPatchFileChange[]; + summary: string; +} + export interface ShowPopupWindowParams { x: number; y: number; diff --git a/apps/desktop/src/views/SearchView/components/ConversationPanel/components/BuiltInApplyPatchToolCallItem.vue b/apps/desktop/src/views/SearchView/components/ConversationPanel/components/BuiltInApplyPatchToolCallItem.vue new file mode 100644 index 00000000..a4976038 --- /dev/null +++ b/apps/desktop/src/views/SearchView/components/ConversationPanel/components/BuiltInApplyPatchToolCallItem.vue @@ -0,0 +1,1035 @@ + + + + + + + diff --git a/apps/desktop/src/views/SearchView/components/ConversationPanel/components/ToolCallItem.vue b/apps/desktop/src/views/SearchView/components/ConversationPanel/components/ToolCallItem.vue index 732073f9..b318c244 100644 --- a/apps/desktop/src/views/SearchView/components/ConversationPanel/components/ToolCallItem.vue +++ b/apps/desktop/src/views/SearchView/components/ConversationPanel/components/ToolCallItem.vue @@ -1,7 +1,8 @@