We finally have a cancellation mechanism to use! Made the non side-effectful handlers stop their work if we get a cancel request notification. Release Notes: - N/A
1251 lines
40 KiB
Rust
1251 lines
40 KiB
Rust
mod reindent;
|
|
mod streaming_fuzzy_matcher;
|
|
mod streaming_parser;
|
|
|
|
use super::tool_permissions::resolve_creatable_global_skill_path;
|
|
use crate::{Thread, ToolCallEventStream};
|
|
use acp_thread::Diff;
|
|
use action_log::ActionLog;
|
|
use agent_client_protocol::schema::v1::{self as acp, ToolCallLocation, ToolCallUpdateFields};
|
|
use anyhow::Result;
|
|
use collections::HashSet;
|
|
use futures::{FutureExt, channel::oneshot};
|
|
use gpui::{App, AppContext, AsyncApp, Entity, Task, WeakEntity};
|
|
use language::language_settings::{self, FormatOnSave};
|
|
use language::{Buffer, BufferEditSource, BufferEvent, LanguageRegistry};
|
|
use language_model::LanguageModelToolResultContent;
|
|
use project::lsp_store::{FormatTrigger, LspFormatTarget};
|
|
use project::{AgentLocation, Project, ProjectPath};
|
|
use reindent::{Reindenter, compute_indent_delta};
|
|
use schemars::JsonSchema;
|
|
use serde::{Deserialize, Serialize, de::DeserializeOwned};
|
|
use std::ops::Range;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use streaming_diff::{CharOperation, StreamingDiff};
|
|
use streaming_fuzzy_matcher::StreamingFuzzyMatcher;
|
|
use streaming_parser::{EditEvent, StreamingParser, WriteEvent};
|
|
use text::ToOffset;
|
|
use ui::SharedString;
|
|
use util::rel_path::RelPath;
|
|
use util::{Deferred, ResultExt};
|
|
|
|
/// Operating mode used internally by `EditSession`/`Pipeline` to choose between
|
|
/// applying granular edits (the `edit_file` tool) or replacing/creating the
|
|
/// entire file content (the `write_file` tool).
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub(crate) enum EditSessionMode {
|
|
Write,
|
|
Edit,
|
|
}
|
|
|
|
/// A single edit operation that replaces old text with new text
|
|
/// Properly escape all text fields as valid JSON strings.
|
|
/// Remember to escape special characters like newlines (`\n`) and quotes (`"`) in JSON strings.
|
|
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
|
|
pub struct Edit {
|
|
/// The exact text to find in the file. This will be matched using fuzzy matching
|
|
/// to handle minor differences in whitespace or formatting.
|
|
///
|
|
/// Be minimal with replacements:
|
|
/// - For unique lines, include only those lines
|
|
/// - For non-unique lines, include enough context to identify them
|
|
pub old_text: String,
|
|
/// The text to replace it with
|
|
pub new_text: String,
|
|
}
|
|
|
|
#[derive(Clone, Default, Debug, Deserialize)]
|
|
pub struct PartialEdit {
|
|
#[serde(default)]
|
|
pub old_text: Option<String>,
|
|
#[serde(default)]
|
|
pub new_text: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
#[serde(untagged)]
|
|
pub enum EditSessionOutput {
|
|
Success {
|
|
#[serde(alias = "original_path")]
|
|
input_path: PathBuf,
|
|
new_text: String,
|
|
old_text: Arc<String>,
|
|
#[serde(default)]
|
|
diff: String,
|
|
},
|
|
Error {
|
|
error: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
input_path: Option<PathBuf>,
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
diff: String,
|
|
},
|
|
}
|
|
|
|
impl EditSessionOutput {
|
|
pub fn error(error: impl Into<String>) -> Self {
|
|
Self::Error {
|
|
error: error.into(),
|
|
input_path: None,
|
|
diff: String::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for EditSessionOutput {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
EditSessionOutput::Success {
|
|
diff, input_path, ..
|
|
} => {
|
|
if diff.is_empty() {
|
|
write!(f, "No edits were made.")
|
|
} else {
|
|
write!(f, "Edited {} successfully", input_path.display())
|
|
}
|
|
}
|
|
EditSessionOutput::Error {
|
|
error,
|
|
diff,
|
|
input_path,
|
|
} => {
|
|
write!(f, "{error}\n")?;
|
|
if let Some(input_path) = input_path
|
|
&& !diff.is_empty()
|
|
{
|
|
write!(
|
|
f,
|
|
"Edited {}:\n\n```diff\n{diff}\n```",
|
|
input_path.display()
|
|
)
|
|
} else {
|
|
write!(f, "No edits were made.")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<EditSessionOutput> for LanguageModelToolResultContent {
|
|
fn from(output: EditSessionOutput) -> Self {
|
|
output.to_string().into()
|
|
}
|
|
}
|
|
|
|
pub(crate) struct EditSessionContext {
|
|
project: Entity<Project>,
|
|
thread: WeakEntity<Thread>,
|
|
action_log: Entity<ActionLog>,
|
|
language_registry: Arc<LanguageRegistry>,
|
|
}
|
|
|
|
impl EditSessionContext {
|
|
pub(crate) fn new(
|
|
project: Entity<Project>,
|
|
thread: WeakEntity<Thread>,
|
|
action_log: Entity<ActionLog>,
|
|
language_registry: Arc<LanguageRegistry>,
|
|
) -> Self {
|
|
Self {
|
|
project,
|
|
thread,
|
|
action_log,
|
|
language_registry,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn authorize(
|
|
&self,
|
|
tool_name: &str,
|
|
path: &PathBuf,
|
|
event_stream: &ToolCallEventStream,
|
|
cx: &mut App,
|
|
) -> Task<Result<()>> {
|
|
super::tool_permissions::authorize_file_edit(
|
|
tool_name,
|
|
path,
|
|
&self.thread,
|
|
event_stream,
|
|
cx,
|
|
)
|
|
}
|
|
|
|
fn set_agent_location(&self, buffer: WeakEntity<Buffer>, position: text::Anchor, cx: &mut App) {
|
|
let should_update_agent_location = self
|
|
.thread
|
|
.read_with(cx, |thread, _cx| !thread.is_subagent())
|
|
.unwrap_or_default();
|
|
if should_update_agent_location {
|
|
self.project.update(cx, |project, cx| {
|
|
project.set_agent_location(Some(AgentLocation { buffer, position }), cx);
|
|
});
|
|
}
|
|
}
|
|
|
|
async fn ensure_buffer_saved(&self, buffer: &Entity<Buffer>, cx: &mut AsyncApp) {
|
|
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 {
|
|
self.project
|
|
.update(cx, |project, cx| {
|
|
project.format(
|
|
HashSet::from_iter([buffer.clone()]),
|
|
LspFormatTarget::Buffers,
|
|
false,
|
|
FormatTrigger::Save,
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.log_err();
|
|
}
|
|
|
|
self.project
|
|
.update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
|
|
.await
|
|
.log_err();
|
|
|
|
self.action_log.update(cx, |log, cx| {
|
|
log.buffer_edited(buffer.clone(), cx);
|
|
});
|
|
}
|
|
|
|
pub(crate) fn initial_title_from_path(
|
|
&self,
|
|
path: &std::path::Path,
|
|
default: &str,
|
|
cx: &App,
|
|
) -> SharedString {
|
|
let project = self.project.read(cx);
|
|
if let Some(project_path) = project.find_project_path(path, cx)
|
|
&& let Some(short) = project.short_full_path_for_project_path(&project_path, cx)
|
|
{
|
|
return short.into();
|
|
}
|
|
|
|
let display = path.to_string_lossy();
|
|
if display.is_empty() {
|
|
default.into()
|
|
} else {
|
|
display.into_owned().into()
|
|
}
|
|
}
|
|
|
|
pub(crate) fn replay_output(
|
|
&self,
|
|
output: EditSessionOutput,
|
|
event_stream: ToolCallEventStream,
|
|
cx: &mut App,
|
|
) -> Result<()> {
|
|
match output {
|
|
EditSessionOutput::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(())
|
|
}
|
|
EditSessionOutput::Error { .. } => Ok(()),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) enum EditSessionResult {
|
|
Completed(EditSession),
|
|
Failed {
|
|
error: String,
|
|
session: Option<EditSession>,
|
|
},
|
|
}
|
|
|
|
pub(crate) async fn run_session(
|
|
result: EditSessionResult,
|
|
event_stream: &ToolCallEventStream,
|
|
cx: &mut AsyncApp,
|
|
) -> Result<EditSessionOutput, EditSessionOutput> {
|
|
match result {
|
|
EditSessionResult::Completed(session) => {
|
|
session
|
|
.context
|
|
.ensure_buffer_saved(&session.buffer, cx)
|
|
.await;
|
|
let (new_text, diff) = session.compute_new_text_and_diff(cx).await;
|
|
Ok(EditSessionOutput::Success {
|
|
old_text: session.old_text.clone(),
|
|
new_text,
|
|
input_path: session.input_path,
|
|
diff,
|
|
})
|
|
}
|
|
EditSessionResult::Failed {
|
|
error,
|
|
session: Some(session),
|
|
} => {
|
|
session
|
|
.context
|
|
.ensure_buffer_saved(&session.buffer, cx)
|
|
.await;
|
|
let (_new_text, diff) = session.compute_new_text_and_diff(cx).await;
|
|
if diff.is_empty() {
|
|
event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![
|
|
acp::ToolCallContent::Content(acp::Content::new(error.clone())),
|
|
]));
|
|
}
|
|
Err(EditSessionOutput::Error {
|
|
error,
|
|
input_path: Some(session.input_path),
|
|
diff,
|
|
})
|
|
}
|
|
EditSessionResult::Failed {
|
|
error,
|
|
session: None,
|
|
} => {
|
|
event_stream.update_fields(acp::ToolCallUpdateFields::new().content(vec![
|
|
acp::ToolCallContent::Content(acp::Content::new(error.clone())),
|
|
]));
|
|
Err(EditSessionOutput::Error {
|
|
error,
|
|
input_path: None,
|
|
diff: String::new(),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn initial_title_from_partial_path<P>(
|
|
context: &EditSessionContext,
|
|
raw_input: serde_json::Value,
|
|
extract_path: impl FnOnce(&P) -> Option<String>,
|
|
default: &str,
|
|
cx: &App,
|
|
) -> SharedString
|
|
where
|
|
P: DeserializeOwned,
|
|
{
|
|
if let Ok(partial) = serde_json::from_value::<P>(raw_input)
|
|
&& let Some(raw_path) = extract_path(&partial)
|
|
{
|
|
let trimmed = raw_path.trim();
|
|
if !trimmed.is_empty() {
|
|
return context.initial_title_from_path(std::path::Path::new(trimmed), default, cx);
|
|
}
|
|
}
|
|
default.into()
|
|
}
|
|
|
|
pub(crate) struct EditSession {
|
|
abs_path: PathBuf,
|
|
pub(crate) input_path: PathBuf,
|
|
pub(crate) buffer: Entity<Buffer>,
|
|
pub(crate) old_text: Arc<String>,
|
|
diff: Entity<Diff>,
|
|
parser: StreamingParser,
|
|
pipeline: Pipeline,
|
|
context: Arc<EditSessionContext>,
|
|
_finalize_diff_guard: Deferred<Box<dyn FnOnce()>>,
|
|
}
|
|
|
|
/// The destination of an edit session, identified by its absolute path on
|
|
/// disk. `project_path` is `Some` for files that live inside one of the
|
|
/// project's worktrees (i.e. that the standard project-path machinery can
|
|
/// resolve), and `None` for global skill files reached through the
|
|
/// `~/.agents/skills` allowlist.
|
|
struct EditSessionTarget {
|
|
abs_path: PathBuf,
|
|
project_path: Option<ProjectPath>,
|
|
}
|
|
|
|
enum Pipeline {
|
|
Write(WritePipeline),
|
|
Edit(EditPipeline),
|
|
}
|
|
|
|
struct WritePipeline {
|
|
content_written: bool,
|
|
}
|
|
|
|
struct EditPipeline {
|
|
current_edit: Option<EditPipelineEntry>,
|
|
file_changed_since_last_read: bool,
|
|
}
|
|
|
|
enum EditPipelineEntry {
|
|
ResolvingOldText {
|
|
matcher: StreamingFuzzyMatcher,
|
|
},
|
|
StreamingNewText {
|
|
streaming_diff: StreamingDiff,
|
|
edit_cursor: usize,
|
|
reindenter: Reindenter,
|
|
original_snapshot: text::BufferSnapshot,
|
|
},
|
|
}
|
|
|
|
impl Pipeline {
|
|
fn new(mode: EditSessionMode, file_changed_since_last_read: bool) -> Self {
|
|
match mode {
|
|
EditSessionMode::Write => Self::Write(WritePipeline {
|
|
content_written: false,
|
|
}),
|
|
EditSessionMode::Edit => Self::Edit(EditPipeline {
|
|
current_edit: None,
|
|
file_changed_since_last_read,
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl WritePipeline {
|
|
fn process_event(
|
|
&mut self,
|
|
event: &WriteEvent,
|
|
buffer: &Entity<Buffer>,
|
|
context: &EditSessionContext,
|
|
cx: &mut AsyncApp,
|
|
) {
|
|
let WriteEvent::ContentChunk { chunk } = event;
|
|
|
|
let (buffer_id, buffer_len) =
|
|
buffer.read_with(cx, |buffer, _cx| (buffer.remote_id(), buffer.len()));
|
|
let edit_range = if self.content_written {
|
|
buffer_len..buffer_len
|
|
} else {
|
|
0..buffer_len
|
|
};
|
|
|
|
agent_edit_buffer(
|
|
buffer,
|
|
[(edit_range, chunk.as_str())],
|
|
&context.action_log,
|
|
cx,
|
|
);
|
|
cx.update(|cx| {
|
|
context.set_agent_location(
|
|
buffer.downgrade(),
|
|
text::Anchor::max_for_buffer(buffer_id),
|
|
cx,
|
|
);
|
|
});
|
|
self.content_written = true;
|
|
}
|
|
}
|
|
|
|
impl EditPipeline {
|
|
fn ensure_resolving_old_text(&mut self, buffer: &Entity<Buffer>, cx: &mut AsyncApp) {
|
|
if self.current_edit.is_none() {
|
|
let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot());
|
|
self.current_edit = Some(EditPipelineEntry::ResolvingOldText {
|
|
matcher: StreamingFuzzyMatcher::new(snapshot),
|
|
});
|
|
}
|
|
}
|
|
|
|
fn process_event(
|
|
&mut self,
|
|
event: &EditEvent,
|
|
buffer: &Entity<Buffer>,
|
|
diff: &Entity<Diff>,
|
|
abs_path: &PathBuf,
|
|
context: &EditSessionContext,
|
|
event_stream: &ToolCallEventStream,
|
|
cx: &mut AsyncApp,
|
|
) -> Result<(), String> {
|
|
match event {
|
|
EditEvent::OldTextChunk {
|
|
chunk, done: false, ..
|
|
} => {
|
|
log::debug!("old_text_chunk: done=false, chunk='{}'", chunk);
|
|
self.ensure_resolving_old_text(buffer, cx);
|
|
|
|
if let Some(EditPipelineEntry::ResolvingOldText { matcher }) =
|
|
&mut self.current_edit
|
|
&& !chunk.is_empty()
|
|
{
|
|
if let Some(match_range) = matcher.push(chunk, None) {
|
|
let anchor_range = buffer.read_with(cx, |buffer, _cx| {
|
|
buffer.anchor_range_outside(match_range.clone())
|
|
});
|
|
diff.update(cx, |diff, cx| diff.reveal_range(anchor_range, cx));
|
|
|
|
cx.update(|cx| {
|
|
let position = buffer.read(cx).anchor_before(match_range.end);
|
|
context.set_agent_location(buffer.downgrade(), position, cx);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
EditEvent::OldTextChunk {
|
|
edit_index,
|
|
chunk,
|
|
done: true,
|
|
} => {
|
|
log::debug!("old_text_chunk: done=true, chunk='{}'", chunk);
|
|
|
|
self.ensure_resolving_old_text(buffer, cx);
|
|
|
|
let Some(EditPipelineEntry::ResolvingOldText { matcher }) = &mut self.current_edit
|
|
else {
|
|
return Ok(());
|
|
};
|
|
|
|
if !chunk.is_empty() {
|
|
matcher.push(chunk, None);
|
|
}
|
|
let range = extract_match(
|
|
matcher.finish(),
|
|
buffer,
|
|
edit_index,
|
|
self.file_changed_since_last_read,
|
|
cx,
|
|
)?;
|
|
|
|
let anchor_range =
|
|
buffer.read_with(cx, |buffer, _cx| buffer.anchor_range_outside(range.clone()));
|
|
diff.update(cx, |diff, cx| diff.reveal_range(anchor_range, cx));
|
|
|
|
let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
|
|
|
|
let line = snapshot.offset_to_point(range.start).row;
|
|
event_stream.update_fields(
|
|
ToolCallUpdateFields::new()
|
|
.locations(vec![ToolCallLocation::new(abs_path).line(Some(line))]),
|
|
);
|
|
|
|
let buffer_indent = snapshot.line_indent_for_row(line);
|
|
let query_indent = text::LineIndent::from_iter(
|
|
matcher
|
|
.query_lines()
|
|
.first()
|
|
.map(|s| s.as_str())
|
|
.unwrap_or("")
|
|
.chars(),
|
|
);
|
|
let indent_delta = compute_indent_delta(buffer_indent, query_indent);
|
|
|
|
let old_text_in_buffer = snapshot.text_for_range(range.clone()).collect::<String>();
|
|
|
|
log::debug!(
|
|
"edit[{}] old_text matched at {}..{}: {:?}",
|
|
edit_index,
|
|
range.start,
|
|
range.end,
|
|
old_text_in_buffer,
|
|
);
|
|
|
|
let text_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.text_snapshot());
|
|
self.current_edit = Some(EditPipelineEntry::StreamingNewText {
|
|
streaming_diff: StreamingDiff::new(old_text_in_buffer),
|
|
edit_cursor: range.start,
|
|
reindenter: Reindenter::new(indent_delta),
|
|
original_snapshot: text_snapshot,
|
|
});
|
|
|
|
cx.update(|cx| {
|
|
let position = buffer.read(cx).anchor_before(range.end);
|
|
context.set_agent_location(buffer.downgrade(), position, cx);
|
|
});
|
|
}
|
|
EditEvent::NewTextChunk {
|
|
chunk, done: false, ..
|
|
} => {
|
|
log::debug!("new_text_chunk: done=false, chunk='{}'", chunk);
|
|
|
|
let Some(EditPipelineEntry::StreamingNewText {
|
|
streaming_diff,
|
|
edit_cursor,
|
|
reindenter,
|
|
original_snapshot,
|
|
..
|
|
}) = &mut self.current_edit
|
|
else {
|
|
return Ok(());
|
|
};
|
|
|
|
let reindented = reindenter.push(chunk);
|
|
if reindented.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let char_ops = streaming_diff.push_new(&reindented);
|
|
apply_char_operations(
|
|
&char_ops,
|
|
buffer,
|
|
original_snapshot,
|
|
edit_cursor,
|
|
&context.action_log,
|
|
cx,
|
|
);
|
|
|
|
let position = original_snapshot.anchor_before(*edit_cursor);
|
|
cx.update(|cx| {
|
|
context.set_agent_location(buffer.downgrade(), position, cx);
|
|
});
|
|
}
|
|
EditEvent::NewTextChunk {
|
|
chunk, done: true, ..
|
|
} => {
|
|
log::debug!("new_text_chunk: done=true, chunk='{}'", chunk);
|
|
|
|
let Some(EditPipelineEntry::StreamingNewText {
|
|
mut streaming_diff,
|
|
mut edit_cursor,
|
|
mut reindenter,
|
|
original_snapshot,
|
|
}) = self.current_edit.take()
|
|
else {
|
|
return Ok(());
|
|
};
|
|
|
|
let mut final_text = reindenter.push(chunk);
|
|
final_text.push_str(&reindenter.finish());
|
|
|
|
log::debug!("new_text_chunk: done=true, final_text='{}'", final_text);
|
|
|
|
let mut char_ops = if final_text.is_empty() {
|
|
Vec::new()
|
|
} else {
|
|
streaming_diff.push_new(&final_text)
|
|
};
|
|
char_ops.extend(streaming_diff.finish());
|
|
apply_char_operations(
|
|
&char_ops,
|
|
buffer,
|
|
&original_snapshot,
|
|
&mut edit_cursor,
|
|
&context.action_log,
|
|
cx,
|
|
);
|
|
|
|
let position = original_snapshot.anchor_before(edit_cursor);
|
|
cx.update(|cx| {
|
|
context.set_agent_location(buffer.downgrade(), position, cx);
|
|
});
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl EditSession {
|
|
pub(crate) async fn new(
|
|
path: PathBuf,
|
|
mode: EditSessionMode,
|
|
tool_name: &str,
|
|
context: Arc<EditSessionContext>,
|
|
event_stream: &ToolCallEventStream,
|
|
cx: &mut AsyncApp,
|
|
) -> Result<Self, String> {
|
|
let target = if let Some(abs_path) =
|
|
resolve_global_skill_path_for_edit_session(mode, &path, &context, cx).await?
|
|
{
|
|
EditSessionTarget {
|
|
abs_path,
|
|
project_path: None,
|
|
}
|
|
} else {
|
|
let project_path = cx.update(|cx| resolve_path(mode, &path, &context.project, cx))?;
|
|
|
|
let Some(abs_path) =
|
|
cx.update(|cx| context.project.read(cx).absolute_path(&project_path, cx))
|
|
else {
|
|
return Err(format!(
|
|
"Worktree at '{}' does not exist",
|
|
path.to_string_lossy()
|
|
));
|
|
};
|
|
|
|
EditSessionTarget {
|
|
abs_path,
|
|
project_path: Some(project_path),
|
|
}
|
|
};
|
|
let EditSessionTarget {
|
|
abs_path,
|
|
project_path,
|
|
} = target;
|
|
|
|
event_stream.update_fields(
|
|
ToolCallUpdateFields::new().locations(vec![ToolCallLocation::new(abs_path.clone())]),
|
|
);
|
|
|
|
cx.update(|cx| context.authorize(tool_name, &path, event_stream, cx))
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let buffer = match project_path {
|
|
Some(project_path) => context
|
|
.project
|
|
.update(cx, |project, cx| project.open_buffer(project_path, cx))
|
|
.await
|
|
.map_err(|e| e.to_string())?,
|
|
None => context
|
|
.project
|
|
.update(cx, |project, cx| {
|
|
project.open_local_buffer(abs_path.clone(), cx)
|
|
})
|
|
.await
|
|
.map_err(|e| e.to_string())?,
|
|
};
|
|
|
|
let file_changed_since_last_read =
|
|
ensure_buffer_saved(&buffer, &abs_path, mode, &context, event_stream, cx).await?;
|
|
|
|
let diff = cx.new(|cx| Diff::new(buffer.clone(), cx));
|
|
event_stream.update_diff(diff.clone());
|
|
let finalize_diff_guard = util::defer(Box::new({
|
|
let diff = diff.downgrade();
|
|
let mut cx = cx.clone();
|
|
move || {
|
|
diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok();
|
|
}
|
|
}) as Box<dyn FnOnce()>);
|
|
|
|
context.action_log.update(cx, |log, cx| match mode {
|
|
EditSessionMode::Write => log.buffer_created(buffer.clone(), cx),
|
|
EditSessionMode::Edit => log.buffer_read(buffer.clone(), cx),
|
|
});
|
|
|
|
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;
|
|
|
|
Ok(Self {
|
|
abs_path,
|
|
input_path: path,
|
|
buffer,
|
|
old_text,
|
|
diff,
|
|
parser: StreamingParser::default(),
|
|
pipeline: Pipeline::new(mode, file_changed_since_last_read),
|
|
context,
|
|
_finalize_diff_guard: finalize_diff_guard,
|
|
})
|
|
}
|
|
|
|
pub(crate) async fn finalize_edit(
|
|
&mut self,
|
|
edits: Vec<Edit>,
|
|
event_stream: &ToolCallEventStream,
|
|
cx: &mut AsyncApp,
|
|
) -> Result<(), String> {
|
|
let Self {
|
|
abs_path,
|
|
buffer,
|
|
diff,
|
|
parser,
|
|
pipeline,
|
|
context,
|
|
..
|
|
} = self;
|
|
let Pipeline::Edit(edit_pipeline) = pipeline else {
|
|
return Err("Cannot finalize edits on a write session".to_string());
|
|
};
|
|
|
|
for event in &parser.finalize_edits(&edits) {
|
|
edit_pipeline.process_event(
|
|
event,
|
|
buffer,
|
|
diff,
|
|
abs_path,
|
|
context,
|
|
event_stream,
|
|
cx,
|
|
)?;
|
|
}
|
|
|
|
if log::log_enabled!(log::Level::Debug) {
|
|
log::debug!("Got edits:");
|
|
for edit in &edits {
|
|
log::debug!(
|
|
" old_text: '{}', new_text: '{}'",
|
|
edit.old_text.replace('\n', "\\n"),
|
|
edit.new_text.replace('\n', "\\n")
|
|
);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn finalize_write(
|
|
&mut self,
|
|
content: &str,
|
|
cx: &mut AsyncApp,
|
|
) -> Result<(), String> {
|
|
let Self {
|
|
buffer,
|
|
parser,
|
|
pipeline,
|
|
context,
|
|
..
|
|
} = self;
|
|
let Pipeline::Write(write) = pipeline else {
|
|
return Err("Cannot finalize a write on an edit session".to_string());
|
|
};
|
|
|
|
for event in &parser.finalize_content(content) {
|
|
write.process_event(event, buffer, context, cx);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn compute_new_text_and_diff(&self, cx: &mut AsyncApp) -> (String, String) {
|
|
let new_snapshot = self.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 = self.old_text.clone();
|
|
async move {
|
|
let new_text = new_snapshot.text();
|
|
let diff = language::unified_diff(&old_text, &new_text);
|
|
(new_text, diff)
|
|
}
|
|
})
|
|
.await;
|
|
(new_text, unified_diff)
|
|
}
|
|
|
|
pub(crate) fn process_edit(
|
|
&mut self,
|
|
edits: Option<&[PartialEdit]>,
|
|
event_stream: &ToolCallEventStream,
|
|
cx: &mut AsyncApp,
|
|
) -> Result<(), String> {
|
|
let Self {
|
|
abs_path,
|
|
buffer,
|
|
diff,
|
|
parser,
|
|
pipeline,
|
|
context,
|
|
..
|
|
} = self;
|
|
let Pipeline::Edit(edit_pipeline) = pipeline else {
|
|
return Err("Cannot apply partial edits on a write session".to_string());
|
|
};
|
|
let Some(edits) = edits else {
|
|
return Ok(());
|
|
};
|
|
for event in &parser.push_edits(edits) {
|
|
edit_pipeline.process_event(
|
|
event,
|
|
buffer,
|
|
diff,
|
|
abs_path,
|
|
context,
|
|
event_stream,
|
|
cx,
|
|
)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn process_write(
|
|
&mut self,
|
|
content: Option<&str>,
|
|
cx: &mut AsyncApp,
|
|
) -> Result<(), String> {
|
|
let Self {
|
|
buffer,
|
|
parser,
|
|
pipeline,
|
|
context,
|
|
..
|
|
} = self;
|
|
let Pipeline::Write(write) = pipeline else {
|
|
return Err("Cannot apply partial content on an edit session".to_string());
|
|
};
|
|
let Some(content) = content else {
|
|
return Ok(());
|
|
};
|
|
for event in &parser.push_content(content) {
|
|
write.process_event(event, buffer, context, cx);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn apply_char_operations(
|
|
ops: &[CharOperation],
|
|
buffer: &Entity<Buffer>,
|
|
snapshot: &text::BufferSnapshot,
|
|
edit_cursor: &mut usize,
|
|
action_log: &Entity<ActionLog>,
|
|
cx: &mut AsyncApp,
|
|
) {
|
|
let mut edits: Vec<_> = Vec::new();
|
|
for op in ops {
|
|
match op {
|
|
CharOperation::Insert { text } => {
|
|
let anchor = snapshot.anchor_after(*edit_cursor);
|
|
edits.push((anchor..anchor, text.as_str().into()));
|
|
}
|
|
CharOperation::Delete { bytes } => {
|
|
let delete_end = *edit_cursor + bytes;
|
|
let anchor_range = snapshot.anchor_range_inside(*edit_cursor..delete_end);
|
|
edits.push((anchor_range, Arc::<str>::from("")));
|
|
*edit_cursor = delete_end;
|
|
}
|
|
CharOperation::Keep { bytes } => {
|
|
*edit_cursor += bytes;
|
|
}
|
|
}
|
|
}
|
|
if !edits.is_empty() {
|
|
agent_edit_buffer(buffer, edits, action_log, cx);
|
|
}
|
|
}
|
|
|
|
fn extract_match(
|
|
matches: Vec<Range<usize>>,
|
|
buffer: &Entity<Buffer>,
|
|
edit_index: &usize,
|
|
file_changed_since_last_read: bool,
|
|
cx: &mut AsyncApp,
|
|
) -> Result<Range<usize>, String> {
|
|
let file_changed_since_last_read_message = if file_changed_since_last_read {
|
|
" The file has changed on disk since you last read it."
|
|
} else {
|
|
""
|
|
};
|
|
|
|
match matches.len() {
|
|
0 => Err(format!(
|
|
"Could not find matching text for edit at index {}. \
|
|
The old_text did not match any content in the file.{} \
|
|
Please read the file again to get the current content.",
|
|
edit_index, file_changed_since_last_read_message,
|
|
)),
|
|
1 => Ok(matches.into_iter().next().unwrap()),
|
|
_ => {
|
|
let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
|
|
let lines = matches
|
|
.iter()
|
|
.map(|range| (snapshot.offset_to_point(range.start).row + 1).to_string())
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
Err(format!(
|
|
"Edit {} matched multiple locations in the file at lines: {}. \
|
|
Please provide more context in old_text to uniquely \
|
|
identify the location.",
|
|
edit_index, lines
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Edits a buffer and reports the edit to the action log in the same effect
|
|
/// cycle. This ensures the action log's subscription handler sees the version
|
|
/// already updated by `buffer_edited`, so it does not misattribute the agent's
|
|
/// edit as a user edit.
|
|
fn agent_edit_buffer<I, S, T>(
|
|
buffer: &Entity<Buffer>,
|
|
edits: I,
|
|
action_log: &Entity<ActionLog>,
|
|
cx: &mut AsyncApp,
|
|
) where
|
|
I: IntoIterator<Item = (Range<S>, T)>,
|
|
S: ToOffset,
|
|
T: Into<Arc<str>>,
|
|
{
|
|
cx.update(|cx| {
|
|
buffer.update(cx, |buffer, cx| {
|
|
buffer.start_transaction();
|
|
buffer.edit(edits, None, cx);
|
|
buffer.end_transaction_with_source(BufferEditSource::Agent, cx);
|
|
});
|
|
action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx));
|
|
});
|
|
}
|
|
|
|
async fn ensure_buffer_saved(
|
|
buffer: &Entity<Buffer>,
|
|
abs_path: &PathBuf,
|
|
mode: EditSessionMode,
|
|
context: &EditSessionContext,
|
|
event_stream: &ToolCallEventStream,
|
|
cx: &mut AsyncApp,
|
|
) -> Result<bool, String> {
|
|
let last_read_mtime = context
|
|
.action_log
|
|
.read_with(cx, |log, _| log.file_read_time(abs_path));
|
|
let (current_mtime, is_dirty) = buffer.read_with(cx, |buffer, _cx| {
|
|
let current = buffer.file().and_then(|file| file.disk_state().mtime());
|
|
let dirty = buffer.is_dirty();
|
|
(current, dirty)
|
|
});
|
|
|
|
if is_dirty {
|
|
resolve_dirty_buffer(buffer, mode, context, event_stream, cx).await?;
|
|
}
|
|
|
|
if let (Some(last_read), Some(current)) = (last_read_mtime, current_mtime)
|
|
&& current != last_read
|
|
{
|
|
return Ok(true);
|
|
}
|
|
|
|
Ok(false)
|
|
}
|
|
|
|
/// Prompts the user about how to handle a dirty buffer that the agent
|
|
/// wants to edit (`EditSessionMode::Edit`) or overwrite
|
|
/// (`EditSessionMode::Write`), and performs the chosen action so the
|
|
/// edit session can proceed (or returns `Err` to cancel).
|
|
///
|
|
/// If the user resolves the dirty state externally (e.g. cmd-s or
|
|
/// reload) while the prompt is visible, the prompt is dismissed
|
|
/// automatically.
|
|
async fn resolve_dirty_buffer(
|
|
buffer: &Entity<Buffer>,
|
|
mode: EditSessionMode,
|
|
context: &EditSessionContext,
|
|
event_stream: &ToolCallEventStream,
|
|
cx: &mut AsyncApp,
|
|
) -> Result<(), String> {
|
|
let (manual_resolve_tx, manual_resolve_rx) = oneshot::channel::<()>();
|
|
let _buffer_subscription = cx.update(|cx| {
|
|
let mut tx = Some(manual_resolve_tx);
|
|
cx.subscribe(buffer, move |buffer, event: &BufferEvent, cx| {
|
|
if matches!(
|
|
event,
|
|
BufferEvent::Saved | BufferEvent::Reloaded | BufferEvent::DirtyChanged
|
|
) && !buffer.read(cx).is_dirty()
|
|
&& let Some(tx) = tx.take()
|
|
{
|
|
tx.send(()).ok();
|
|
}
|
|
})
|
|
});
|
|
|
|
let prompt_kind = match mode {
|
|
EditSessionMode::Edit => super::tool_permissions::DirtyBufferPromptKind::Edit,
|
|
EditSessionMode::Write => super::tool_permissions::DirtyBufferPromptKind::Overwrite,
|
|
};
|
|
let prompt = cx.update(|cx| {
|
|
super::tool_permissions::authorize_dirty_buffer(prompt_kind, event_stream, cx)
|
|
});
|
|
|
|
let decision = futures::select_biased! {
|
|
_ = manual_resolve_rx.fuse() => {
|
|
None
|
|
}
|
|
decision = prompt.fuse() => {
|
|
Some(decision.map_err(|e| e.to_string())?)
|
|
}
|
|
};
|
|
|
|
let Some(decision) = decision else {
|
|
let outcome = match mode {
|
|
EditSessionMode::Edit => acp_thread::SelectedPermissionOutcome::new(
|
|
acp::PermissionOptionId::new("save"),
|
|
acp::PermissionOptionKind::AllowOnce,
|
|
),
|
|
EditSessionMode::Write => acp_thread::SelectedPermissionOutcome::new(
|
|
acp::PermissionOptionId::new("keep"),
|
|
acp::PermissionOptionKind::RejectOnce,
|
|
),
|
|
};
|
|
event_stream.resolve_authorization(outcome);
|
|
return match mode {
|
|
EditSessionMode::Edit => Ok(()),
|
|
EditSessionMode::Write => Err(
|
|
"The user saved their unsaved changes while the prompt was visible; \
|
|
the file overwrite was cancelled to preserve them. Ask the user how \
|
|
they'd like to proceed before retrying."
|
|
.to_string(),
|
|
),
|
|
};
|
|
};
|
|
|
|
match decision {
|
|
super::tool_permissions::DirtyBufferDecision::Save => {
|
|
context
|
|
.project
|
|
.update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
|
|
.await
|
|
.map_err(|e| format!("Failed to save buffer: {e}"))?;
|
|
}
|
|
super::tool_permissions::DirtyBufferDecision::Discard => {
|
|
context
|
|
.project
|
|
.update(cx, |project, cx| {
|
|
project.reload_buffers(HashSet::from_iter([buffer.clone()]), false, cx)
|
|
})
|
|
.await
|
|
.map_err(|e| format!("Failed to discard unsaved changes: {e}"))?;
|
|
}
|
|
super::tool_permissions::DirtyBufferDecision::Keep => {
|
|
let error = "The user chose to keep their unsaved changes; the file overwrite \
|
|
was cancelled. Ask the user how they'd like to proceed before \
|
|
retrying."
|
|
.to_string();
|
|
event_stream.update_fields(
|
|
acp::ToolCallUpdateFields::new().content(vec![error.clone().into()]),
|
|
);
|
|
return Err(error);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Mirrors [`resolve_path`]'s pre-auth validation for the global-skill
|
|
/// branch: returns `Ok(Some(abs_path))` if the path lives under
|
|
/// `~/.agents/skills` and is in a valid state for the requested mode,
|
|
/// `Ok(None)` if the path isn't a global skill at all (so the caller should
|
|
/// fall through to project-path resolution), or `Err(message)` if the path
|
|
/// is a global skill but can't be used (missing in Edit mode, parent
|
|
/// missing in Write mode, etc.).
|
|
///
|
|
/// Errors returned from here surface to the model as tool-result errors
|
|
/// without prompting the user — same contract as [`resolve_path`]. The
|
|
/// idea is that "file doesn't exist" or "parent isn't a directory" are
|
|
/// model mistakes, not decisions the user should be asked to approve.
|
|
async fn resolve_global_skill_path_for_edit_session(
|
|
mode: EditSessionMode,
|
|
path: &PathBuf,
|
|
context: &EditSessionContext,
|
|
cx: &mut AsyncApp,
|
|
) -> Result<Option<PathBuf>, String> {
|
|
let fs = context
|
|
.project
|
|
.read_with(cx, |project, _cx| project.fs().clone());
|
|
let Some(abs_path) = resolve_creatable_global_skill_path(path, fs.as_ref()).await else {
|
|
return Ok(None);
|
|
};
|
|
|
|
match mode {
|
|
EditSessionMode::Edit => {
|
|
let metadata = fs
|
|
.metadata(&abs_path)
|
|
.await
|
|
.map_err(|e| format!("Can't edit file: {e}"))?
|
|
.ok_or_else(|| "Can't edit file: path not found".to_string())?;
|
|
if metadata.is_dir {
|
|
return Err("Can't edit file: path is a directory".to_string());
|
|
}
|
|
}
|
|
EditSessionMode::Write => {
|
|
if let Some(metadata) = fs
|
|
.metadata(&abs_path)
|
|
.await
|
|
.map_err(|e| format!("Can't write to file: {e}"))?
|
|
{
|
|
if metadata.is_dir {
|
|
return Err("Can't write to file: path is a directory".to_string());
|
|
}
|
|
} else {
|
|
let parent_path = abs_path
|
|
.parent()
|
|
.ok_or_else(|| "Can't create file: incorrect path".to_string())?;
|
|
let parent_metadata = fs
|
|
.metadata(parent_path)
|
|
.await
|
|
.map_err(|e| format!("Can't create file: {e}"))?
|
|
.ok_or_else(|| {
|
|
"Can't create file: parent directory doesn't exist".to_string()
|
|
})?;
|
|
if !parent_metadata.is_dir {
|
|
return Err("Can't create file: parent is not a directory".to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(Some(abs_path))
|
|
}
|
|
|
|
fn resolve_path(
|
|
mode: EditSessionMode,
|
|
path: &PathBuf,
|
|
project: &Entity<Project>,
|
|
cx: &mut App,
|
|
) -> Result<ProjectPath, String> {
|
|
let project = project.read(cx);
|
|
|
|
match mode {
|
|
EditSessionMode::Edit => {
|
|
let path = project
|
|
.find_project_path(&path, cx)
|
|
.ok_or_else(|| "Can't edit file: path not found".to_string())?;
|
|
|
|
let entry = project
|
|
.entry_for_path(&path, cx)
|
|
.ok_or_else(|| "Can't edit file: path not found".to_string())?;
|
|
|
|
if entry.is_file() {
|
|
Ok(path)
|
|
} else {
|
|
Err("Can't edit file: path is a directory".to_string())
|
|
}
|
|
}
|
|
EditSessionMode::Write => {
|
|
if let Some(path) = project.find_project_path(&path, cx)
|
|
&& let Some(entry) = project.entry_for_path(&path, cx)
|
|
{
|
|
if entry.is_file() {
|
|
return Ok(path);
|
|
} else {
|
|
return Err("Can't write to file: path is a directory".to_string());
|
|
}
|
|
}
|
|
|
|
let parent_path = path
|
|
.parent()
|
|
.ok_or_else(|| "Can't create file: incorrect path".to_string())?;
|
|
|
|
let parent_project_path = project.find_project_path(&parent_path, cx);
|
|
|
|
let parent_entry = parent_project_path
|
|
.as_ref()
|
|
.and_then(|path| project.entry_for_path(path, cx))
|
|
.ok_or_else(|| "Can't create file: parent directory doesn't exist")?;
|
|
|
|
if !parent_entry.is_dir() {
|
|
return Err("Can't create file: parent is not a directory".to_string());
|
|
}
|
|
|
|
let file_name = path
|
|
.file_name()
|
|
.and_then(|file_name| file_name.to_str())
|
|
.and_then(|file_name| RelPath::unix(file_name).ok())
|
|
.ok_or_else(|| "Can't create file: invalid filename".to_string())?;
|
|
|
|
let new_file_path = parent_project_path.map(|parent| ProjectPath {
|
|
path: parent.path.join(file_name),
|
|
..parent
|
|
});
|
|
|
|
new_file_path.ok_or_else(|| "Can't create file".to_string())
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) async fn test_resolve_path(
|
|
mode: &EditSessionMode,
|
|
path: &str,
|
|
project: &Entity<Project>,
|
|
cx: &mut gpui::TestAppContext,
|
|
) -> Result<ProjectPath, String> {
|
|
cx.update(|cx| resolve_path(*mode, &PathBuf::from(path), project, cx))
|
|
}
|