Richard FeldmanFix stale edits (#42895)23872b012 months agoHistory
2218 lines · 78.6 KB · Rust
RawDownload
use crate::{    AgentTool, Templates, Thread, ToolCallEventStream,    edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat},};use acp_thread::Diff;use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields};use anyhow::{Context as _, Result, anyhow};use cloud_llm_client::CompletionIntent;use collections::HashSet;use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};use indoc::formatdoc;use language::language_settings::{self, FormatOnSave};use language::{LanguageRegistry, ToPoint};use language_model::LanguageModelToolResultContent;use paths;use project::lsp_store::{FormatTrigger, LspFormatTarget};use project::{Project, ProjectPath};use schemars::JsonSchema;use serde::{Deserialize, Serialize};use settings::Settings;use smol::stream::StreamExt as _;use std::ffi::OsStr;use std::path::{Path, PathBuf};use std::sync::Arc;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 `terminal` tool with the 'mv' command 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 EditFileToolInput {    /// A one-line, user-friendly markdown description of the edit. This will be shown in the UI and also passed to another model to perform the edit.    ///    /// 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:    /// - 'edit': Make granular edits to an existing file.    /// - 'create': Create a new file if it doesn't exist.    /// - 'overwrite': Replace the entire contents of an existing file.    ///    /// When a file already exists or you just created it, prefer editing it as opposed to recreating it from scratch.    pub mode: EditFileMode,} #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]struct EditFileToolPartialInput {    #[serde(default)]    path: String,    #[serde(default)]    display_description: String,} #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]#[serde(rename_all = "lowercase")]#[schemars(inline)]pub enum EditFileMode {    Edit,    Create,    Overwrite,} #[derive(Debug, Serialize, Deserialize)]pub struct EditFileToolOutput {    #[serde(alias = "original_path")]    input_path: PathBuf,    new_text: String,    old_text: Arc<String>,    #[serde(default)]    diff: String,    #[serde(alias = "raw_output")]    edit_agent_output: EditAgentOutput,} impl From<EditFileToolOutput> for LanguageModelToolResultContent {    fn from(output: EditFileToolOutput) -> Self {        if output.diff.is_empty() {            "No edits were made.".into()        } else {            format!(                "Edited {}:\n\n```diff\n{}\n```",                output.input_path.display(),                output.diff            )            .into()        }    }} pub struct EditFileTool {    thread: WeakEntity<Thread>,    language_registry: Arc<LanguageRegistry>,    project: Entity<Project>,    templates: Arc<Templates>,} impl EditFileTool {    pub fn new(        project: Entity<Project>,        thread: WeakEntity<Thread>,        language_registry: Arc<LanguageRegistry>,        templates: Arc<Templates>,    ) -> Self {        Self {            project,            thread,            language_registry,            templates,        }    }     fn authorize(        &self,        input: &EditFileToolInput,        event_stream: &ToolCallEventStream,        cx: &mut App,    ) -> Task<Result<()>> {        if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions {            return Task::ready(Ok(()));        }         // If any path component matches the local settings folder, then this could affect        // the editor in ways beyond the project source, so prompt.        let local_settings_folder = paths::local_settings_folder_name();        let path = Path::new(&input.path);        if path.components().any(|component| {            component.as_os_str() == <_ as AsRef<OsStr>>::as_ref(&local_settings_folder)        }) {            return event_stream.authorize(                format!("{} (local settings)", input.display_description),                cx,            );        }         // It's also possible that the global config dir is configured to be inside the project,        // so check for that edge case too.        // TODO this is broken when remoting        if let Ok(canonical_path) = std::fs::canonicalize(&input.path)            && canonical_path.starts_with(paths::config_dir())        {            return event_stream.authorize(                format!("{} (global settings)", input.display_description),                cx,            );        }         // Check if path is inside the global config directory        // First check if it's already inside project - if not, try to canonicalize        let Ok(project_path) = self.thread.read_with(cx, |thread, cx| {            thread.project().read(cx).find_project_path(&input.path, cx)        }) else {            return Task::ready(Err(anyhow!("thread was dropped")));        };         // If the path is inside the project, and it's not one of the above edge cases,        // then no confirmation is necessary. Otherwise, confirmation is necessary.        if project_path.is_some() {            Task::ready(Ok(()))        } else {            event_stream.authorize(&input.display_description, cx)        }    }} impl AgentTool for EditFileTool {    type Input = EditFileToolInput;    type Output = EditFileToolOutput;     fn name() -> &'static str {        "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::<EditFileToolPartialInput>(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>> {        let Ok(project) = self            .thread            .read_with(cx, |thread, _cx| thread.project().clone())        else {            return Task::ready(Err(anyhow!("thread was dropped")));        };        let project_path = match resolve_path(&input, project.clone(), cx) {            Ok(path) => path,            Err(err) => return Task::ready(Err(anyhow!(err))),        };        let abs_path = project.read(cx).absolute_path(&project_path, cx);        if let Some(abs_path) = abs_path.clone() {            event_stream.update_fields(ToolCallUpdateFields {                locations: Some(vec![acp::ToolCallLocation {                    path: abs_path,                    line: None,                    meta: None,                }]),                ..Default::default()            });        }         let authorize = self.authorize(&input, &event_stream, cx);        cx.spawn(async move |cx: &mut AsyncApp| {            authorize.await?;             let (request, model, action_log) = self.thread.update(cx, |thread, cx| {                let request = thread.build_completion_request(CompletionIntent::ToolResults, cx);                (request, thread.model().cloned(), thread.action_log().clone())            })?;            let request = request?;            let model = model.context("No language model configured")?;             let edit_format = EditFormat::from_model(model.clone())?;            let edit_agent = EditAgent::new(                model,                project.clone(),                action_log.clone(),                self.templates.clone(),                edit_format,            );             let buffer = project                .update(cx, |project, cx| {                    project.open_buffer(project_path.clone(), cx)                })?                .await?;             // Check if the file has been modified since the agent last read it            if let Some(abs_path) = abs_path.as_ref() {                let (last_read_mtime, current_mtime, is_dirty) = 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();                    (last_read, current, dirty)                })?;                 // Check for unsaved changes first - these indicate modifications we don't know about                if is_dirty {                    anyhow::bail!(                        "This file cannot be written to because it has unsaved changes. \                         Please end the current conversation immediately by telling the user you want to write to this file (mention its path explicitly) but you can't write to it because it has unsaved changes. \                         Ask the user to save that buffer's changes and to inform you when it's ok to proceed."                    );                }                 // Check if the file was modified on disk since we last read it                if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime) {                    // MTime can be unreliable for comparisons, so our newtype intentionally                    // doesn't support comparing them. If the mtime at all different                    // (which could be because of a modification or because e.g. system clock changed),                    // we pessimistically assume it was modified.                    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 (output, mut events) = if matches!(input.mode, EditFileMode::Edit) {                edit_agent.edit(                    buffer.clone(),                    input.display_description.clone(),                    &request,                    cx,                )            } else {                edit_agent.overwrite(                    buffer.clone(),                    input.display_description.clone(),                    &request,                    cx,                )            };             let mut hallucinated_old_text = false;            let mut ambiguous_ranges = Vec::new();            let mut emitted_location = false;            while let Some(event) = events.next().await {                match event {                    EditAgentOutputEvent::Edited(range) => {                        if !emitted_location {                            let line = buffer.update(cx, |buffer, _cx| {                                range.start.to_point(&buffer.snapshot()).row                            }).ok();                            if let Some(abs_path) = abs_path.clone() {                                event_stream.update_fields(ToolCallUpdateFields {                                    locations: Some(vec![ToolCallLocation { path: abs_path, line, meta: None }]),                                    ..Default::default()                                });                            }                            emitted_location = true;                        }                    },                    EditAgentOutputEvent::UnresolvedEditRange => hallucinated_old_text = true,                    EditAgentOutputEvent::AmbiguousEditRange(ranges) => ambiguous_ranges = ranges,                    EditAgentOutputEvent::ResolvingEditRange(range) => {                        diff.update(cx, |card, cx| card.reveal_range(range.clone(), cx))?;                        // if !emitted_location {                        //     let line = buffer.update(cx, |buffer, _cx| {                        //         range.start.to_point(&buffer.snapshot()).row                        //     }).ok();                        //     if let Some(abs_path) = abs_path.clone() {                        //         event_stream.update_fields(ToolCallUpdateFields {                        //             locations: Some(vec![ToolCallLocation { path: abs_path, line }]),                        //             ..Default::default()                        //         });                        //     }                        // }                    }                }            }             // If format_on_save is enabled, format the buffer            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                })                .unwrap_or(false);             let edit_agent_output = output.await?;             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, // Don't push to history since the tool did it.                        FormatTrigger::Save,                        cx,                    )                })?;                format_task.await.log_err();            }             project                .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))?                .await?;             action_log.update(cx, |log, cx| {                log.buffer_edited(buffer.clone(), cx);            })?;             // Update the recorded read time after a successful edit so consecutive edits work            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 input_path = input.path.display();            if unified_diff.is_empty() {                anyhow::ensure!(                    !hallucinated_old_text,                    formatdoc! {"                        Some edits were produced but none of them could be applied.                        Read the relevant sections of {input_path} again so that                        I can perform the requested edits.                    "}                );                anyhow::ensure!(                    ambiguous_ranges.is_empty(),                    {                        let line_numbers = ambiguous_ranges                            .iter()                            .map(|range| range.start.to_string())                            .collect::<Vec<_>>()                            .join(", ");                        formatdoc! {"

Lines 1–500 of 2218.