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.
///
/// Fix API endpoint URLs
/// Update copyright year in `page_footer`
///
/// 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
///
///
/// `backend/src/main.rs`
///
/// Notice how the file path starts with `backend`. Without that, the path would be ambiguous and the call would fail!
///
///
///
/// `frontend/db.js`
///
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,
/// 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>,
}
#[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,
#[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 for LanguageModelToolResultContent {
fn from(output: StreamingEditFileToolOutput) -> Self {
output.to_string().into()
}
}
pub struct StreamingEditFileTool {
thread: WeakEntity,
language_registry: Arc,
project: Entity,
#[allow(dead_code)]
templates: Arc,
}
impl StreamingEditFileTool {
pub fn new(
project: Entity,
thread: WeakEntity,
language_registry: Arc,
templates: Arc,
) -> Self {
Self {
project,
thread,
language_registry,
templates,
}
}
pub fn with_thread(&self, new_thread: WeakEntity) -> 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> {
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,
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::(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,
input: Self::Input,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task> {
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 = 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,
) -> Result<()> {
match output {
StreamingEditFileToolOutput::Success {
input_path,
old_text,
new_text,
..
} => {
event_stream.update_diff(cx.new(|cx| {
Diff::finalized(
input_path.to_string_lossy().into_owned(),
Some(old_text.to_string()),
new_text,
self.language_registry.clone(),
cx,
)
}));
Ok(())
}
StreamingEditFileToolOutput::Error { .. } => Ok(()),
}
}
}
fn apply_edits(
buffer: &Entity,
action_log: &Entity,
edits: &[EditOperation],
diff: &Entity,
event_stream: &ToolCallEventStream,
abs_path: &Option,
cx: &mut AsyncApp,
) -> Result<()> {
let mut failed_edits = Vec::new();
let mut ambiguous_edits = Vec::new();
let mut resolved_edits: Vec<(Range, String)> = Vec::new();
// First pass: resolve all edits without applying them
let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
for (index, edit) in edits.iter().enumerate() {
let result = resolve_edit(&snapshot, edit);
match result {
Ok(Some((range, new_text))) => {
// Reveal the range in the diff view
let (start_anchor, end_anchor) = buffer.read_with(cx, |buffer, _cx| {
(
buffer.anchor_before(range.start),
buffer.anchor_after(range.end),
)
});
diff.update(cx, |card, cx| {
card.reveal_range(start_anchor..end_anchor, cx)
});
resolved_edits.push((range, new_text));
}
Ok(None) => {
failed_edits.push(index);
}
Err(ranges) => {
ambiguous_edits.push((index, ranges));
}
}
}
// Check for errors before applying any edits
if !failed_edits.is_empty() {
let indices = failed_edits
.iter()
.map(|i| i.to_string())
.collect::>()
.join(", ");
anyhow::bail!(
"Could not find matching text for edit(s) at index(es): {}. \
The old_text did not match any content in the file. \
Please read the file again to get the current content.",
indices
);
}
if !ambiguous_edits.is_empty() {
let details: Vec = ambiguous_edits
.iter()
.map(|(index, ranges)| {
let lines = ranges
.iter()
.map(|r| (snapshot.offset_to_point(r.start).row + 1).to_string())
.collect::>()
.join(", ");
format!("edit {}: matches at lines {}", index, lines)
})
.collect();
anyhow::bail!(
"Some edits matched multiple locations in the file:\n{}. \
Please provide more context in old_text to uniquely identify the location.",
details.join("\n")
);
}
// Sort edits by position so buffer.edit() can handle offset translation
let mut edits_sorted = resolved_edits;
edits_sorted.sort_by(|a, b| a.0.start.cmp(&b.0.start));
// Emit location for the earliest edit in the file
if let Some((first_range, _)) = edits_sorted.first() {
if let Some(abs_path) = abs_path.clone() {
let line = snapshot.offset_to_point(first_range.start).row;
event_stream.update_fields(
ToolCallUpdateFields::new()
.locations(vec![ToolCallLocation::new(abs_path).line(Some(line))]),
);
}
}
// Validate no overlaps (sorted ascending by start)
for window in edits_sorted.windows(2) {
if let [(earlier_range, _), (later_range, _)] = window
&& (earlier_range.end > later_range.start || earlier_range.start == later_range.start)
{
let earlier_start_line = snapshot.offset_to_point(earlier_range.start).row + 1;
let earlier_end_line = snapshot.offset_to_point(earlier_range.end).row + 1;
let later_start_line = snapshot.offset_to_point(later_range.start).row + 1;
let later_end_line = snapshot.offset_to_point(later_range.end).row + 1;
anyhow::bail!(
"Conflicting edit ranges detected: lines {}-{} conflicts with lines {}-{}. \
Conflicting edit ranges are not allowed, as they would overwrite each other.",
earlier_start_line,
earlier_end_line,
later_start_line,
later_end_line,
);
}
}
// Apply all edits in a single batch and report to action_log in the same
// effect cycle. This prevents the buffer subscription from treating these
// as user edits.
if !edits_sorted.is_empty() {
cx.update(|cx| {
buffer.update(cx, |buffer, cx| {
buffer.edit(
edits_sorted
.iter()
.map(|(range, new_text)| (range.clone(), new_text.as_str())),
None,
cx,
);
});
action_log.update(cx, |log, cx| {
log.buffer_edited(buffer.clone(), cx);
});
});
}
Ok(())
}
/// Resolves an edit operation by finding the matching text in the buffer.
/// Returns Ok(Some((range, new_text))) if a unique match is found,
/// Ok(None) if no match is found, or Err(ranges) if multiple matches are found.
fn resolve_edit(
snapshot: &BufferSnapshot,
edit: &EditOperation,
) -> std::result::Result