Skip to content

Commit e298301

Browse files
agu-zmaxdeviant
andauthored
assistant: Make scripting a first-class concept instead of a tool (#26338)
This PR makes refactors the scripting functionality to be a first-class concept of the assistant instead of a generic tool, which will allow us to build a more customized experience. - The tool prompt has been slightly tweaked and is now included as a system message in all conversations. I'm getting decent results, but now that it isn't in the tools framework, it will probably require more refining. - The model will now include an `<eval ...>` tag at the end of the message with the script. We parse this tag incrementally as it streams in so that we can indicate that we are generating a script before we see the closing `</eval>` tag. Later, this will help us interpret the script as it arrives also. - Threads now hold a `ScriptSession` entity which manages the state of all scripts (from parsing to exited) in a centralized way, and will later collect all script operations so they can be displayed in the UI. - `script_tool` has been renamed to `assistant_scripting` - Script source now opens in a regular read-only buffer Note: We still need to handle persistence properly Release Notes: - N/A --------- Co-authored-by: Marshall Bowers <[email protected]>
1 parent ed6bf7f commit e298301

File tree

16 files changed

+809
-195
lines changed

16 files changed

+809
-195
lines changed

Cargo.lock

Lines changed: 20 additions & 21 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ members = [
118118
"crates/rope",
119119
"crates/rpc",
120120
"crates/schema_generator",
121-
"crates/scripting_tool",
121+
"crates/assistant_scripting",
122122
"crates/search",
123123
"crates/semantic_index",
124124
"crates/semantic_version",
@@ -318,7 +318,7 @@ reqwest_client = { path = "crates/reqwest_client" }
318318
rich_text = { path = "crates/rich_text" }
319319
rope = { path = "crates/rope" }
320320
rpc = { path = "crates/rpc" }
321-
scripting_tool = { path = "crates/scripting_tool" }
321+
assistant_scripting = { path = "crates/assistant_scripting" }
322322
search = { path = "crates/search" }
323323
semantic_index = { path = "crates/semantic_index" }
324324
semantic_version = { path = "crates/semantic_version" }

crates/assistant2/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ serde.workspace = true
6363
serde_json.workspace = true
6464
settings.workspace = true
6565
smol.workspace = true
66+
assistant_scripting.workspace = true
6667
streaming_diff.workspace = true
6768
telemetry_events.workspace = true
6869
terminal.workspace = true

crates/assistant2/src/active_thread.rs

Lines changed: 162 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
use std::sync::Arc;
22

3-
use collections::HashMap;
3+
use assistant_scripting::{ScriptId, ScriptState};
4+
use collections::{HashMap, HashSet};
45
use editor::{Editor, MultiBuffer};
56
use gpui::{
67
list, AbsoluteLength, AnyElement, App, ClickEvent, DefiniteLength, EdgesRefinement, Empty,
78
Entity, Focusable, Length, ListAlignment, ListOffset, ListState, StyleRefinement, Subscription,
8-
Task, TextStyleRefinement, UnderlineStyle,
9+
Task, TextStyleRefinement, UnderlineStyle, WeakEntity,
910
};
1011
use language::{Buffer, LanguageRegistry};
1112
use language_model::{LanguageModelRegistry, LanguageModelToolUseId, Role};
@@ -14,13 +15,15 @@ use settings::Settings as _;
1415
use theme::ThemeSettings;
1516
use ui::{prelude::*, Disclosure, KeyBinding};
1617
use util::ResultExt as _;
18+
use workspace::Workspace;
1719

1820
use crate::thread::{MessageId, RequestKind, Thread, ThreadError, ThreadEvent};
1921
use crate::thread_store::ThreadStore;
2022
use crate::tool_use::{ToolUse, ToolUseStatus};
2123
use crate::ui::ContextPill;
2224

2325
pub struct ActiveThread {
26+
workspace: WeakEntity<Workspace>,
2427
language_registry: Arc<LanguageRegistry>,
2528
thread_store: Entity<ThreadStore>,
2629
thread: Entity<Thread>,
@@ -30,6 +33,7 @@ pub struct ActiveThread {
3033
rendered_messages_by_id: HashMap<MessageId, Entity<Markdown>>,
3134
editing_message: Option<(MessageId, EditMessageState)>,
3235
expanded_tool_uses: HashMap<LanguageModelToolUseId, bool>,
36+
expanded_scripts: HashSet<ScriptId>,
3337
last_error: Option<ThreadError>,
3438
_subscriptions: Vec<Subscription>,
3539
}
@@ -40,6 +44,7 @@ struct EditMessageState {
4044

4145
impl ActiveThread {
4246
pub fn new(
47+
workspace: WeakEntity<Workspace>,
4348
thread: Entity<Thread>,
4449
thread_store: Entity<ThreadStore>,
4550
language_registry: Arc<LanguageRegistry>,
@@ -52,13 +57,15 @@ impl ActiveThread {
5257
];
5358

5459
let mut this = Self {
60+
workspace,
5561
language_registry,
5662
thread_store,
5763
thread: thread.clone(),
5864
save_thread_task: None,
5965
messages: Vec::new(),
6066
rendered_messages_by_id: HashMap::default(),
6167
expanded_tool_uses: HashMap::default(),
68+
expanded_scripts: HashSet::default(),
6269
list_state: ListState::new(0, ListAlignment::Bottom, px(1024.), {
6370
let this = cx.entity().downgrade();
6471
move |ix, window: &mut Window, cx: &mut App| {
@@ -241,7 +248,7 @@ impl ActiveThread {
241248

242249
fn handle_thread_event(
243250
&mut self,
244-
_: &Entity<Thread>,
251+
_thread: &Entity<Thread>,
245252
event: &ThreadEvent,
246253
window: &mut Window,
247254
cx: &mut Context<Self>,
@@ -306,6 +313,14 @@ impl ActiveThread {
306313
}
307314
}
308315
}
316+
ThreadEvent::ScriptFinished => {
317+
let model_registry = LanguageModelRegistry::read_global(cx);
318+
if let Some(model) = model_registry.active_model() {
319+
self.thread.update(cx, |thread, cx| {
320+
thread.send_to_model(model, RequestKind::Chat, false, cx);
321+
});
322+
}
323+
}
309324
}
310325
}
311326

@@ -445,12 +460,16 @@ impl ActiveThread {
445460
return Empty.into_any();
446461
};
447462

448-
let context = self.thread.read(cx).context_for_message(message_id);
449-
let tool_uses = self.thread.read(cx).tool_uses_for_message(message_id);
450-
let colors = cx.theme().colors();
463+
let thread = self.thread.read(cx);
464+
465+
let context = thread.context_for_message(message_id);
466+
let tool_uses = thread.tool_uses_for_message(message_id);
451467

452468
// Don't render user messages that are just there for returning tool results.
453-
if message.role == Role::User && self.thread.read(cx).message_has_tool_results(message_id) {
469+
if message.role == Role::User
470+
&& (thread.message_has_tool_results(message_id)
471+
|| thread.message_has_script_output(message_id))
472+
{
454473
return Empty.into_any();
455474
}
456475

@@ -463,6 +482,8 @@ impl ActiveThread {
463482
.filter(|(id, _)| *id == message_id)
464483
.map(|(_, state)| state.editor.clone());
465484

485+
let colors = cx.theme().colors();
486+
466487
let message_content = v_flex()
467488
.child(
468489
if let Some(edit_message_editor) = edit_message_editor.clone() {
@@ -597,6 +618,7 @@ impl ActiveThread {
597618
Role::Assistant => div()
598619
.id(("message-container", ix))
599620
.child(message_content)
621+
.children(self.render_script(message_id, cx))
600622
.map(|parent| {
601623
if tool_uses.is_empty() {
602624
return parent;
@@ -716,6 +738,139 @@ impl ActiveThread {
716738
}),
717739
)
718740
}
741+
742+
fn render_script(&self, message_id: MessageId, cx: &mut Context<Self>) -> Option<AnyElement> {
743+
let script = self.thread.read(cx).script_for_message(message_id, cx)?;
744+
745+
let is_open = self.expanded_scripts.contains(&script.id);
746+
let colors = cx.theme().colors();
747+
748+
let element = div().px_2p5().child(
749+
v_flex()
750+
.gap_1()
751+
.rounded_lg()
752+
.border_1()
753+
.border_color(colors.border)
754+
.child(
755+
h_flex()
756+
.justify_between()
757+
.py_0p5()
758+
.pl_1()
759+
.pr_2()
760+
.bg(colors.editor_foreground.opacity(0.02))
761+
.when(is_open, |element| element.border_b_1().rounded_t(px(6.)))
762+
.when(!is_open, |element| element.rounded_md())
763+
.border_color(colors.border)
764+
.child(
765+
h_flex()
766+
.gap_1()
767+
.child(Disclosure::new("script-disclosure", is_open).on_click(
768+
cx.listener({
769+
let script_id = script.id;
770+
move |this, _event, _window, _cx| {
771+
if this.expanded_scripts.contains(&script_id) {
772+
this.expanded_scripts.remove(&script_id);
773+
} else {
774+
this.expanded_scripts.insert(script_id);
775+
}
776+
}
777+
}),
778+
))
779+
// TODO: Generate script description
780+
.child(Label::new("Script")),
781+
)
782+
.child(
783+
h_flex()
784+
.gap_1()
785+
.child(
786+
Label::new(match script.state {
787+
ScriptState::Generating => "Generating",
788+
ScriptState::Running { .. } => "Running",
789+
ScriptState::Succeeded { .. } => "Finished",
790+
ScriptState::Failed { .. } => "Error",
791+
})
792+
.size(LabelSize::XSmall)
793+
.buffer_font(cx),
794+
)
795+
.child(
796+
IconButton::new("view-source", IconName::Eye)
797+
.icon_color(Color::Muted)
798+
.disabled(matches!(script.state, ScriptState::Generating))
799+
.on_click(cx.listener({
800+
let source = script.source.clone();
801+
move |this, _event, window, cx| {
802+
this.open_script_source(source.clone(), window, cx);
803+
}
804+
})),
805+
),
806+
),
807+
)
808+
.when(is_open, |parent| {
809+
let stdout = script.stdout_snapshot();
810+
let error = script.error();
811+
812+
parent.child(
813+
v_flex()
814+
.p_2()
815+
.bg(colors.editor_background)
816+
.gap_2()
817+
.child(if stdout.is_empty() && error.is_none() {
818+
Label::new("No output yet")
819+
.size(LabelSize::Small)
820+
.color(Color::Muted)
821+
} else {
822+
Label::new(stdout).size(LabelSize::Small).buffer_font(cx)
823+
})
824+
.children(script.error().map(|err| {
825+
Label::new(err.to_string())
826+
.size(LabelSize::Small)
827+
.color(Color::Error)
828+
})),
829+
)
830+
}),
831+
);
832+
833+
Some(element.into_any())
834+
}
835+
836+
fn open_script_source(
837+
&mut self,
838+
source: SharedString,
839+
window: &mut Window,
840+
cx: &mut Context<'_, ActiveThread>,
841+
) {
842+
let language_registry = self.language_registry.clone();
843+
let workspace = self.workspace.clone();
844+
let source = source.clone();
845+
846+
cx.spawn_in(window, |_, mut cx| async move {
847+
let lua = language_registry.language_for_name("Lua").await.log_err();
848+
849+
workspace.update_in(&mut cx, |workspace, window, cx| {
850+
let project = workspace.project().clone();
851+
852+
let buffer = project.update(cx, |project, cx| {
853+
project.create_local_buffer(&source.trim(), lua, cx)
854+
});
855+
856+
let buffer = cx.new(|cx| {
857+
MultiBuffer::singleton(buffer, cx)
858+
// TODO: Generate script description
859+
.with_title("Assistant script".into())
860+
});
861+
862+
let editor = cx.new(|cx| {
863+
let mut editor =
864+
Editor::for_multibuffer(buffer, Some(project), true, window, cx);
865+
editor.set_read_only(true);
866+
editor
867+
});
868+
869+
workspace.add_item_to_active_pane(Box::new(editor), None, true, window, cx);
870+
})
871+
})
872+
.detach_and_log_err(cx);
873+
}
719874
}
720875

721876
impl Render for ActiveThread {

0 commit comments

Comments
 (0)