1295 lines · 48.7 KB · Rust
RawDownload
use super::edit_file_tool::EditFileTool;use super::restore_file_from_disk_tool::RestoreFileFromDiskTool;use super::save_file_tool::SaveFileTool;use crate::{    AgentTool, Templates, Thread, ToolCallEventStream,    edit_agent::streaming_fuzzy_matcher::StreamingFuzzyMatcher,};use acp_thread::Diff;use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields};use anyhow::{Context as _, Result, anyhow};use collections::HashSet;use futures::FutureExt as _;use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};use language::LanguageRegistry;use language::language_settings::{self, FormatOnSave};use language_model::LanguageModelToolResultContent;use project::lsp_store::{FormatTrigger, LspFormatTarget};use project::{Project, ProjectPath};use schemars::JsonSchema;use serde::{Deserialize, Serialize};use std::ops::Range;use std::path::PathBuf;use std::sync::Arc;use text::BufferSnapshot;use ui::SharedString;use util::ResultExt;use util::rel_path::RelPath; const DEFAULT_UI_TEXT: &str = "Editing file"; /// This is a tool for creating a new file or editing an existing file. For moving or renaming files, you should generally use the `move_path` tool instead.////// Before using this tool:////// 1. Use the `read_file` tool to understand the file's contents and context////// 2. Verify the directory path is correct (only applicable when creating new files):///    - Use the `list_directory` tool to verify the parent directory exists and is the correct location#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]pub struct StreamingEditFileToolInput {    /// A one-line, user-friendly markdown description of the edit. This will be shown in the UI.    ///    /// Be terse, but also descriptive in what you want to achieve with this edit. Avoid generic instructions.    ///    /// NEVER mention the file path in this description.    ///    /// <example>Fix API endpoint URLs</example>    /// <example>Update copyright year in `page_footer`</example>    ///    /// Make sure to include this field before all the others in the input object so that we can display it immediately.    pub display_description: String,     /// The full path of the file to create or modify in the project.    ///    /// WARNING: When specifying which file path need changing, you MUST start each path with one of the project's root directories.    ///    /// The following examples assume we have two root directories in the project:    /// - /a/b/backend    /// - /c/d/frontend    ///    /// <example>    /// `backend/src/main.rs`    ///    /// Notice how the file path starts with `backend`. Without that, the path would be ambiguous and the call would fail!    /// </example>    ///    /// <example>    /// `frontend/db.js`    /// </example>    pub path: PathBuf,     /// The mode of operation on the file. Possible values:    /// - 'create': Create a new file if it doesn't exist. Requires 'content' field.    /// - 'overwrite': Replace the entire contents of an existing file. Requires 'content' field.    /// - 'edit': Make granular edits to an existing file. Requires 'edits' field.    ///    /// When a file already exists or you just created it, prefer editing it as opposed to recreating it from scratch.    pub mode: StreamingEditFileMode,     /// The complete content for the new file (required for 'create' and 'overwrite' modes).    /// This field should contain the entire file content.    #[serde(default, skip_serializing_if = "Option::is_none")]    pub content: Option<String>,     /// List of edit operations to apply sequentially (required for 'edit' mode).    /// Each edit finds `old_text` in the file and replaces it with `new_text`.    #[serde(default, skip_serializing_if = "Option::is_none")]    pub edits: Option<Vec<EditOperation>>,} #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]#[serde(rename_all = "snake_case")]pub enum StreamingEditFileMode {    /// Create a new file if it doesn't exist    Create,    /// Replace the entire contents of an existing file    Overwrite,    /// Make granular edits to an existing file    Edit,} /// A single edit operation that replaces old text with new text#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]pub struct EditOperation {    /// The exact text to find in the file. This will be matched using fuzzy matching    /// to handle minor differences in whitespace or formatting.    pub old_text: String,    /// The text to replace it with    pub new_text: String,} #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]struct StreamingEditFileToolPartialInput {    #[serde(default)]    path: String,    #[serde(default)]    display_description: String,} #[derive(Debug, Serialize, Deserialize)]#[serde(untagged)]pub enum StreamingEditFileToolOutput {    Success {        #[serde(alias = "original_path")]        input_path: PathBuf,        new_text: String,        old_text: Arc<String>,        #[serde(default)]        diff: String,    },    Error {        error: String,    },} impl std::fmt::Display for StreamingEditFileToolOutput {    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {        match self {            StreamingEditFileToolOutput::Success {                diff, input_path, ..            } => {                if diff.is_empty() {                    write!(f, "No edits were made.")                } else {                    write!(                        f,                        "Edited {}:\n\n```diff\n{diff}\n```",                        input_path.display()                    )                }            }            StreamingEditFileToolOutput::Error { error } => write!(f, "{error}"),        }    }} impl From<StreamingEditFileToolOutput> for LanguageModelToolResultContent {    fn from(output: StreamingEditFileToolOutput) -> Self {        output.to_string().into()    }} pub struct StreamingEditFileTool {    thread: WeakEntity<Thread>,    language_registry: Arc<LanguageRegistry>,    project: Entity<Project>,    #[allow(dead_code)]    templates: Arc<Templates>,} impl StreamingEditFileTool {    pub fn new(        project: Entity<Project>,        thread: WeakEntity<Thread>,        language_registry: Arc<LanguageRegistry>,        templates: Arc<Templates>,    ) -> Self {        Self {            project,            thread,            language_registry,            templates,        }    }     pub fn with_thread(&self, new_thread: WeakEntity<Thread>) -> Self {        Self {            project: self.project.clone(),            thread: new_thread,            language_registry: self.language_registry.clone(),            templates: self.templates.clone(),        }    }     fn authorize(        &self,        input: &StreamingEditFileToolInput,        event_stream: &ToolCallEventStream,        cx: &mut App,    ) -> Task<Result<()>> {        super::tool_permissions::authorize_file_edit(            EditFileTool::NAME,            &input.path,            &input.display_description,            &self.thread,            event_stream,            cx,        )    }} impl AgentTool for StreamingEditFileTool {    type Input = StreamingEditFileToolInput;    type Output = StreamingEditFileToolOutput;     const NAME: &'static str = "streaming_edit_file";     fn kind() -> acp::ToolKind {        acp::ToolKind::Edit    }     fn initial_title(        &self,        input: Result<Self::Input, serde_json::Value>,        cx: &mut App,    ) -> SharedString {        match input {            Ok(input) => self                .project                .read(cx)                .find_project_path(&input.path, cx)                .and_then(|project_path| {                    self.project                        .read(cx)                        .short_full_path_for_project_path(&project_path, cx)                })                .unwrap_or(input.path.to_string_lossy().into_owned())                .into(),            Err(raw_input) => {                if let Some(input) =                    serde_json::from_value::<StreamingEditFileToolPartialInput>(raw_input).ok()                {                    let path = input.path.trim();                    if !path.is_empty() {                        return self                            .project                            .read(cx)                            .find_project_path(&input.path, cx)                            .and_then(|project_path| {                                self.project                                    .read(cx)                                    .short_full_path_for_project_path(&project_path, cx)                            })                            .unwrap_or(input.path)                            .into();                    }                     let description = input.display_description.trim();                    if !description.is_empty() {                        return description.to_string().into();                    }                }                 DEFAULT_UI_TEXT.into()            }        }    }     fn run(        self: Arc<Self>,        input: Self::Input,        event_stream: ToolCallEventStream,        cx: &mut App,    ) -> Task<Result<Self::Output, Self::Output>> {        let Ok(project) = self            .thread            .read_with(cx, |thread, _cx| thread.project().clone())        else {            return Task::ready(Err(StreamingEditFileToolOutput::Error {                error: "thread was dropped".to_string(),            }));        };         let project_path = match resolve_path(&input, project.clone(), cx) {            Ok(path) => path,            Err(err) => {                return Task::ready(Err(StreamingEditFileToolOutput::Error {                    error: err.to_string(),                }));            }        };         let abs_path = project.read(cx).absolute_path(&project_path, cx);        if let Some(abs_path) = abs_path.clone() {            event_stream.update_fields(                ToolCallUpdateFields::new().locations(vec![acp::ToolCallLocation::new(abs_path)]),            );        }         let authorize = self.authorize(&input, &event_stream, cx);         cx.spawn(async move |cx: &mut AsyncApp| {            let result: anyhow::Result<StreamingEditFileToolOutput> = async {                authorize.await?;                 let buffer = project                    .update(cx, |project, cx| {                        project.open_buffer(project_path.clone(), cx)                    })                    .await?;                 if let Some(abs_path) = abs_path.as_ref() {                    let (last_read_mtime, current_mtime, is_dirty, has_save_tool, has_restore_tool) =                        self.thread.update(cx, |thread, cx| {                            let last_read = thread.file_read_times.get(abs_path).copied();                            let current = buffer                                .read(cx)                                .file()                                .and_then(|file| file.disk_state().mtime());                            let dirty = buffer.read(cx).is_dirty();                            let has_save = thread.has_tool(SaveFileTool::NAME);                            let has_restore = thread.has_tool(RestoreFileFromDiskTool::NAME);                            (last_read, current, dirty, has_save, has_restore)                        })?;                     if is_dirty {                        let message = match (has_save_tool, has_restore_tool) {                            (true, true) => {                                "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \                                 If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \                                 If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."                            }                            (true, false) => {                                "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \                                 If they want to keep them, ask for confirmation then use the save_file tool to save the file, then retry this edit. \                                 If they want to discard them, ask the user to manually revert the file, then inform you when it's ok to proceed."                            }                            (false, true) => {                                "This file has unsaved changes. Ask the user whether they want to keep or discard those changes. \                                 If they want to keep them, ask the user to manually save the file, then inform you when it's ok to proceed. \                                 If they want to discard them, ask for confirmation then use the restore_file_from_disk tool to restore the on-disk contents, then retry this edit."                            }                            (false, false) => {                                "This file has unsaved changes. Ask the user whether they want to keep or discard those changes, \                                 then ask them to save or revert the file manually and inform you when it's ok to proceed."                            }                        };                        anyhow::bail!("{}", message);                    }                     if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) {                        if current != last_read {                            anyhow::bail!(                                "The file {} has been modified since you last read it. \                                 Please read the file again to get the current state before editing it.",                                input.path.display()                            );                        }                    }                }                 let diff = cx.new(|cx| Diff::new(buffer.clone(), cx));                event_stream.update_diff(diff.clone());                let _finalize_diff = util::defer({                    let diff = diff.downgrade();                    let mut cx = cx.clone();                    move || {                        diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok();                    }                });                 let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());                let old_text = cx                    .background_spawn({                        let old_snapshot = old_snapshot.clone();                        async move { Arc::new(old_snapshot.text()) }                    })                    .await;                 let action_log = self.thread.read_with(cx, |thread, _cx| thread.action_log().clone())?;                 // Edit the buffer and report edits to the action log as part of the                // same effect cycle, otherwise the edit will be reported as if the                // user made it (due to the buffer subscription in action_log).                match input.mode {                    StreamingEditFileMode::Create | StreamingEditFileMode::Overwrite => {                        action_log.update(cx, |log, cx| {                            log.buffer_created(buffer.clone(), cx);                        });                        let content = input.content.ok_or_else(|| {                            anyhow!("'content' field is required for create and overwrite modes")                        })?;                        cx.update(|cx| {                            buffer.update(cx, |buffer, cx| {                                buffer.edit([(0..buffer.len(), content.as_str())], None, cx);                            });                            action_log.update(cx, |log, cx| {                                log.buffer_edited(buffer.clone(), cx);                            });                        });                    }                    StreamingEditFileMode::Edit => {                        action_log.update(cx, |log, cx| {                            log.buffer_read(buffer.clone(), cx);                        });                        let edits = input.edits.ok_or_else(|| {                            anyhow!("'edits' field is required for edit mode")                        })?;                        // apply_edits now handles buffer_edited internally in the same effect cycle                        apply_edits(&buffer, &action_log, &edits, &diff, &event_stream, &abs_path, cx)?;                    }                }                 let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| {                    let settings = language_settings::language_settings(                        buffer.language().map(|l| l.name()),                        buffer.file(),                        cx,                    );                    settings.format_on_save != FormatOnSave::Off                });                 if format_on_save_enabled {                    action_log.update(cx, |log, cx| {                        log.buffer_edited(buffer.clone(), cx);                    });                     let format_task = project.update(cx, |project, cx| {                        project.format(                            HashSet::from_iter([buffer.clone()]),                            LspFormatTarget::Buffers,                            false,                            FormatTrigger::Save,                            cx,                        )                    });                    futures::select! {                        result = format_task.fuse() => { result.log_err(); },                        _ = event_stream.cancelled_by_user().fuse() => {                            anyhow::bail!("Edit cancelled by user");                        }                    };                }                 let save_task = project                    .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx));                futures::select! {                    result = save_task.fuse() => { result?; },                    _ = event_stream.cancelled_by_user().fuse() => {                        anyhow::bail!("Edit cancelled by user");                    }                };                 action_log.update(cx, |log, cx| {                    log.buffer_edited(buffer.clone(), cx);                });                 if let Some(abs_path) = abs_path.as_ref() {                    if let Some(new_mtime) = buffer.read_with(cx, |buffer, _| {                        buffer.file().and_then(|file| file.disk_state().mtime())                    }) {                        self.thread.update(cx, |thread, _| {                            thread.file_read_times.insert(abs_path.to_path_buf(), new_mtime);                        })?;                    }                }                 let new_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());                let (new_text, unified_diff) = cx                    .background_spawn({                        let new_snapshot = new_snapshot.clone();                        let old_text = old_text.clone();                        async move {                            let new_text = new_snapshot.text();                            let diff = language::unified_diff(&old_text, &new_text);                            (new_text, diff)                        }                    })                    .await;                 let output = StreamingEditFileToolOutput::Success {                    input_path: input.path,                    new_text,                    old_text,                    diff: unified_diff,                };                 Ok(output)            }.await;            result                .map_err(|e| StreamingEditFileToolOutput::Error { error: e.to_string() })        })    }     fn replay(        &self,        _input: Self::Input,        output: Self::Output,        event_stream: ToolCallEventStream,        cx: &mut App,

Lines 1–500 of 1295.