Make WorkflowStepResolution an entity (#16268)

Max Brunsfeld created

This PR is just a refactor, to pave the way toward adding a view for
workflow step resolution. The entity carries the state of the tool
call's streaming output.

Release Notes:

- N/A

Change summary

crates/assistant/src/assistant.rs           |   2 
crates/assistant/src/assistant_panel.rs     |  18 
crates/assistant/src/context.rs             | 762 +---------------------
crates/assistant/src/context_inspector.rs   |  14 
crates/assistant/src/workflow.rs            | 672 ++++++++++++++++++++
crates/language_model/src/language_model.rs |  10 
6 files changed, 753 insertions(+), 725 deletions(-)

Detailed changes

crates/assistant/src/assistant.rs 🔗

@@ -13,6 +13,7 @@ mod slash_command;
 pub mod slash_command_settings;
 mod streaming_diff;
 mod terminal_inline_assistant;
+mod workflow;
 
 pub use assistant_panel::{AssistantPanel, AssistantPanelEvent};
 use assistant_settings::AssistantSettings;
@@ -43,6 +44,7 @@ use slash_command::{
 use std::sync::Arc;
 pub(crate) use streaming_diff::*;
 use util::ResultExt;
+pub use workflow::*;
 
 use crate::slash_command_settings::SlashCommandSettings;
 

crates/assistant/src/assistant_panel.rs 🔗

@@ -1884,9 +1884,8 @@ impl ContextEditor {
         range: Range<language::Anchor>,
         cx: &mut ViewContext<Self>,
     ) {
-        self.context.update(cx, |context, cx| {
-            context.resolve_workflow_step(range, self.project.clone(), cx)
-        });
+        self.context
+            .update(cx, |context, cx| context.resolve_workflow_step(range, cx));
     }
 
     fn stop_workflow_step(&mut self, range: Range<language::Anchor>, cx: &mut ViewContext<Self>) {
@@ -2010,19 +2009,16 @@ impl ContextEditor {
                     .text_for_range(step.tagged_range.clone())
                     .collect::<String>()
             ));
-            match &step.status {
-                crate::WorkflowStepStatus::Resolved(ResolvedWorkflowStep {
-                    title,
-                    suggestions,
-                }) => {
+            match &step.resolution.read(cx).result {
+                Some(Ok(ResolvedWorkflowStep { title, suggestions })) => {
                     output.push_str("Resolution:\n");
                     output.push_str(&format!("  {:?}\n", title));
                     output.push_str(&format!("  {:?}\n", suggestions));
                 }
-                crate::WorkflowStepStatus::Pending(_) => {
+                None => {
                     output.push_str("Resolution: Pending\n");
                 }
-                crate::WorkflowStepStatus::Error(error) => {
+                Some(Err(error)) => {
                     writeln!(output, "Resolution: Error\n{:?}", error).unwrap();
                 }
             }
@@ -2485,7 +2481,7 @@ impl ContextEditor {
             return;
         };
 
-        let resolved_step = step.status.into_resolved();
+        let resolved_step = step.resolution.read(cx).result.clone();
         if let Some(existing_step) = self.workflow_steps.get_mut(&step_range) {
             existing_step.resolved_step = resolved_step;
             if let Some(debug) = self.debug_inspector.as_mut() {

crates/assistant/src/context.rs 🔗

@@ -1,6 +1,6 @@
 use crate::{
-    prompts::PromptBuilder, slash_command::SlashCommandLine, AssistantPanel, InlineAssistId,
-    InlineAssistant, MessageId, MessageStatus,
+    prompts::PromptBuilder, slash_command::SlashCommandLine, workflow::WorkflowStepResolution,
+    MessageId, MessageStatus,
 };
 use anyhow::{anyhow, Context as _, Result};
 use assistant_slash_command::{
@@ -9,34 +9,25 @@ use assistant_slash_command::{
 use client::{self, proto, telemetry::Telemetry};
 use clock::ReplicaId;
 use collections::{HashMap, HashSet};
-use editor::Editor;
 use fs::{Fs, RemoveOptions};
-use futures::{
-    future::{self, Shared},
-    stream::FuturesUnordered,
-    FutureExt, StreamExt,
-};
+use futures::{future::Shared, stream::FuturesUnordered, FutureExt, StreamExt};
 use gpui::{
-    AppContext, Context as _, EventEmitter, Image, Model, ModelContext, RenderImage, Subscription,
-    Task, UpdateGlobal, View, WeakView,
+    AppContext, Context as _, EventEmitter, Image, Model, ModelContext, RenderImage, SharedString,
+    Subscription, Task,
 };
 
-use language::{
-    AnchorRangeExt, Bias, Buffer, BufferSnapshot, LanguageRegistry, OffsetRangeExt, ParseStatus,
-    Point, ToOffset,
-};
+use language::{AnchorRangeExt, Bias, Buffer, LanguageRegistry, OffsetRangeExt, Point, ToOffset};
 use language_model::{
     LanguageModelImage, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage,
-    LanguageModelTool, Role,
+    Role,
 };
 use open_ai::Model as OpenAiModel;
 use paths::{context_images_dir, contexts_dir};
 use project::Project;
-use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use smallvec::SmallVec;
 use std::{
-    cmp::{self, Ordering},
+    cmp::Ordering,
     collections::hash_map,
     fmt::Debug,
     iter, mem,
@@ -46,10 +37,8 @@ use std::{
     time::{Duration, Instant},
 };
 use telemetry_events::AssistantKind;
-use ui::{SharedString, WindowContext};
 use util::{post_inc, ResultExt, TryFutureExt};
 use uuid::Uuid;
-use workspace::Workspace;
 
 #[derive(Clone, Eq, PartialEq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
 pub struct ContextId(String);
@@ -408,250 +397,17 @@ struct PendingCompletion {
 #[derive(Copy, Clone, Debug, Hash, Eq, PartialEq)]
 pub struct SlashCommandId(clock::Lamport);
 
-#[derive(Debug)]
 pub struct WorkflowStep {
     pub tagged_range: Range<language::Anchor>,
-    pub status: WorkflowStepStatus,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct ResolvedWorkflowStep {
-    pub title: String,
-    pub suggestions: HashMap<Model<Buffer>, Vec<WorkflowSuggestionGroup>>,
-}
-
-pub enum WorkflowStepStatus {
-    Pending(Task<Option<()>>),
-    Resolved(ResolvedWorkflowStep),
-    Error(Arc<anyhow::Error>),
-}
-
-impl WorkflowStepStatus {
-    pub fn into_resolved(&self) -> Option<Result<ResolvedWorkflowStep, Arc<anyhow::Error>>> {
-        match self {
-            WorkflowStepStatus::Resolved(resolved) => Some(Ok(resolved.clone())),
-            WorkflowStepStatus::Error(error) => Some(Err(error.clone())),
-            WorkflowStepStatus::Pending(_) => None,
-        }
-    }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct WorkflowSuggestionGroup {
-    pub context_range: Range<language::Anchor>,
-    pub suggestions: Vec<WorkflowSuggestion>,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum WorkflowSuggestion {
-    Update {
-        range: Range<language::Anchor>,
-        description: String,
-    },
-    CreateFile {
-        description: String,
-    },
-    InsertSiblingBefore {
-        position: language::Anchor,
-        description: String,
-    },
-    InsertSiblingAfter {
-        position: language::Anchor,
-        description: String,
-    },
-    PrependChild {
-        position: language::Anchor,
-        description: String,
-    },
-    AppendChild {
-        position: language::Anchor,
-        description: String,
-    },
-    Delete {
-        range: Range<language::Anchor>,
-    },
-}
-
-impl WorkflowSuggestion {
-    pub fn range(&self) -> Range<language::Anchor> {
-        match self {
-            WorkflowSuggestion::Update { range, .. } => range.clone(),
-            WorkflowSuggestion::CreateFile { .. } => language::Anchor::MIN..language::Anchor::MAX,
-            WorkflowSuggestion::InsertSiblingBefore { position, .. }
-            | WorkflowSuggestion::InsertSiblingAfter { position, .. }
-            | WorkflowSuggestion::PrependChild { position, .. }
-            | WorkflowSuggestion::AppendChild { position, .. } => *position..*position,
-            WorkflowSuggestion::Delete { range } => range.clone(),
-        }
-    }
-
-    pub fn description(&self) -> Option<&str> {
-        match self {
-            WorkflowSuggestion::Update { description, .. }
-            | WorkflowSuggestion::CreateFile { description }
-            | WorkflowSuggestion::InsertSiblingBefore { description, .. }
-            | WorkflowSuggestion::InsertSiblingAfter { description, .. }
-            | WorkflowSuggestion::PrependChild { description, .. }
-            | WorkflowSuggestion::AppendChild { description, .. } => Some(description),
-            WorkflowSuggestion::Delete { .. } => None,
-        }
-    }
-
-    fn description_mut(&mut self) -> Option<&mut String> {
-        match self {
-            WorkflowSuggestion::Update { description, .. }
-            | WorkflowSuggestion::CreateFile { description }
-            | WorkflowSuggestion::InsertSiblingBefore { description, .. }
-            | WorkflowSuggestion::InsertSiblingAfter { description, .. }
-            | WorkflowSuggestion::PrependChild { description, .. }
-            | WorkflowSuggestion::AppendChild { description, .. } => Some(description),
-            WorkflowSuggestion::Delete { .. } => None,
-        }
-    }
-
-    fn try_merge(&mut self, other: &Self, buffer: &BufferSnapshot) -> bool {
-        let range = self.range();
-        let other_range = other.range();
-
-        // Don't merge if we don't contain the other suggestion.
-        if range.start.cmp(&other_range.start, buffer).is_gt()
-            || range.end.cmp(&other_range.end, buffer).is_lt()
-        {
-            return false;
-        }
-
-        if let Some(description) = self.description_mut() {
-            if let Some(other_description) = other.description() {
-                description.push('\n');
-                description.push_str(other_description);
-            }
-        }
-        true
-    }
-
-    pub fn show(
-        &self,
-        editor: &View<Editor>,
-        excerpt_id: editor::ExcerptId,
-        workspace: &WeakView<Workspace>,
-        assistant_panel: &View<AssistantPanel>,
-        cx: &mut WindowContext,
-    ) -> Option<InlineAssistId> {
-        let mut initial_transaction_id = None;
-        let initial_prompt;
-        let suggestion_range;
-        let buffer = editor.read(cx).buffer().clone();
-        let snapshot = buffer.read(cx).snapshot(cx);
-
-        match self {
-            WorkflowSuggestion::Update { range, description } => {
-                initial_prompt = description.clone();
-                suggestion_range = snapshot.anchor_in_excerpt(excerpt_id, range.start)?
-                    ..snapshot.anchor_in_excerpt(excerpt_id, range.end)?;
-            }
-            WorkflowSuggestion::CreateFile { description } => {
-                initial_prompt = description.clone();
-                suggestion_range = editor::Anchor::min()..editor::Anchor::min();
-            }
-            WorkflowSuggestion::InsertSiblingBefore {
-                position,
-                description,
-            } => {
-                let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?;
-                initial_prompt = description.clone();
-                suggestion_range = buffer.update(cx, |buffer, cx| {
-                    buffer.start_transaction(cx);
-                    let line_start = buffer.insert_empty_line(position, true, true, cx);
-                    initial_transaction_id = buffer.end_transaction(cx);
-                    buffer.refresh_preview(cx);
-
-                    let line_start = buffer.read(cx).anchor_before(line_start);
-                    line_start..line_start
-                });
-            }
-            WorkflowSuggestion::InsertSiblingAfter {
-                position,
-                description,
-            } => {
-                let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?;
-                initial_prompt = description.clone();
-                suggestion_range = buffer.update(cx, |buffer, cx| {
-                    buffer.start_transaction(cx);
-                    let line_start = buffer.insert_empty_line(position, true, true, cx);
-                    initial_transaction_id = buffer.end_transaction(cx);
-                    buffer.refresh_preview(cx);
-
-                    let line_start = buffer.read(cx).anchor_before(line_start);
-                    line_start..line_start
-                });
-            }
-            WorkflowSuggestion::PrependChild {
-                position,
-                description,
-            } => {
-                let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?;
-                initial_prompt = description.clone();
-                suggestion_range = buffer.update(cx, |buffer, cx| {
-                    buffer.start_transaction(cx);
-                    let line_start = buffer.insert_empty_line(position, false, true, cx);
-                    initial_transaction_id = buffer.end_transaction(cx);
-                    buffer.refresh_preview(cx);
-
-                    let line_start = buffer.read(cx).anchor_before(line_start);
-                    line_start..line_start
-                });
-            }
-            WorkflowSuggestion::AppendChild {
-                position,
-                description,
-            } => {
-                let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?;
-                initial_prompt = description.clone();
-                suggestion_range = buffer.update(cx, |buffer, cx| {
-                    buffer.start_transaction(cx);
-                    let line_start = buffer.insert_empty_line(position, true, false, cx);
-                    initial_transaction_id = buffer.end_transaction(cx);
-                    buffer.refresh_preview(cx);
-
-                    let line_start = buffer.read(cx).anchor_before(line_start);
-                    line_start..line_start
-                });
-            }
-            WorkflowSuggestion::Delete { range } => {
-                initial_prompt = "Delete".to_string();
-                suggestion_range = snapshot.anchor_in_excerpt(excerpt_id, range.start)?
-                    ..snapshot.anchor_in_excerpt(excerpt_id, range.end)?;
-            }
-        }
-
-        InlineAssistant::update_global(cx, |inline_assistant, cx| {
-            Some(inline_assistant.suggest_assist(
-                editor,
-                suggestion_range,
-                initial_prompt,
-                initial_transaction_id,
-                Some(workspace.clone()),
-                Some(assistant_panel),
-                cx,
-            ))
-        })
-    }
+    pub resolution: Model<WorkflowStepResolution>,
+    pub _task: Option<Task<()>>,
 }
 
-impl Debug for WorkflowStepStatus {
+impl Debug for WorkflowStep {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            WorkflowStepStatus::Pending(_) => write!(f, "WorkflowStepStatus::Pending"),
-            WorkflowStepStatus::Resolved(ResolvedWorkflowStep { title, suggestions }) => f
-                .debug_struct("WorkflowStepStatus::Resolved")
-                .field("title", title)
-                .field("suggestions", suggestions)
-                .finish(),
-            WorkflowStepStatus::Error(error) => f
-                .debug_tuple("WorkflowStepStatus::Error")
-                .field(error)
-                .finish(),
-        }
+        f.debug_struct("WorkflowStep")
+            .field("tagged_range", &self.tagged_range)
+            .finish_non_exhaustive()
     }
 }
 
@@ -1082,6 +838,14 @@ impl Context {
         &self.buffer
     }
 
+    pub fn project(&self) -> Option<Model<Project>> {
+        self.project.clone()
+    }
+
+    pub fn prompt_builder(&self) -> Arc<PromptBuilder> {
+        self.prompt_builder.clone()
+    }
+
     pub fn path(&self) -> Option<&Path> {
         self.path.as_deref()
     }
@@ -1308,12 +1072,7 @@ impl Context {
         start_ix..end_ix
     }
 
-    fn parse_workflow_steps_in_range(
-        &mut self,
-        range: Range<usize>,
-        project: Model<Project>,
-        cx: &mut ModelContext<Self>,
-    ) {
+    fn parse_workflow_steps_in_range(&mut self, range: Range<usize>, cx: &mut ModelContext<Self>) {
         let mut new_edit_steps = Vec::new();
         let mut edits = Vec::new();
 
@@ -1356,8 +1115,11 @@ impl Context {
                         new_edit_steps.push((
                             ix,
                             WorkflowStep {
+                                resolution: cx.new_model(|_| {
+                                    WorkflowStepResolution::new(tagged_range.clone())
+                                }),
                                 tagged_range,
-                                status: WorkflowStepStatus::Pending(Task::ready(None)),
+                                _task: None,
                             },
                         ));
                     }
@@ -1374,7 +1136,7 @@ impl Context {
             let step_range = step.tagged_range.clone();
             updated.push(step_range.clone());
             self.workflow_steps.insert(index, step);
-            self.resolve_workflow_step(step_range, project.clone(), cx);
+            self.resolve_workflow_step(step_range, cx);
         }
 
         // Delete <step> tags, making sure we don't accidentally invalidate
@@ -1387,7 +1149,6 @@ impl Context {
     pub fn resolve_workflow_step(
         &mut self,
         tagged_range: Range<language::Anchor>,
-        project: Model<Project>,
         cx: &mut ModelContext<Self>,
     ) {
         let Ok(step_index) = self
@@ -1397,152 +1158,22 @@ impl Context {
             return;
         };
 
-        let mut request = self.to_completion_request(cx);
-        let Some(edit_step) = self.workflow_steps.get_mut(step_index) else {
-            return;
-        };
-
-        if let Some(model) = LanguageModelRegistry::read_global(cx).active_model() {
-            let step_text = self
-                .buffer
-                .read(cx)
-                .text_for_range(tagged_range.clone())
-                .collect::<String>();
-
-            let tagged_range = tagged_range.clone();
-            edit_step.status = WorkflowStepStatus::Pending(cx.spawn(|this, mut cx| {
-                async move {
-                    let result = async {
-                        let mut prompt = this.update(&mut cx, |this, _| {
-                            this.prompt_builder.generate_step_resolution_prompt()
-                        })??;
-                        prompt.push_str(&step_text);
-
-                        request.messages.push(LanguageModelRequestMessage {
-                            role: Role::User,
-                            content: vec![prompt.into()],
-                        });
-
-                        // Invoke the model to get its edit suggestions for this workflow step.
-                        let resolution = model
-                            .use_tool::<tool::WorkflowStepResolution>(request, &cx)
-                            .await?;
-
-                        // Translate the parsed suggestions to our internal types, which anchor the suggestions to locations in the code.
-                        let suggestion_tasks: Vec<_> = resolution
-                            .suggestions
-                            .iter()
-                            .map(|suggestion| suggestion.resolve(project.clone(), cx.clone()))
-                            .collect();
-
-                        // Expand the context ranges of each suggestion and group suggestions with overlapping context ranges.
-                        let suggestions = future::join_all(suggestion_tasks)
-                            .await
-                            .into_iter()
-                            .filter_map(|task| task.log_err())
-                            .collect::<Vec<_>>();
-
-                        let mut suggestions_by_buffer = HashMap::default();
-                        for (buffer, suggestion) in suggestions {
-                            suggestions_by_buffer
-                                .entry(buffer)
-                                .or_insert_with(Vec::new)
-                                .push(suggestion);
-                        }
-
-                        let mut suggestion_groups_by_buffer = HashMap::default();
-                        for (buffer, mut suggestions) in suggestions_by_buffer {
-                            let mut suggestion_groups = Vec::<WorkflowSuggestionGroup>::new();
-                            let snapshot = buffer.update(&mut cx, |buffer, _| buffer.snapshot())?;
-                            // Sort suggestions by their range so that earlier, larger ranges come first
-                            suggestions.sort_by(|a, b| a.range().cmp(&b.range(), &snapshot));
-
-                            // Merge overlapping suggestions
-                            suggestions.dedup_by(|a, b| b.try_merge(&a, &snapshot));
-
-                            // Create context ranges for each suggestion
-                            for suggestion in suggestions {
-                                let context_range = {
-                                    let suggestion_point_range =
-                                        suggestion.range().to_point(&snapshot);
-                                    let start_row =
-                                        suggestion_point_range.start.row.saturating_sub(5);
-                                    let end_row = cmp::min(
-                                        suggestion_point_range.end.row + 5,
-                                        snapshot.max_point().row,
-                                    );
-                                    let start = snapshot.anchor_before(Point::new(start_row, 0));
-                                    let end = snapshot.anchor_after(Point::new(
-                                        end_row,
-                                        snapshot.line_len(end_row),
-                                    ));
-                                    start..end
-                                };
-
-                                if let Some(last_group) = suggestion_groups.last_mut() {
-                                    if last_group
-                                        .context_range
-                                        .end
-                                        .cmp(&context_range.start, &snapshot)
-                                        .is_ge()
-                                    {
-                                        // Merge with the previous group if context ranges overlap
-                                        last_group.context_range.end = context_range.end;
-                                        last_group.suggestions.push(suggestion);
-                                    } else {
-                                        // Create a new group
-                                        suggestion_groups.push(WorkflowSuggestionGroup {
-                                            context_range,
-                                            suggestions: vec![suggestion],
-                                        });
-                                    }
-                                } else {
-                                    // Create the first group
-                                    suggestion_groups.push(WorkflowSuggestionGroup {
-                                        context_range,
-                                        suggestions: vec![suggestion],
-                                    });
-                                }
-                            }
-
-                            suggestion_groups_by_buffer.insert(buffer, suggestion_groups);
-                        }
-
-                        Ok((resolution.step_title, suggestion_groups_by_buffer))
-                    };
-
-                    let result = result.await;
-                    this.update(&mut cx, |this, cx| {
-                        let step_index = this
-                            .workflow_steps
-                            .binary_search_by(|step| {
-                                step.tagged_range.cmp(&tagged_range, this.buffer.read(cx))
-                            })
-                            .map_err(|_| anyhow!("edit step not found"))?;
-                        if let Some(edit_step) = this.workflow_steps.get_mut(step_index) {
-                            edit_step.status = match result {
-                                Ok((title, suggestions)) => {
-                                    WorkflowStepStatus::Resolved(ResolvedWorkflowStep {
-                                        title,
-                                        suggestions,
-                                    })
-                                }
-                                Err(error) => WorkflowStepStatus::Error(Arc::new(error)),
-                            };
-                            cx.emit(ContextEvent::WorkflowStepUpdated(tagged_range));
-                            cx.notify();
-                        }
-                        anyhow::Ok(())
-                    })?
-                }
-                .log_err()
-            }));
-        } else {
-            edit_step.status = WorkflowStepStatus::Error(Arc::new(anyhow!("no active model")));
-        }
-
-        cx.emit(ContextEvent::WorkflowStepUpdated(tagged_range));
+        cx.emit(ContextEvent::WorkflowStepUpdated(tagged_range.clone()));
         cx.notify();
+
+        let task = self.workflow_steps[step_index]
+            .resolution
+            .update(cx, |resolution, cx| resolution.resolve(self, cx));
+        self.workflow_steps[step_index]._task = task.map(|task| {
+            cx.spawn(|this, mut cx| async move {
+                task.await;
+                this.update(&mut cx, |_, cx| {
+                    cx.emit(ContextEvent::WorkflowStepUpdated(tagged_range));
+                    cx.notify();
+                })
+                .ok();
+            })
+        });
     }
 
     pub fn pending_command_for_position(
@@ -1762,11 +1393,10 @@ impl Context {
                                 );
                                 message_start_offset..message_new_end_offset
                             });
-                            if let Some(project) = this.project.clone() {
-                                // Use `inclusive = false` as edits might occur at the end of a parsed step.
-                                this.prune_invalid_workflow_steps(false, cx);
-                                this.parse_workflow_steps_in_range(message_range, project, cx);
-                            }
+
+                            // Use `inclusive = false` as edits might occur at the end of a parsed step.
+                            this.prune_invalid_workflow_steps(false, cx);
+                            this.parse_workflow_steps_in_range(message_range, cx);
                             cx.emit(ContextEvent::StreamedCompletion);
 
                             Some(())
@@ -2752,7 +2382,9 @@ pub struct SavedContextMetadata {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::{assistant_panel, prompt_library, slash_command::file_command, MessageId};
+    use crate::{
+        assistant_panel, prompt_library, slash_command::file_command, workflow::tool, MessageId,
+    };
     use assistant_slash_command::{ArgumentCompletion, SlashCommand};
     use fs::FakeFs;
     use gpui::{AppContext, TestAppContext, WeakView};
@@ -3392,10 +3024,10 @@ mod tests {
                 .iter()
                 .map(|step| {
                     let buffer = context.buffer.read(cx);
-                    let status = match &step.status {
-                        WorkflowStepStatus::Pending(_) => WorkflowStepTestStatus::Pending,
-                        WorkflowStepStatus::Resolved { .. } => WorkflowStepTestStatus::Resolved,
-                        WorkflowStepStatus::Error(_) => WorkflowStepTestStatus::Error,
+                    let status = match &step.resolution.read(cx).result {
+                        None => WorkflowStepTestStatus::Pending,
+                        Some(Ok(_)) => WorkflowStepTestStatus::Resolved,
+                        Some(Err(_)) => WorkflowStepTestStatus::Error,
                     };
                     (step.tagged_range.to_point(buffer), status)
                 })
@@ -3798,289 +3430,3 @@ mod tests {
         }
     }
 }
-
-mod tool {
-    use gpui::AsyncAppContext;
-    use project::ProjectPath;
-
-    use super::*;
-
-    #[derive(Debug, Serialize, Deserialize, JsonSchema)]
-    pub struct WorkflowStepResolution {
-        /// An extremely short title for the edit step represented by these operations.
-        pub step_title: String,
-        /// A sequence of operations to apply to the codebase.
-        /// When multiple operations are required for a step, be sure to include multiple operations in this list.
-        pub suggestions: Vec<WorkflowSuggestion>,
-    }
-
-    impl LanguageModelTool for WorkflowStepResolution {
-        fn name() -> String {
-            "edit".into()
-        }
-
-        fn description() -> String {
-            "suggest edits to one or more locations in the codebase".into()
-        }
-    }
-
-    /// A description of an operation to apply to one location in the codebase.
-    ///
-    /// This object represents a single edit operation that can be performed on a specific file
-    /// in the codebase. It encapsulates both the location (file path) and the nature of the
-    /// edit to be made.
-    ///
-    /// # Fields
-    ///
-    /// * `path`: A string representing the file path where the edit operation should be applied.
-    ///           This path is relative to the root of the project or repository.
-    ///
-    /// * `kind`: An enum representing the specific type of edit operation to be performed.
-    ///
-    /// # Usage
-    ///
-    /// `EditOperation` is used within a code editor to represent and apply
-    /// programmatic changes to source code. It provides a structured way to describe
-    /// edits for features like refactoring tools or AI-assisted coding suggestions.
-    #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
-    pub struct WorkflowSuggestion {
-        /// The path to the file containing the relevant operation
-        pub path: String,
-        #[serde(flatten)]
-        pub kind: WorkflowSuggestionKind,
-    }
-
-    impl WorkflowSuggestion {
-        pub(super) async fn resolve(
-            &self,
-            project: Model<Project>,
-            mut cx: AsyncAppContext,
-        ) -> Result<(Model<Buffer>, super::WorkflowSuggestion)> {
-            let path = self.path.clone();
-            let kind = self.kind.clone();
-            let buffer = project
-                .update(&mut cx, |project, cx| {
-                    let project_path = project
-                        .find_project_path(Path::new(&path), cx)
-                        .or_else(|| {
-                            // If we couldn't find a project path for it, put it in the active worktree
-                            // so that when we create the buffer, it can be saved.
-                            let worktree = project
-                                .active_entry()
-                                .and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
-                                .or_else(|| project.worktrees(cx).next())?;
-                            let worktree = worktree.read(cx);
-
-                            Some(ProjectPath {
-                                worktree_id: worktree.id(),
-                                path: Arc::from(Path::new(&path)),
-                            })
-                        })
-                        .with_context(|| format!("worktree not found for {:?}", path))?;
-                    anyhow::Ok(project.open_buffer(project_path, cx))
-                })??
-                .await?;
-
-            let mut parse_status = buffer.read_with(&cx, |buffer, _cx| buffer.parse_status())?;
-            while *parse_status.borrow() != ParseStatus::Idle {
-                parse_status.changed().await?;
-            }
-
-            let snapshot = buffer.update(&mut cx, |buffer, _| buffer.snapshot())?;
-            let outline = snapshot.outline(None).context("no outline for buffer")?;
-
-            let suggestion;
-            match kind {
-                WorkflowSuggestionKind::Update {
-                    symbol,
-                    description,
-                } => {
-                    let symbol = outline
-                        .find_most_similar(&symbol)
-                        .with_context(|| format!("symbol not found: {:?}", symbol))?
-                        .to_point(&snapshot);
-                    let start = symbol
-                        .annotation_range
-                        .map_or(symbol.range.start, |range| range.start);
-                    let start = Point::new(start.row, 0);
-                    let end = Point::new(
-                        symbol.range.end.row,
-                        snapshot.line_len(symbol.range.end.row),
-                    );
-                    let range = snapshot.anchor_before(start)..snapshot.anchor_after(end);
-                    suggestion = super::WorkflowSuggestion::Update { range, description };
-                }
-                WorkflowSuggestionKind::Create { description } => {
-                    suggestion = super::WorkflowSuggestion::CreateFile { description };
-                }
-                WorkflowSuggestionKind::InsertSiblingBefore {
-                    symbol,
-                    description,
-                } => {
-                    let symbol = outline
-                        .find_most_similar(&symbol)
-                        .with_context(|| format!("symbol not found: {:?}", symbol))?
-                        .to_point(&snapshot);
-                    let position = snapshot.anchor_before(
-                        symbol
-                            .annotation_range
-                            .map_or(symbol.range.start, |annotation_range| {
-                                annotation_range.start
-                            }),
-                    );
-                    suggestion = super::WorkflowSuggestion::InsertSiblingBefore {
-                        position,
-                        description,
-                    };
-                }
-                WorkflowSuggestionKind::InsertSiblingAfter {
-                    symbol,
-                    description,
-                } => {
-                    let symbol = outline
-                        .find_most_similar(&symbol)
-                        .with_context(|| format!("symbol not found: {:?}", symbol))?
-                        .to_point(&snapshot);
-                    let position = snapshot.anchor_after(symbol.range.end);
-                    suggestion = super::WorkflowSuggestion::InsertSiblingAfter {
-                        position,
-                        description,
-                    };
-                }
-                WorkflowSuggestionKind::PrependChild {
-                    symbol,
-                    description,
-                } => {
-                    if let Some(symbol) = symbol {
-                        let symbol = outline
-                            .find_most_similar(&symbol)
-                            .with_context(|| format!("symbol not found: {:?}", symbol))?
-                            .to_point(&snapshot);
-
-                        let position = snapshot.anchor_after(
-                            symbol
-                                .body_range
-                                .map_or(symbol.range.start, |body_range| body_range.start),
-                        );
-                        suggestion = super::WorkflowSuggestion::PrependChild {
-                            position,
-                            description,
-                        };
-                    } else {
-                        suggestion = super::WorkflowSuggestion::PrependChild {
-                            position: language::Anchor::MIN,
-                            description,
-                        };
-                    }
-                }
-                WorkflowSuggestionKind::AppendChild {
-                    symbol,
-                    description,
-                } => {
-                    if let Some(symbol) = symbol {
-                        let symbol = outline
-                            .find_most_similar(&symbol)
-                            .with_context(|| format!("symbol not found: {:?}", symbol))?
-                            .to_point(&snapshot);
-
-                        let position = snapshot.anchor_before(
-                            symbol
-                                .body_range
-                                .map_or(symbol.range.end, |body_range| body_range.end),
-                        );
-                        suggestion = super::WorkflowSuggestion::AppendChild {
-                            position,
-                            description,
-                        };
-                    } else {
-                        suggestion = super::WorkflowSuggestion::PrependChild {
-                            position: language::Anchor::MAX,
-                            description,
-                        };
-                    }
-                }
-                WorkflowSuggestionKind::Delete { symbol } => {
-                    let symbol = outline
-                        .find_most_similar(&symbol)
-                        .with_context(|| format!("symbol not found: {:?}", symbol))?
-                        .to_point(&snapshot);
-                    let start = symbol
-                        .annotation_range
-                        .map_or(symbol.range.start, |range| range.start);
-                    let start = Point::new(start.row, 0);
-                    let end = Point::new(
-                        symbol.range.end.row,
-                        snapshot.line_len(symbol.range.end.row),
-                    );
-                    let range = snapshot.anchor_before(start)..snapshot.anchor_after(end);
-                    suggestion = super::WorkflowSuggestion::Delete { range };
-                }
-            }
-
-            Ok((buffer, suggestion))
-        }
-    }
-
-    #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
-    #[serde(tag = "kind")]
-    pub enum WorkflowSuggestionKind {
-        /// Rewrites the specified symbol entirely based on the given description.
-        /// This operation completely replaces the existing symbol with new content.
-        Update {
-            /// A fully-qualified reference to the symbol, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`.
-            /// The path should uniquely identify the symbol within the containing file.
-            symbol: String,
-            /// A brief description of the transformation to apply to the symbol.
-            description: String,
-        },
-        /// Creates a new file with the given path based on the provided description.
-        /// This operation adds a new file to the codebase.
-        Create {
-            /// A brief description of the file to be created.
-            description: String,
-        },
-        /// Inserts a new symbol based on the given description before the specified symbol.
-        /// This operation adds new content immediately preceding an existing symbol.
-        InsertSiblingBefore {
-            /// A fully-qualified reference to the symbol, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`.
-            /// The new content will be inserted immediately before this symbol.
-            symbol: String,
-            /// A brief description of the new symbol to be inserted.
-            description: String,
-        },
-        /// Inserts a new symbol based on the given description after the specified symbol.
-        /// This operation adds new content immediately following an existing symbol.
-        InsertSiblingAfter {
-            /// A fully-qualified reference to the symbol, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`.
-            /// The new content will be inserted immediately after this symbol.
-            symbol: String,
-            /// A brief description of the new symbol to be inserted.
-            description: String,
-        },
-        /// Inserts a new symbol as a child of the specified symbol at the start.
-        /// This operation adds new content as the first child of an existing symbol (or file if no symbol is provided).
-        PrependChild {
-            /// An optional fully-qualified reference to the symbol after the code you want to insert, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`.
-            /// If provided, the new content will be inserted as the first child of this symbol.
-            /// If not provided, the new content will be inserted at the top of the file.
-            symbol: Option<String>,
-            /// A brief description of the new symbol to be inserted.
-            description: String,
-        },
-        /// Inserts a new symbol as a child of the specified symbol at the end.
-        /// This operation adds new content as the last child of an existing symbol (or file if no symbol is provided).
-        AppendChild {
-            /// An optional fully-qualified reference to the symbol before the code you want to insert, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`.
-            /// If provided, the new content will be inserted as the last child of this symbol.
-            /// If not provided, the new content will be applied at the bottom of the file.
-            symbol: Option<String>,
-            /// A brief description of the new symbol to be inserted.
-            description: String,
-        },
-        /// Deletes the specified symbol from the containing file.
-        Delete {
-            /// An fully-qualified reference to the symbol to be deleted, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`.
-            symbol: String,
-        },
-    }
-}

crates/assistant/src/context_inspector.rs 🔗

@@ -44,6 +44,7 @@ impl ContextInspector {
             self.activate_for_step(range.clone(), cx);
         }
     }
+
     fn crease_content(
         context: &Model<Context>,
         range: StepRange,
@@ -52,8 +53,8 @@ impl ContextInspector {
         use std::fmt::Write;
         let step = context.read(cx).workflow_step_for_range(range)?;
         let mut output = String::from("\n\n");
-        match &step.status {
-            crate::WorkflowStepStatus::Resolved(ResolvedWorkflowStep { title, suggestions }) => {
+        match &step.resolution.read(cx).result {
+            Some(Ok(ResolvedWorkflowStep { title, suggestions })) => {
                 writeln!(output, "Resolution:").ok()?;
                 writeln!(output, "  {title:?}").ok()?;
                 if suggestions.is_empty() {
@@ -75,17 +76,18 @@ impl ContextInspector {
                     }
                 }
             }
-            crate::WorkflowStepStatus::Pending(_) => {
-                writeln!(output, "Resolution: Pending").ok()?;
-            }
-            crate::WorkflowStepStatus::Error(error) => {
+            Some(Err(error)) => {
                 writeln!(output, "Resolution: Error").ok()?;
                 writeln!(output, "{error:?}").ok()?;
             }
+            None => {
+                writeln!(output, "Resolution: Pending").ok()?;
+            }
         }
 
         Some(output.into())
     }
+
     pub(crate) fn activate_for_step(&mut self, range: StepRange, cx: &mut WindowContext<'_>) {
         let text = Self::crease_content(&self.context, range.clone(), cx)
             .unwrap_or_else(|| Arc::from("Error fetching debug info"));

crates/assistant/src/workflow.rs 🔗

@@ -0,0 +1,672 @@
+use crate::{AssistantPanel, Context, InlineAssistId, InlineAssistant};
+use anyhow::{anyhow, Error, Result};
+use collections::HashMap;
+use editor::Editor;
+use futures::future;
+use gpui::{Model, ModelContext, Task, UpdateGlobal as _, View, WeakView, WindowContext};
+use language::{Anchor, Buffer, BufferSnapshot};
+use language_model::{LanguageModelRegistry, LanguageModelRequestMessage, Role};
+use project::Project;
+use rope::Point;
+use serde::{Deserialize, Serialize};
+use smol::stream::StreamExt;
+use std::{cmp, ops::Range, sync::Arc};
+use text::{AnchorRangeExt as _, OffsetRangeExt as _};
+use util::ResultExt as _;
+use workspace::Workspace;
+
+pub struct WorkflowStepResolution {
+    tagged_range: Range<Anchor>,
+    output: String,
+    pub result: Option<Result<ResolvedWorkflowStep, Arc<Error>>>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ResolvedWorkflowStep {
+    pub title: String,
+    pub suggestions: HashMap<Model<Buffer>, Vec<WorkflowSuggestionGroup>>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct WorkflowSuggestionGroup {
+    pub context_range: Range<language::Anchor>,
+    pub suggestions: Vec<WorkflowSuggestion>,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum WorkflowSuggestion {
+    Update {
+        range: Range<language::Anchor>,
+        description: String,
+    },
+    CreateFile {
+        description: String,
+    },
+    InsertSiblingBefore {
+        position: language::Anchor,
+        description: String,
+    },
+    InsertSiblingAfter {
+        position: language::Anchor,
+        description: String,
+    },
+    PrependChild {
+        position: language::Anchor,
+        description: String,
+    },
+    AppendChild {
+        position: language::Anchor,
+        description: String,
+    },
+    Delete {
+        range: Range<language::Anchor>,
+    },
+}
+
+impl WorkflowStepResolution {
+    pub fn new(range: Range<Anchor>) -> Self {
+        Self {
+            tagged_range: range,
+            output: String::new(),
+            result: None,
+        }
+    }
+
+    pub fn resolve(
+        &mut self,
+        context: &Context,
+        cx: &mut ModelContext<WorkflowStepResolution>,
+    ) -> Option<Task<()>> {
+        let project = context.project()?;
+        let context_buffer = context.buffer().clone();
+        let prompt_builder = context.prompt_builder();
+        let mut request = context.to_completion_request(cx);
+        let model = LanguageModelRegistry::read_global(cx).active_model();
+        let step_text = context_buffer
+            .read(cx)
+            .text_for_range(self.tagged_range.clone())
+            .collect::<String>();
+
+        Some(cx.spawn(|this, mut cx| async move {
+            let result = async {
+                let Some(model) = model else {
+                    return Err(anyhow!("no model selected"));
+                };
+
+                this.update(&mut cx, |this, cx| {
+                    this.output.clear();
+                    this.result = None;
+                    cx.notify();
+                })?;
+
+                let mut prompt = prompt_builder.generate_step_resolution_prompt()?;
+                prompt.push_str(&step_text);
+                request.messages.push(LanguageModelRequestMessage {
+                    role: Role::User,
+                    content: vec![prompt.into()],
+                });
+
+                // Invoke the model to get its edit suggestions for this workflow step.
+                let mut stream = model
+                    .use_tool_stream::<tool::WorkflowStepResolution>(request, &cx)
+                    .await?;
+                while let Some(chunk) = stream.next().await {
+                    let chunk = chunk?;
+                    this.update(&mut cx, |this, cx| {
+                        this.output.push_str(&chunk);
+                        cx.notify();
+                    })?;
+                }
+
+                let resolution = this.update(&mut cx, |this, _| {
+                    serde_json::from_str::<tool::WorkflowStepResolution>(&this.output)
+                })??;
+
+                // Translate the parsed suggestions to our internal types, which anchor the suggestions to locations in the code.
+                let suggestion_tasks: Vec<_> = resolution
+                    .suggestions
+                    .iter()
+                    .map(|suggestion| suggestion.resolve(project.clone(), cx.clone()))
+                    .collect();
+
+                // Expand the context ranges of each suggestion and group suggestions with overlapping context ranges.
+                let suggestions = future::join_all(suggestion_tasks)
+                    .await
+                    .into_iter()
+                    .filter_map(|task| task.log_err())
+                    .collect::<Vec<_>>();
+
+                let mut suggestions_by_buffer = HashMap::default();
+                for (buffer, suggestion) in suggestions {
+                    suggestions_by_buffer
+                        .entry(buffer)
+                        .or_insert_with(Vec::new)
+                        .push(suggestion);
+                }
+
+                let mut suggestion_groups_by_buffer = HashMap::default();
+                for (buffer, mut suggestions) in suggestions_by_buffer {
+                    let mut suggestion_groups = Vec::<WorkflowSuggestionGroup>::new();
+                    let snapshot = buffer.update(&mut cx, |buffer, _| buffer.snapshot())?;
+                    // Sort suggestions by their range so that earlier, larger ranges come first
+                    suggestions.sort_by(|a, b| a.range().cmp(&b.range(), &snapshot));
+
+                    // Merge overlapping suggestions
+                    suggestions.dedup_by(|a, b| b.try_merge(&a, &snapshot));
+
+                    // Create context ranges for each suggestion
+                    for suggestion in suggestions {
+                        let context_range = {
+                            let suggestion_point_range = suggestion.range().to_point(&snapshot);
+                            let start_row = suggestion_point_range.start.row.saturating_sub(5);
+                            let end_row = cmp::min(
+                                suggestion_point_range.end.row + 5,
+                                snapshot.max_point().row,
+                            );
+                            let start = snapshot.anchor_before(Point::new(start_row, 0));
+                            let end = snapshot
+                                .anchor_after(Point::new(end_row, snapshot.line_len(end_row)));
+                            start..end
+                        };
+
+                        if let Some(last_group) = suggestion_groups.last_mut() {
+                            if last_group
+                                .context_range
+                                .end
+                                .cmp(&context_range.start, &snapshot)
+                                .is_ge()
+                            {
+                                // Merge with the previous group if context ranges overlap
+                                last_group.context_range.end = context_range.end;
+                                last_group.suggestions.push(suggestion);
+                            } else {
+                                // Create a new group
+                                suggestion_groups.push(WorkflowSuggestionGroup {
+                                    context_range,
+                                    suggestions: vec![suggestion],
+                                });
+                            }
+                        } else {
+                            // Create the first group
+                            suggestion_groups.push(WorkflowSuggestionGroup {
+                                context_range,
+                                suggestions: vec![suggestion],
+                            });
+                        }
+                    }
+
+                    suggestion_groups_by_buffer.insert(buffer, suggestion_groups);
+                }
+
+                Ok((resolution.step_title, suggestion_groups_by_buffer))
+            };
+
+            let result = result.await;
+            this.update(&mut cx, |this, cx| {
+                this.result = Some(match result {
+                    Ok((title, suggestions)) => Ok(ResolvedWorkflowStep { title, suggestions }),
+                    Err(error) => Err(Arc::new(error)),
+                });
+                cx.notify();
+            })
+            .ok();
+        }))
+    }
+}
+
+impl WorkflowSuggestion {
+    pub fn range(&self) -> Range<language::Anchor> {
+        match self {
+            WorkflowSuggestion::Update { range, .. } => range.clone(),
+            WorkflowSuggestion::CreateFile { .. } => language::Anchor::MIN..language::Anchor::MAX,
+            WorkflowSuggestion::InsertSiblingBefore { position, .. }
+            | WorkflowSuggestion::InsertSiblingAfter { position, .. }
+            | WorkflowSuggestion::PrependChild { position, .. }
+            | WorkflowSuggestion::AppendChild { position, .. } => *position..*position,
+            WorkflowSuggestion::Delete { range } => range.clone(),
+        }
+    }
+
+    pub fn description(&self) -> Option<&str> {
+        match self {
+            WorkflowSuggestion::Update { description, .. }
+            | WorkflowSuggestion::CreateFile { description }
+            | WorkflowSuggestion::InsertSiblingBefore { description, .. }
+            | WorkflowSuggestion::InsertSiblingAfter { description, .. }
+            | WorkflowSuggestion::PrependChild { description, .. }
+            | WorkflowSuggestion::AppendChild { description, .. } => Some(description),
+            WorkflowSuggestion::Delete { .. } => None,
+        }
+    }
+
+    fn description_mut(&mut self) -> Option<&mut String> {
+        match self {
+            WorkflowSuggestion::Update { description, .. }
+            | WorkflowSuggestion::CreateFile { description }
+            | WorkflowSuggestion::InsertSiblingBefore { description, .. }
+            | WorkflowSuggestion::InsertSiblingAfter { description, .. }
+            | WorkflowSuggestion::PrependChild { description, .. }
+            | WorkflowSuggestion::AppendChild { description, .. } => Some(description),
+            WorkflowSuggestion::Delete { .. } => None,
+        }
+    }
+
+    fn try_merge(&mut self, other: &Self, buffer: &BufferSnapshot) -> bool {
+        let range = self.range();
+        let other_range = other.range();
+
+        // Don't merge if we don't contain the other suggestion.
+        if range.start.cmp(&other_range.start, buffer).is_gt()
+            || range.end.cmp(&other_range.end, buffer).is_lt()
+        {
+            return false;
+        }
+
+        if let Some(description) = self.description_mut() {
+            if let Some(other_description) = other.description() {
+                description.push('\n');
+                description.push_str(other_description);
+            }
+        }
+        true
+    }
+
+    pub fn show(
+        &self,
+        editor: &View<Editor>,
+        excerpt_id: editor::ExcerptId,
+        workspace: &WeakView<Workspace>,
+        assistant_panel: &View<AssistantPanel>,
+        cx: &mut WindowContext,
+    ) -> Option<InlineAssistId> {
+        let mut initial_transaction_id = None;
+        let initial_prompt;
+        let suggestion_range;
+        let buffer = editor.read(cx).buffer().clone();
+        let snapshot = buffer.read(cx).snapshot(cx);
+
+        match self {
+            WorkflowSuggestion::Update { range, description } => {
+                initial_prompt = description.clone();
+                suggestion_range = snapshot.anchor_in_excerpt(excerpt_id, range.start)?
+                    ..snapshot.anchor_in_excerpt(excerpt_id, range.end)?;
+            }
+            WorkflowSuggestion::CreateFile { description } => {
+                initial_prompt = description.clone();
+                suggestion_range = editor::Anchor::min()..editor::Anchor::min();
+            }
+            WorkflowSuggestion::InsertSiblingBefore {
+                position,
+                description,
+            } => {
+                let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?;
+                initial_prompt = description.clone();
+                suggestion_range = buffer.update(cx, |buffer, cx| {
+                    buffer.start_transaction(cx);
+                    let line_start = buffer.insert_empty_line(position, true, true, cx);
+                    initial_transaction_id = buffer.end_transaction(cx);
+                    buffer.refresh_preview(cx);
+
+                    let line_start = buffer.read(cx).anchor_before(line_start);
+                    line_start..line_start
+                });
+            }
+            WorkflowSuggestion::InsertSiblingAfter {
+                position,
+                description,
+            } => {
+                let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?;
+                initial_prompt = description.clone();
+                suggestion_range = buffer.update(cx, |buffer, cx| {
+                    buffer.start_transaction(cx);
+                    let line_start = buffer.insert_empty_line(position, true, true, cx);
+                    initial_transaction_id = buffer.end_transaction(cx);
+                    buffer.refresh_preview(cx);
+
+                    let line_start = buffer.read(cx).anchor_before(line_start);
+                    line_start..line_start
+                });
+            }
+            WorkflowSuggestion::PrependChild {
+                position,
+                description,
+            } => {
+                let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?;
+                initial_prompt = description.clone();
+                suggestion_range = buffer.update(cx, |buffer, cx| {
+                    buffer.start_transaction(cx);
+                    let line_start = buffer.insert_empty_line(position, false, true, cx);
+                    initial_transaction_id = buffer.end_transaction(cx);
+                    buffer.refresh_preview(cx);
+
+                    let line_start = buffer.read(cx).anchor_before(line_start);
+                    line_start..line_start
+                });
+            }
+            WorkflowSuggestion::AppendChild {
+                position,
+                description,
+            } => {
+                let position = snapshot.anchor_in_excerpt(excerpt_id, *position)?;
+                initial_prompt = description.clone();
+                suggestion_range = buffer.update(cx, |buffer, cx| {
+                    buffer.start_transaction(cx);
+                    let line_start = buffer.insert_empty_line(position, true, false, cx);
+                    initial_transaction_id = buffer.end_transaction(cx);
+                    buffer.refresh_preview(cx);
+
+                    let line_start = buffer.read(cx).anchor_before(line_start);
+                    line_start..line_start
+                });
+            }
+            WorkflowSuggestion::Delete { range } => {
+                initial_prompt = "Delete".to_string();
+                suggestion_range = snapshot.anchor_in_excerpt(excerpt_id, range.start)?
+                    ..snapshot.anchor_in_excerpt(excerpt_id, range.end)?;
+            }
+        }
+
+        InlineAssistant::update_global(cx, |inline_assistant, cx| {
+            Some(inline_assistant.suggest_assist(
+                editor,
+                suggestion_range,
+                initial_prompt,
+                initial_transaction_id,
+                Some(workspace.clone()),
+                Some(assistant_panel),
+                cx,
+            ))
+        })
+    }
+}
+
+pub mod tool {
+    use std::path::Path;
+
+    use super::*;
+    use anyhow::Context as _;
+    use gpui::AsyncAppContext;
+    use language::ParseStatus;
+    use language_model::LanguageModelTool;
+    use project::ProjectPath;
+    use schemars::JsonSchema;
+
+    #[derive(Debug, Serialize, Deserialize, JsonSchema)]
+    pub struct WorkflowStepResolution {
+        /// An extremely short title for the edit step represented by these operations.
+        pub step_title: String,
+        /// A sequence of operations to apply to the codebase.
+        /// When multiple operations are required for a step, be sure to include multiple operations in this list.
+        pub suggestions: Vec<WorkflowSuggestion>,
+    }
+
+    impl LanguageModelTool for WorkflowStepResolution {
+        fn name() -> String {
+            "edit".into()
+        }
+
+        fn description() -> String {
+            "suggest edits to one or more locations in the codebase".into()
+        }
+    }
+
+    /// A description of an operation to apply to one location in the codebase.
+    ///
+    /// This object represents a single edit operation that can be performed on a specific file
+    /// in the codebase. It encapsulates both the location (file path) and the nature of the
+    /// edit to be made.
+    ///
+    /// # Fields
+    ///
+    /// * `path`: A string representing the file path where the edit operation should be applied.
+    ///           This path is relative to the root of the project or repository.
+    ///
+    /// * `kind`: An enum representing the specific type of edit operation to be performed.
+    ///
+    /// # Usage
+    ///
+    /// `EditOperation` is used within a code editor to represent and apply
+    /// programmatic changes to source code. It provides a structured way to describe
+    /// edits for features like refactoring tools or AI-assisted coding suggestions.
+    #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+    pub struct WorkflowSuggestion {
+        /// The path to the file containing the relevant operation
+        pub path: String,
+        #[serde(flatten)]
+        pub kind: WorkflowSuggestionKind,
+    }
+
+    impl WorkflowSuggestion {
+        pub(super) async fn resolve(
+            &self,
+            project: Model<Project>,
+            mut cx: AsyncAppContext,
+        ) -> Result<(Model<Buffer>, super::WorkflowSuggestion)> {
+            let path = self.path.clone();
+            let kind = self.kind.clone();
+            let buffer = project
+                .update(&mut cx, |project, cx| {
+                    let project_path = project
+                        .find_project_path(Path::new(&path), cx)
+                        .or_else(|| {
+                            // If we couldn't find a project path for it, put it in the active worktree
+                            // so that when we create the buffer, it can be saved.
+                            let worktree = project
+                                .active_entry()
+                                .and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
+                                .or_else(|| project.worktrees(cx).next())?;
+                            let worktree = worktree.read(cx);
+
+                            Some(ProjectPath {
+                                worktree_id: worktree.id(),
+                                path: Arc::from(Path::new(&path)),
+                            })
+                        })
+                        .with_context(|| format!("worktree not found for {:?}", path))?;
+                    anyhow::Ok(project.open_buffer(project_path, cx))
+                })??
+                .await?;
+
+            let mut parse_status = buffer.read_with(&cx, |buffer, _cx| buffer.parse_status())?;
+            while *parse_status.borrow() != ParseStatus::Idle {
+                parse_status.changed().await?;
+            }
+
+            let snapshot = buffer.update(&mut cx, |buffer, _| buffer.snapshot())?;
+            let outline = snapshot.outline(None).context("no outline for buffer")?;
+
+            let suggestion;
+            match kind {
+                WorkflowSuggestionKind::Update {
+                    symbol,
+                    description,
+                } => {
+                    let symbol = outline
+                        .find_most_similar(&symbol)
+                        .with_context(|| format!("symbol not found: {:?}", symbol))?
+                        .to_point(&snapshot);
+                    let start = symbol
+                        .annotation_range
+                        .map_or(symbol.range.start, |range| range.start);
+                    let start = Point::new(start.row, 0);
+                    let end = Point::new(
+                        symbol.range.end.row,
+                        snapshot.line_len(symbol.range.end.row),
+                    );
+                    let range = snapshot.anchor_before(start)..snapshot.anchor_after(end);
+                    suggestion = super::WorkflowSuggestion::Update { range, description };
+                }
+                WorkflowSuggestionKind::Create { description } => {
+                    suggestion = super::WorkflowSuggestion::CreateFile { description };
+                }
+                WorkflowSuggestionKind::InsertSiblingBefore {
+                    symbol,
+                    description,
+                } => {
+                    let symbol = outline
+                        .find_most_similar(&symbol)
+                        .with_context(|| format!("symbol not found: {:?}", symbol))?
+                        .to_point(&snapshot);
+                    let position = snapshot.anchor_before(
+                        symbol
+                            .annotation_range
+                            .map_or(symbol.range.start, |annotation_range| {
+                                annotation_range.start
+                            }),
+                    );
+                    suggestion = super::WorkflowSuggestion::InsertSiblingBefore {
+                        position,
+                        description,
+                    };
+                }
+                WorkflowSuggestionKind::InsertSiblingAfter {
+                    symbol,
+                    description,
+                } => {
+                    let symbol = outline
+                        .find_most_similar(&symbol)
+                        .with_context(|| format!("symbol not found: {:?}", symbol))?
+                        .to_point(&snapshot);
+                    let position = snapshot.anchor_after(symbol.range.end);
+                    suggestion = super::WorkflowSuggestion::InsertSiblingAfter {
+                        position,
+                        description,
+                    };
+                }
+                WorkflowSuggestionKind::PrependChild {
+                    symbol,
+                    description,
+                } => {
+                    if let Some(symbol) = symbol {
+                        let symbol = outline
+                            .find_most_similar(&symbol)
+                            .with_context(|| format!("symbol not found: {:?}", symbol))?
+                            .to_point(&snapshot);
+
+                        let position = snapshot.anchor_after(
+                            symbol
+                                .body_range
+                                .map_or(symbol.range.start, |body_range| body_range.start),
+                        );
+                        suggestion = super::WorkflowSuggestion::PrependChild {
+                            position,
+                            description,
+                        };
+                    } else {
+                        suggestion = super::WorkflowSuggestion::PrependChild {
+                            position: language::Anchor::MIN,
+                            description,
+                        };
+                    }
+                }
+                WorkflowSuggestionKind::AppendChild {
+                    symbol,
+                    description,
+                } => {
+                    if let Some(symbol) = symbol {
+                        let symbol = outline
+                            .find_most_similar(&symbol)
+                            .with_context(|| format!("symbol not found: {:?}", symbol))?
+                            .to_point(&snapshot);
+
+                        let position = snapshot.anchor_before(
+                            symbol
+                                .body_range
+                                .map_or(symbol.range.end, |body_range| body_range.end),
+                        );
+                        suggestion = super::WorkflowSuggestion::AppendChild {
+                            position,
+                            description,
+                        };
+                    } else {
+                        suggestion = super::WorkflowSuggestion::PrependChild {
+                            position: language::Anchor::MAX,
+                            description,
+                        };
+                    }
+                }
+                WorkflowSuggestionKind::Delete { symbol } => {
+                    let symbol = outline
+                        .find_most_similar(&symbol)
+                        .with_context(|| format!("symbol not found: {:?}", symbol))?
+                        .to_point(&snapshot);
+                    let start = symbol
+                        .annotation_range
+                        .map_or(symbol.range.start, |range| range.start);
+                    let start = Point::new(start.row, 0);
+                    let end = Point::new(
+                        symbol.range.end.row,
+                        snapshot.line_len(symbol.range.end.row),
+                    );
+                    let range = snapshot.anchor_before(start)..snapshot.anchor_after(end);
+                    suggestion = super::WorkflowSuggestion::Delete { range };
+                }
+            }
+
+            Ok((buffer, suggestion))
+        }
+    }
+
+    #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+    #[serde(tag = "kind")]
+    pub enum WorkflowSuggestionKind {
+        /// Rewrites the specified symbol entirely based on the given description.
+        /// This operation completely replaces the existing symbol with new content.
+        Update {
+            /// A fully-qualified reference to the symbol, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`.
+            /// The path should uniquely identify the symbol within the containing file.
+            symbol: String,
+            /// A brief description of the transformation to apply to the symbol.
+            description: String,
+        },
+        /// Creates a new file with the given path based on the provided description.
+        /// This operation adds a new file to the codebase.
+        Create {
+            /// A brief description of the file to be created.
+            description: String,
+        },
+        /// Inserts a new symbol based on the given description before the specified symbol.
+        /// This operation adds new content immediately preceding an existing symbol.
+        InsertSiblingBefore {
+            /// A fully-qualified reference to the symbol, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`.
+            /// The new content will be inserted immediately before this symbol.
+            symbol: String,
+            /// A brief description of the new symbol to be inserted.
+            description: String,
+        },
+        /// Inserts a new symbol based on the given description after the specified symbol.
+        /// This operation adds new content immediately following an existing symbol.
+        InsertSiblingAfter {
+            /// A fully-qualified reference to the symbol, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`.
+            /// The new content will be inserted immediately after this symbol.
+            symbol: String,
+            /// A brief description of the new symbol to be inserted.
+            description: String,
+        },
+        /// Inserts a new symbol as a child of the specified symbol at the start.
+        /// This operation adds new content as the first child of an existing symbol (or file if no symbol is provided).
+        PrependChild {
+            /// An optional fully-qualified reference to the symbol after the code you want to insert, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`.
+            /// If provided, the new content will be inserted as the first child of this symbol.
+            /// If not provided, the new content will be inserted at the top of the file.
+            symbol: Option<String>,
+            /// A brief description of the new symbol to be inserted.
+            description: String,
+        },
+        /// Inserts a new symbol as a child of the specified symbol at the end.
+        /// This operation adds new content as the last child of an existing symbol (or file if no symbol is provided).
+        AppendChild {
+            /// An optional fully-qualified reference to the symbol before the code you want to insert, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`.
+            /// If provided, the new content will be inserted as the last child of this symbol.
+            /// If not provided, the new content will be applied at the bottom of the file.
+            symbol: Option<String>,
+            /// A brief description of the new symbol to be inserted.
+            description: String,
+        },
+        /// Deletes the specified symbol from the containing file.
+        Delete {
+            /// An fully-qualified reference to the symbol to be deleted, e.g. `mod foo impl Bar pub fn baz` instead of just `fn baz`.
+            symbol: String,
+        },
+    }
+}

crates/language_model/src/language_model.rs 🔗

@@ -99,6 +99,16 @@ impl dyn LanguageModel {
             Ok(serde_json::from_str(&response)?)
         }
     }
+
+    pub fn use_tool_stream<T: LanguageModelTool>(
+        &self,
+        request: LanguageModelRequest,
+        cx: &AsyncAppContext,
+    ) -> BoxFuture<'static, Result<BoxStream<'static, Result<String>>>> {
+        let schema = schemars::schema_for!(T);
+        let schema_json = serde_json::to_value(&schema).unwrap();
+        self.use_any_tool(request, T::name(), T::description(), schema_json, cx)
+    }
 }
 
 pub trait LanguageModelTool: 'static + DeserializeOwned + JsonSchema {