2639 lines · 94.8 KB · Rust
RawDownload
use super::restore_file_from_disk_tool::RestoreFileFromDiskTool;use super::save_file_tool::SaveFileTool;use super::tool_permissions::authorize_file_edit;use crate::{    AgentTool, Templates, Thread, ToolCallEventStream, ToolInput,    edit_agent::{EditAgent, EditAgentOutputEvent, EditFormat},};use acp_thread::Diff;use agent_client_protocol::{self as acp, ToolCallLocation, ToolCallUpdateFields};use anyhow::{Context as _, Result};use collections::HashSet;use futures::{FutureExt as _, StreamExt as _};use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};use indoc::formatdoc;use language::language_settings::{self, FormatOnSave};use language::{LanguageRegistry, ToPoint};use language_model::{CompletionIntent, LanguageModelToolResultContent};use project::lsp_store::{FormatTrigger, LspFormatTarget};use project::{Project, ProjectPath};use schemars::JsonSchema;use serde::{Deserialize, Serialize};use std::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 `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 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)]#[serde(untagged)]pub enum EditFileToolOutput {    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 EditFileToolOutput {    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {        match self {            EditFileToolOutput::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()                    )                }            }            EditFileToolOutput::Error { error } => write!(f, "{error}"),        }    }} impl From<EditFileToolOutput> for LanguageModelToolResultContent {    fn from(output: EditFileToolOutput) -> Self {        output.to_string().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<()>> {        authorize_file_edit(            Self::NAME,            &input.path,            &input.display_description,            &self.thread,            event_stream,            cx,        )    }} impl AgentTool for EditFileTool {    type Input = EditFileToolInput;    type Output = EditFileToolOutput;     const 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: ToolInput<Self::Input>,        event_stream: ToolCallEventStream,        cx: &mut App,    ) -> Task<Result<Self::Output, Self::Output>> {        cx.spawn(async move |cx: &mut AsyncApp| {            let input = input.recv().await.map_err(|e| EditFileToolOutput::Error {                error: format!("Failed to receive tool input: {e}"),            })?;             let project = self                .thread                .read_with(cx, |thread, _cx| thread.project().clone())                .map_err(|_| EditFileToolOutput::Error {                    error: "thread was dropped".to_string(),                })?;             let (project_path, abs_path, allow_thinking, update_agent_location, authorize) =                cx.update(|cx| {                    let project_path = resolve_path(&input, project.clone(), cx).map_err(|err| {                        EditFileToolOutput::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 allow_thinking = self                        .thread                        .read_with(cx, |thread, _cx| thread.thinking_enabled())                        .unwrap_or(true);                     let update_agent_location = self.thread.read_with(cx, |thread, _cx| !thread.is_subagent()).unwrap_or_default();                     let authorize = self.authorize(&input, &event_stream, cx);                    Ok::<_, EditFileToolOutput>((project_path, abs_path, allow_thinking, update_agent_location, authorize))                })?;             let result: anyhow::Result<EditFileToolOutput> = async {                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,                    allow_thinking,                    update_agent_location,                );                 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 = action_log.read_with(cx, |log, _| log.file_read_time(abs_path));                    let (current_mtime, is_dirty, has_save_tool, has_restore_tool) = self.thread.read_with(cx, |thread, cx| {                        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);                        (current, dirty, has_save, has_restore)                    })?;                     // Check for unsaved changes first - these indicate modifications we don't know about                    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);                    }                     // 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;                loop {                    let event = futures::select! {                        event = events.next().fuse() => match event {                            Some(event) => event,                            None => break,                        },                        _ = event_stream.cancelled_by_user().fuse() => {                            anyhow::bail!("Edit cancelled by user");                        }                    };                    match event {                        EditAgentOutputEvent::Edited(range) => {                            if !emitted_location {                                let line = Some(buffer.update(cx, |buffer, _cx| {                                    range.start.to_point(&buffer.snapshot()).row                                }));                                if let Some(abs_path) = abs_path.clone() {                                    event_stream.update_fields(ToolCallUpdateFields::new().locations(vec![ToolCallLocation::new(abs_path).line(line)]));                                }                                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));                        }                    }                }                 output.await?;                 let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| {                    let settings = language_settings::LanguageSettings::for_buffer(buffer, 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, // 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);                });                 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! {"                                <old_text> matches more than one position in the file (lines: {line_numbers}). Read the                                relevant sections of {input_path} again and extend <old_text> so                                that I can perform the requested edits.                            "}                        }                    );                }                 anyhow::Ok(EditFileToolOutput::Success {                    input_path: input.path,                    new_text,                    old_text,

Lines 1–500 of 2639.