This fixes a race condition where the thread would not get the LSP tools at startup if the feature flag was not resolved yet. We now always add the tools, but filter them out when we start a new turn if the feature flag is not set. Release Notes: - N/A
4604 lines
166 KiB
Rust
4604 lines
166 KiB
Rust
use crate::{
|
|
ApplyCodeActionTool, CodeActionStore, ContextServerRegistry, CopyPathTool, CreateDirectoryTool,
|
|
DbLanguageModel, DbThread, DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool,
|
|
FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool,
|
|
ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool, RenameTool,
|
|
RestoreFileFromDiskTool, SaveFileTool, SpawnAgentTool, SystemPromptTemplate, Template,
|
|
Templates, TerminalTool, ToolPermissionDecision, UpdatePlanTool, WebSearchTool, WriteFileTool,
|
|
decide_permission_from_settings,
|
|
};
|
|
use acp_thread::{MentionUri, UserMessageId};
|
|
use action_log::ActionLog;
|
|
use feature_flags::{FeatureFlagAppExt as _, LspToolFeatureFlag, UpdatePlanToolFeatureFlag};
|
|
|
|
use agent_client_protocol::schema as acp;
|
|
use agent_settings::{
|
|
AgentProfileId, AgentSettings, SUMMARIZE_THREAD_DETAILED_PROMPT, SUMMARIZE_THREAD_PROMPT,
|
|
};
|
|
use anyhow::{Context as _, Result, anyhow};
|
|
use chrono::{DateTime, Utc};
|
|
use client::UserStore;
|
|
use cloud_api_types::Plan;
|
|
use collections::{HashMap, HashSet, IndexMap};
|
|
use fs::Fs;
|
|
use futures::{
|
|
FutureExt,
|
|
channel::{mpsc, oneshot},
|
|
future::Shared,
|
|
stream::FuturesUnordered,
|
|
};
|
|
use futures::{StreamExt, stream};
|
|
use gpui::{
|
|
App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity,
|
|
};
|
|
use heck::ToSnakeCase as _;
|
|
use language_model::{
|
|
CompletionIntent, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
|
|
LanguageModelId, LanguageModelImage, LanguageModelProviderId, LanguageModelRegistry,
|
|
LanguageModelRequest, LanguageModelRequestMessage, LanguageModelRequestTool,
|
|
LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolSchemaFormat,
|
|
LanguageModelToolUse, LanguageModelToolUseId, Role, SelectedModel, Speed, StopReason,
|
|
TokenUsage, ZED_CLOUD_PROVIDER_ID,
|
|
};
|
|
use project::Project;
|
|
use prompt_store::ProjectContext;
|
|
use schemars::{JsonSchema, Schema};
|
|
use serde::de::DeserializeOwned;
|
|
use serde::{Deserialize, Serialize};
|
|
use settings::{
|
|
LanguageModelSelection, Settings, SettingsStore, ToolPermissionMode, update_settings_file,
|
|
};
|
|
use std::{
|
|
collections::BTreeMap,
|
|
marker::PhantomData,
|
|
ops::RangeInclusive,
|
|
path::Path,
|
|
rc::Rc,
|
|
sync::Arc,
|
|
time::{Duration, Instant},
|
|
};
|
|
use std::{fmt::Write, path::PathBuf};
|
|
use util::{ResultExt, debug_panic, markdown::MarkdownCodeBlock, paths::PathStyle};
|
|
use uuid::Uuid;
|
|
|
|
const TOOL_CANCELED_MESSAGE: &str = "Tool canceled by user";
|
|
pub const MAX_TOOL_NAME_LENGTH: usize = 64;
|
|
pub const MAX_SUBAGENT_DEPTH: u8 = 1;
|
|
|
|
/// Returned when a turn is attempted but no language model has been selected.
|
|
#[derive(Debug)]
|
|
pub struct NoModelConfiguredError;
|
|
|
|
impl std::fmt::Display for NoModelConfiguredError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "no language model configured")
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for NoModelConfiguredError {}
|
|
|
|
/// Context passed to a subagent thread for lifecycle management
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct SubagentContext {
|
|
/// ID of the parent thread
|
|
pub parent_thread_id: acp::SessionId,
|
|
|
|
/// Current depth level (0 = root agent, 1 = first-level subagent, etc.)
|
|
pub depth: u8,
|
|
}
|
|
|
|
/// The ID of the user prompt that initiated a request.
|
|
///
|
|
/// This equates to the user physically submitting a message to the model (e.g., by pressing the Enter key).
|
|
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize)]
|
|
pub struct PromptId(Arc<str>);
|
|
|
|
impl PromptId {
|
|
pub fn new() -> Self {
|
|
Self(Uuid::new_v4().to_string().into())
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for PromptId {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}", self.0)
|
|
}
|
|
}
|
|
|
|
pub(crate) const MAX_RETRY_ATTEMPTS: u8 = 4;
|
|
pub(crate) const BASE_RETRY_DELAY: Duration = Duration::from_secs(5);
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum RetryStrategy {
|
|
ExponentialBackoff {
|
|
initial_delay: Duration,
|
|
max_attempts: u8,
|
|
},
|
|
Fixed {
|
|
delay: Duration,
|
|
max_attempts: u8,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum Message {
|
|
User(UserMessage),
|
|
Agent(AgentMessage),
|
|
Resume,
|
|
}
|
|
|
|
impl Message {
|
|
pub fn as_agent_message(&self) -> Option<&AgentMessage> {
|
|
match self {
|
|
Message::Agent(agent_message) => Some(agent_message),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn to_request(&self) -> Vec<LanguageModelRequestMessage> {
|
|
match self {
|
|
Message::User(message) => {
|
|
if message.content.is_empty() {
|
|
vec![]
|
|
} else {
|
|
vec![message.to_request()]
|
|
}
|
|
}
|
|
Message::Agent(message) => message.to_request(),
|
|
Message::Resume => vec![LanguageModelRequestMessage {
|
|
role: Role::User,
|
|
content: vec!["Continue where you left off".into()],
|
|
cache: false,
|
|
reasoning_details: None,
|
|
}],
|
|
}
|
|
}
|
|
|
|
pub fn to_markdown(&self) -> String {
|
|
match self {
|
|
Message::User(message) => message.to_markdown(),
|
|
Message::Agent(message) => message.to_markdown(),
|
|
Message::Resume => "[resume]\n".into(),
|
|
}
|
|
}
|
|
|
|
pub fn role(&self) -> Role {
|
|
match self {
|
|
Message::User(_) | Message::Resume => Role::User,
|
|
Message::Agent(_) => Role::Assistant,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct UserMessage {
|
|
pub id: UserMessageId,
|
|
pub content: Vec<UserMessageContent>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum UserMessageContent {
|
|
Text(String),
|
|
Mention { uri: MentionUri, content: String },
|
|
Image(LanguageModelImage),
|
|
}
|
|
|
|
impl UserMessage {
|
|
pub fn to_markdown(&self) -> String {
|
|
let mut markdown = String::new();
|
|
|
|
for content in &self.content {
|
|
match content {
|
|
UserMessageContent::Text(text) => {
|
|
markdown.push_str(text);
|
|
markdown.push('\n');
|
|
}
|
|
UserMessageContent::Image(_) => {
|
|
markdown.push_str("<image />\n");
|
|
}
|
|
UserMessageContent::Mention { uri, content } => {
|
|
if !content.is_empty() {
|
|
let _ = writeln!(&mut markdown, "{}\n\n{}", uri.as_link(), content);
|
|
} else {
|
|
let _ = writeln!(&mut markdown, "{}", uri.as_link());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
markdown
|
|
}
|
|
|
|
fn to_request(&self) -> LanguageModelRequestMessage {
|
|
let mut message = LanguageModelRequestMessage {
|
|
role: Role::User,
|
|
content: Vec::with_capacity(self.content.len()),
|
|
cache: false,
|
|
reasoning_details: None,
|
|
};
|
|
|
|
const OPEN_CONTEXT: &str = "<context>\n\
|
|
The following items were attached by the user. \
|
|
They are up-to-date and don't need to be re-read.\n\n";
|
|
|
|
const OPEN_FILES_TAG: &str = "<files>";
|
|
const OPEN_DIRECTORIES_TAG: &str = "<directories>";
|
|
const OPEN_SYMBOLS_TAG: &str = "<symbols>";
|
|
const OPEN_SELECTIONS_TAG: &str = "<selections>";
|
|
const OPEN_THREADS_TAG: &str = "<threads>";
|
|
const OPEN_FETCH_TAG: &str = "<fetched_urls>";
|
|
const OPEN_RULES_TAG: &str =
|
|
"<rules>\nThe user has specified the following rules that should be applied:\n";
|
|
const OPEN_DIAGNOSTICS_TAG: &str = "<diagnostics>";
|
|
const OPEN_DIFFS_TAG: &str = "<diffs>";
|
|
const MERGE_CONFLICT_TAG: &str = "<merge_conflicts>";
|
|
|
|
let mut file_context = OPEN_FILES_TAG.to_string();
|
|
let mut directory_context = OPEN_DIRECTORIES_TAG.to_string();
|
|
let mut symbol_context = OPEN_SYMBOLS_TAG.to_string();
|
|
let mut selection_context = OPEN_SELECTIONS_TAG.to_string();
|
|
let mut thread_context = OPEN_THREADS_TAG.to_string();
|
|
let mut fetch_context = OPEN_FETCH_TAG.to_string();
|
|
let mut rules_context = OPEN_RULES_TAG.to_string();
|
|
let mut diagnostics_context = OPEN_DIAGNOSTICS_TAG.to_string();
|
|
let mut diffs_context = OPEN_DIFFS_TAG.to_string();
|
|
let mut merge_conflict_context = MERGE_CONFLICT_TAG.to_string();
|
|
|
|
for chunk in &self.content {
|
|
let chunk = match chunk {
|
|
UserMessageContent::Text(text) => {
|
|
language_model::MessageContent::Text(text.clone())
|
|
}
|
|
UserMessageContent::Image(value) => {
|
|
language_model::MessageContent::Image(value.clone())
|
|
}
|
|
UserMessageContent::Mention { uri, content } => {
|
|
match uri {
|
|
MentionUri::File { abs_path } => {
|
|
write!(
|
|
&mut file_context,
|
|
"\n{}",
|
|
MarkdownCodeBlock {
|
|
tag: &codeblock_tag(abs_path, None),
|
|
text: &content.to_string(),
|
|
}
|
|
)
|
|
.ok();
|
|
}
|
|
MentionUri::PastedImage { .. } => {
|
|
debug_panic!("pasted image URI should not be used in mention content")
|
|
}
|
|
MentionUri::Directory { .. } => {
|
|
write!(&mut directory_context, "\n{}\n", content).ok();
|
|
}
|
|
MentionUri::Symbol {
|
|
abs_path: path,
|
|
line_range,
|
|
..
|
|
} => {
|
|
write!(
|
|
&mut symbol_context,
|
|
"\n{}",
|
|
MarkdownCodeBlock {
|
|
tag: &codeblock_tag(path, Some(line_range)),
|
|
text: content
|
|
}
|
|
)
|
|
.ok();
|
|
}
|
|
MentionUri::Selection {
|
|
abs_path: path,
|
|
line_range,
|
|
..
|
|
} => {
|
|
write!(
|
|
&mut selection_context,
|
|
"\n{}",
|
|
MarkdownCodeBlock {
|
|
tag: &codeblock_tag(
|
|
path.as_deref().unwrap_or("Untitled".as_ref()),
|
|
Some(line_range)
|
|
),
|
|
text: content
|
|
}
|
|
)
|
|
.ok();
|
|
}
|
|
MentionUri::Thread { .. } => {
|
|
write!(&mut thread_context, "\n{}\n", content).ok();
|
|
}
|
|
MentionUri::Rule { .. } => {
|
|
write!(
|
|
&mut rules_context,
|
|
"\n{}",
|
|
MarkdownCodeBlock {
|
|
tag: "",
|
|
text: content
|
|
}
|
|
)
|
|
.ok();
|
|
}
|
|
MentionUri::Fetch { url } => {
|
|
write!(&mut fetch_context, "\nFetch: {}\n\n{}", url, content).ok();
|
|
}
|
|
MentionUri::Diagnostics { .. } => {
|
|
write!(&mut diagnostics_context, "\n{}\n", content).ok();
|
|
}
|
|
MentionUri::TerminalSelection { .. } => {
|
|
write!(
|
|
&mut selection_context,
|
|
"\n{}",
|
|
MarkdownCodeBlock {
|
|
tag: "console",
|
|
text: content
|
|
}
|
|
)
|
|
.ok();
|
|
}
|
|
MentionUri::GitDiff { base_ref } => {
|
|
write!(
|
|
&mut diffs_context,
|
|
"\nBranch diff against {}:\n{}",
|
|
base_ref,
|
|
MarkdownCodeBlock {
|
|
tag: "diff",
|
|
text: content
|
|
}
|
|
)
|
|
.ok();
|
|
}
|
|
MentionUri::MergeConflict { file_path } => {
|
|
write!(
|
|
&mut merge_conflict_context,
|
|
"\nMerge conflict in {}:\n{}",
|
|
file_path,
|
|
MarkdownCodeBlock {
|
|
tag: "diff",
|
|
text: content
|
|
}
|
|
)
|
|
.ok();
|
|
}
|
|
}
|
|
|
|
language_model::MessageContent::Text(uri.as_link().to_string())
|
|
}
|
|
};
|
|
|
|
message.content.push(chunk);
|
|
}
|
|
|
|
let len_before_context = message.content.len();
|
|
|
|
if file_context.len() > OPEN_FILES_TAG.len() {
|
|
file_context.push_str("</files>\n");
|
|
message
|
|
.content
|
|
.push(language_model::MessageContent::Text(file_context));
|
|
}
|
|
|
|
if directory_context.len() > OPEN_DIRECTORIES_TAG.len() {
|
|
directory_context.push_str("</directories>\n");
|
|
message
|
|
.content
|
|
.push(language_model::MessageContent::Text(directory_context));
|
|
}
|
|
|
|
if symbol_context.len() > OPEN_SYMBOLS_TAG.len() {
|
|
symbol_context.push_str("</symbols>\n");
|
|
message
|
|
.content
|
|
.push(language_model::MessageContent::Text(symbol_context));
|
|
}
|
|
|
|
if selection_context.len() > OPEN_SELECTIONS_TAG.len() {
|
|
selection_context.push_str("</selections>\n");
|
|
message
|
|
.content
|
|
.push(language_model::MessageContent::Text(selection_context));
|
|
}
|
|
|
|
if diffs_context.len() > OPEN_DIFFS_TAG.len() {
|
|
diffs_context.push_str("</diffs>\n");
|
|
message
|
|
.content
|
|
.push(language_model::MessageContent::Text(diffs_context));
|
|
}
|
|
|
|
if thread_context.len() > OPEN_THREADS_TAG.len() {
|
|
thread_context.push_str("</threads>\n");
|
|
message
|
|
.content
|
|
.push(language_model::MessageContent::Text(thread_context));
|
|
}
|
|
|
|
if fetch_context.len() > OPEN_FETCH_TAG.len() {
|
|
fetch_context.push_str("</fetched_urls>\n");
|
|
message
|
|
.content
|
|
.push(language_model::MessageContent::Text(fetch_context));
|
|
}
|
|
|
|
if rules_context.len() > OPEN_RULES_TAG.len() {
|
|
rules_context.push_str("</user_rules>\n");
|
|
message
|
|
.content
|
|
.push(language_model::MessageContent::Text(rules_context));
|
|
}
|
|
|
|
if diagnostics_context.len() > OPEN_DIAGNOSTICS_TAG.len() {
|
|
diagnostics_context.push_str("</diagnostics>\n");
|
|
message
|
|
.content
|
|
.push(language_model::MessageContent::Text(diagnostics_context));
|
|
}
|
|
|
|
if merge_conflict_context.len() > MERGE_CONFLICT_TAG.len() {
|
|
merge_conflict_context.push_str("</merge_conflicts>\n");
|
|
message
|
|
.content
|
|
.push(language_model::MessageContent::Text(merge_conflict_context));
|
|
}
|
|
|
|
if message.content.len() > len_before_context {
|
|
message.content.insert(
|
|
len_before_context,
|
|
language_model::MessageContent::Text(OPEN_CONTEXT.into()),
|
|
);
|
|
message
|
|
.content
|
|
.push(language_model::MessageContent::Text("</context>".into()));
|
|
}
|
|
|
|
message
|
|
}
|
|
}
|
|
|
|
fn codeblock_tag(full_path: &Path, line_range: Option<&RangeInclusive<u32>>) -> String {
|
|
let mut result = String::new();
|
|
|
|
if let Some(extension) = full_path.extension().and_then(|ext| ext.to_str()) {
|
|
let _ = write!(result, "{} ", extension);
|
|
}
|
|
|
|
let _ = write!(result, "{}", full_path.display());
|
|
|
|
if let Some(range) = line_range {
|
|
if range.start() == range.end() {
|
|
let _ = write!(result, ":{}", range.start() + 1);
|
|
} else {
|
|
let _ = write!(result, ":{}-{}", range.start() + 1, range.end() + 1);
|
|
}
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
impl AgentMessage {
|
|
pub fn to_markdown(&self) -> String {
|
|
let mut markdown = String::new();
|
|
|
|
for content in &self.content {
|
|
match content {
|
|
AgentMessageContent::Text(text) => {
|
|
markdown.push_str(text);
|
|
markdown.push('\n');
|
|
}
|
|
AgentMessageContent::Thinking { text, .. } => {
|
|
markdown.push_str("<think>");
|
|
markdown.push_str(text);
|
|
markdown.push_str("</think>\n");
|
|
}
|
|
AgentMessageContent::RedactedThinking(_) => {
|
|
markdown.push_str("<redacted_thinking />\n")
|
|
}
|
|
AgentMessageContent::ToolUse(tool_use) => {
|
|
markdown.push_str(&format!(
|
|
"**Tool Use**: {} (ID: {})\n",
|
|
tool_use.name, tool_use.id
|
|
));
|
|
markdown.push_str(&format!(
|
|
"{}\n",
|
|
MarkdownCodeBlock {
|
|
tag: "json",
|
|
text: &format!("{:#}", tool_use.input)
|
|
}
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
for tool_result in self.tool_results.values() {
|
|
markdown.push_str(&format!(
|
|
"**Tool Result**: {} (ID: {})\n\n",
|
|
tool_result.tool_name, tool_result.tool_use_id
|
|
));
|
|
if tool_result.is_error {
|
|
markdown.push_str("**ERROR:**\n");
|
|
}
|
|
|
|
for part in &tool_result.content {
|
|
match part {
|
|
LanguageModelToolResultContent::Text(text) => {
|
|
writeln!(markdown, "{text}\n").ok();
|
|
}
|
|
LanguageModelToolResultContent::Image(_) => {
|
|
writeln!(markdown, "<image />\n").ok();
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(output) = tool_result.output.as_ref() {
|
|
writeln!(
|
|
markdown,
|
|
"**Debug Output**:\n\n```json\n{}\n```\n",
|
|
serde_json::to_string_pretty(output).unwrap()
|
|
)
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
markdown
|
|
}
|
|
|
|
pub fn to_request(&self) -> Vec<LanguageModelRequestMessage> {
|
|
let mut assistant_message = LanguageModelRequestMessage {
|
|
role: Role::Assistant,
|
|
content: Vec::with_capacity(self.content.len()),
|
|
cache: false,
|
|
reasoning_details: self.reasoning_details.clone(),
|
|
};
|
|
for chunk in &self.content {
|
|
match chunk {
|
|
AgentMessageContent::Text(text) => {
|
|
assistant_message
|
|
.content
|
|
.push(language_model::MessageContent::Text(text.clone()));
|
|
}
|
|
AgentMessageContent::Thinking { text, signature } => {
|
|
assistant_message
|
|
.content
|
|
.push(language_model::MessageContent::Thinking {
|
|
text: text.clone(),
|
|
signature: signature.clone(),
|
|
});
|
|
}
|
|
AgentMessageContent::RedactedThinking(value) => {
|
|
assistant_message.content.push(
|
|
language_model::MessageContent::RedactedThinking(value.clone()),
|
|
);
|
|
}
|
|
AgentMessageContent::ToolUse(tool_use) => {
|
|
if self.tool_results.contains_key(&tool_use.id) {
|
|
assistant_message
|
|
.content
|
|
.push(language_model::MessageContent::ToolUse(tool_use.clone()));
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
let mut user_message = LanguageModelRequestMessage {
|
|
role: Role::User,
|
|
content: Vec::new(),
|
|
cache: false,
|
|
reasoning_details: None,
|
|
};
|
|
|
|
for tool_result in self.tool_results.values() {
|
|
let mut tool_result = tool_result.clone();
|
|
// Surprisingly, the API fails if we return an empty string here.
|
|
// It thinks we are sending a tool use without a tool result.
|
|
if tool_result.is_content_empty() {
|
|
tool_result.content = vec!["<Tool returned an empty string>".into()];
|
|
}
|
|
user_message
|
|
.content
|
|
.push(language_model::MessageContent::ToolResult(tool_result));
|
|
}
|
|
|
|
let mut messages = Vec::new();
|
|
if !assistant_message.content.is_empty() {
|
|
messages.push(assistant_message);
|
|
}
|
|
if !user_message.content.is_empty() {
|
|
messages.push(user_message);
|
|
}
|
|
messages
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct AgentMessage {
|
|
pub content: Vec<AgentMessageContent>,
|
|
pub tool_results: IndexMap<LanguageModelToolUseId, LanguageModelToolResult>,
|
|
pub reasoning_details: Option<serde_json::Value>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum AgentMessageContent {
|
|
Text(String),
|
|
Thinking {
|
|
text: String,
|
|
signature: Option<String>,
|
|
},
|
|
RedactedThinking(String),
|
|
ToolUse(LanguageModelToolUse),
|
|
}
|
|
|
|
pub trait TerminalHandle {
|
|
fn id(&self, cx: &AsyncApp) -> Result<acp::TerminalId>;
|
|
fn current_output(&self, cx: &AsyncApp) -> Result<acp::TerminalOutputResponse>;
|
|
fn wait_for_exit(&self, cx: &AsyncApp) -> Result<Shared<Task<acp::TerminalExitStatus>>>;
|
|
fn kill(&self, cx: &AsyncApp) -> Result<()>;
|
|
fn was_stopped_by_user(&self, cx: &AsyncApp) -> Result<bool>;
|
|
}
|
|
|
|
pub trait SubagentHandle {
|
|
/// The session ID of this subagent thread
|
|
fn id(&self) -> acp::SessionId;
|
|
/// The current number of entries in the thread.
|
|
/// Useful for knowing where the next turn will begin
|
|
fn num_entries(&self, cx: &App) -> usize;
|
|
/// Runs a turn for a given message and returns both the response and the index of that output message.
|
|
fn send(&self, message: String, cx: &AsyncApp) -> Task<Result<String>>;
|
|
}
|
|
|
|
pub trait ThreadEnvironment {
|
|
fn create_terminal(
|
|
&self,
|
|
command: String,
|
|
cwd: Option<PathBuf>,
|
|
output_byte_limit: Option<u64>,
|
|
cx: &mut AsyncApp,
|
|
) -> Task<Result<Rc<dyn TerminalHandle>>>;
|
|
|
|
fn create_subagent(&self, label: String, cx: &mut App) -> Result<Rc<dyn SubagentHandle>>;
|
|
|
|
fn resume_subagent(
|
|
&self,
|
|
_session_id: acp::SessionId,
|
|
_cx: &mut App,
|
|
) -> Result<Rc<dyn SubagentHandle>> {
|
|
Err(anyhow::anyhow!(
|
|
"Resuming subagent sessions is not supported"
|
|
))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum ThreadEvent {
|
|
UserMessage(UserMessage),
|
|
AgentText(String),
|
|
AgentThinking(String),
|
|
ToolCall(acp::ToolCall),
|
|
ToolCallUpdate(acp_thread::ToolCallUpdate),
|
|
Plan(acp::Plan),
|
|
ToolCallAuthorization(ToolCallAuthorization),
|
|
SubagentSpawned(acp::SessionId),
|
|
Retry(acp_thread::RetryStatus),
|
|
Stop(acp::StopReason),
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct NewTerminal {
|
|
pub command: String,
|
|
pub output_byte_limit: Option<u64>,
|
|
pub cwd: Option<PathBuf>,
|
|
pub response: oneshot::Sender<Result<Entity<acp_thread::Terminal>>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ToolPermissionContext {
|
|
pub tool_name: String,
|
|
pub input_values: Vec<String>,
|
|
pub scope: ToolPermissionScope,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ToolPermissionScope {
|
|
ToolInput,
|
|
SymlinkTarget,
|
|
}
|
|
|
|
impl ToolPermissionContext {
|
|
pub fn new(tool_name: impl Into<String>, input_values: Vec<String>) -> Self {
|
|
Self {
|
|
tool_name: tool_name.into(),
|
|
input_values,
|
|
scope: ToolPermissionScope::ToolInput,
|
|
}
|
|
}
|
|
|
|
pub fn symlink_target(tool_name: impl Into<String>, target_paths: Vec<String>) -> Self {
|
|
Self {
|
|
tool_name: tool_name.into(),
|
|
input_values: target_paths,
|
|
scope: ToolPermissionScope::SymlinkTarget,
|
|
}
|
|
}
|
|
|
|
/// Builds the permission options for this tool context.
|
|
///
|
|
/// This is the canonical source for permission option generation.
|
|
/// Tests should use this function rather than manually constructing options.
|
|
///
|
|
/// # Shell Compatibility for Terminal Tool
|
|
///
|
|
/// For the terminal tool, "Always allow" options are only shown when the user's
|
|
/// shell supports POSIX-like command chaining syntax (`&&`, `||`, `;`, `|`).
|
|
///
|
|
/// **Why this matters:** When a user sets up an "always allow" pattern like `^cargo`,
|
|
/// we need to parse the command to extract all sub-commands and verify that EVERY
|
|
/// sub-command matches the pattern. Otherwise, an attacker could craft a command like
|
|
/// `cargo build && rm -rf /` that would bypass the security check.
|
|
///
|
|
/// **Supported shells:** Posix (sh, bash, dash, zsh), Fish 3.0+, PowerShell 7+/Pwsh,
|
|
/// Cmd, Xonsh, Csh, Tcsh
|
|
///
|
|
/// **Unsupported shells:** Nushell (uses `and`/`or` keywords), Elvish (uses `and`/`or`
|
|
/// keywords), Rc (Plan 9 shell - no `&&`/`||` operators)
|
|
///
|
|
/// For unsupported shells, we hide the "Always allow" UI options entirely, and if
|
|
/// the user has `always_allow` rules configured in settings, `ToolPermissionDecision::from_input`
|
|
/// will return a `Deny` with an explanatory error message.
|
|
pub fn build_permission_options(&self) -> acp_thread::PermissionOptions {
|
|
use crate::pattern_extraction::*;
|
|
use util::shell::ShellKind;
|
|
|
|
let tool_name = &self.tool_name;
|
|
let input_values = &self.input_values;
|
|
if self.scope == ToolPermissionScope::SymlinkTarget {
|
|
return acp_thread::PermissionOptions::Flat(vec![
|
|
acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new("allow"),
|
|
"Yes",
|
|
acp::PermissionOptionKind::AllowOnce,
|
|
),
|
|
acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new("deny"),
|
|
"No",
|
|
acp::PermissionOptionKind::RejectOnce,
|
|
),
|
|
]);
|
|
}
|
|
|
|
// Check if the user's shell supports POSIX-like command chaining.
|
|
// See the doc comment above for the full explanation of why this is needed.
|
|
let shell_supports_always_allow = if tool_name == TerminalTool::NAME {
|
|
ShellKind::system().supports_posix_chaining()
|
|
} else {
|
|
true
|
|
};
|
|
|
|
// For terminal commands with multiple pipeline commands, use DropdownWithPatterns
|
|
// to let users individually select which command patterns to always allow.
|
|
if tool_name == TerminalTool::NAME && shell_supports_always_allow {
|
|
if let Some(input) = input_values.first() {
|
|
let all_patterns = extract_all_terminal_patterns(input);
|
|
if all_patterns.len() > 1 {
|
|
let mut choices = Vec::new();
|
|
choices.push(acp_thread::PermissionOptionChoice {
|
|
allow: acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new(format!("always_allow:{}", tool_name)),
|
|
format!("Always for {}", tool_name.replace('_', " ")),
|
|
acp::PermissionOptionKind::AllowAlways,
|
|
),
|
|
deny: acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new(format!("always_deny:{}", tool_name)),
|
|
format!("Always for {}", tool_name.replace('_', " ")),
|
|
acp::PermissionOptionKind::RejectAlways,
|
|
),
|
|
sub_patterns: vec![],
|
|
});
|
|
choices.push(acp_thread::PermissionOptionChoice {
|
|
allow: acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new("allow"),
|
|
"Only this time",
|
|
acp::PermissionOptionKind::AllowOnce,
|
|
),
|
|
deny: acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new("deny"),
|
|
"Only this time",
|
|
acp::PermissionOptionKind::RejectOnce,
|
|
),
|
|
sub_patterns: vec![],
|
|
});
|
|
return acp_thread::PermissionOptions::DropdownWithPatterns {
|
|
choices,
|
|
patterns: all_patterns,
|
|
tool_name: tool_name.clone(),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
let extract_for_value = |value: &str| -> (Option<String>, Option<String>) {
|
|
if tool_name == TerminalTool::NAME {
|
|
(
|
|
extract_terminal_pattern(value),
|
|
extract_terminal_pattern_display(value),
|
|
)
|
|
} else if tool_name == CopyPathTool::NAME
|
|
|| tool_name == MovePathTool::NAME
|
|
|| tool_name == EditFileTool::NAME
|
|
|| tool_name == WriteFileTool::NAME
|
|
|| tool_name == DeletePathTool::NAME
|
|
|| tool_name == CreateDirectoryTool::NAME
|
|
|| tool_name == SaveFileTool::NAME
|
|
{
|
|
(
|
|
extract_path_pattern(value),
|
|
extract_path_pattern_display(value),
|
|
)
|
|
} else if tool_name == FetchTool::NAME {
|
|
(
|
|
extract_url_pattern(value),
|
|
extract_url_pattern_display(value),
|
|
)
|
|
} else {
|
|
(None, None)
|
|
}
|
|
};
|
|
|
|
// Extract patterns from all input values. Only offer a pattern-specific
|
|
// "always allow/deny" button when every value produces the same pattern.
|
|
let (pattern, pattern_display) = match input_values.as_slice() {
|
|
[single] => extract_for_value(single),
|
|
_ => {
|
|
let mut iter = input_values.iter().map(|v| extract_for_value(v));
|
|
match iter.next() {
|
|
Some(first) => {
|
|
if iter.all(|pair| pair.0 == first.0) {
|
|
first
|
|
} else {
|
|
(None, None)
|
|
}
|
|
}
|
|
None => (None, None),
|
|
}
|
|
}
|
|
};
|
|
|
|
let mut choices = Vec::new();
|
|
|
|
let mut push_choice =
|
|
|label: String, allow_id, deny_id, allow_kind, deny_kind, sub_patterns: Vec<String>| {
|
|
choices.push(acp_thread::PermissionOptionChoice {
|
|
allow: acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new(allow_id),
|
|
label.clone(),
|
|
allow_kind,
|
|
),
|
|
deny: acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new(deny_id),
|
|
label,
|
|
deny_kind,
|
|
),
|
|
sub_patterns,
|
|
});
|
|
};
|
|
|
|
if shell_supports_always_allow {
|
|
push_choice(
|
|
format!("Always for {}", tool_name.replace('_', " ")),
|
|
format!("always_allow:{}", tool_name),
|
|
format!("always_deny:{}", tool_name),
|
|
acp::PermissionOptionKind::AllowAlways,
|
|
acp::PermissionOptionKind::RejectAlways,
|
|
vec![],
|
|
);
|
|
|
|
if let (Some(pattern), Some(display)) = (pattern, pattern_display) {
|
|
let button_text = if tool_name == TerminalTool::NAME {
|
|
format!("Always for `{}` commands", display)
|
|
} else {
|
|
format!("Always for `{}`", display)
|
|
};
|
|
push_choice(
|
|
button_text,
|
|
format!("always_allow:{}", tool_name),
|
|
format!("always_deny:{}", tool_name),
|
|
acp::PermissionOptionKind::AllowAlways,
|
|
acp::PermissionOptionKind::RejectAlways,
|
|
vec![pattern],
|
|
);
|
|
}
|
|
}
|
|
|
|
push_choice(
|
|
"Only this time".to_string(),
|
|
"allow".to_string(),
|
|
"deny".to_string(),
|
|
acp::PermissionOptionKind::AllowOnce,
|
|
acp::PermissionOptionKind::RejectOnce,
|
|
vec![],
|
|
);
|
|
|
|
acp_thread::PermissionOptions::Dropdown(choices)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct ToolCallAuthorization {
|
|
pub tool_call: acp::ToolCallUpdate,
|
|
pub options: acp_thread::PermissionOptions,
|
|
pub response: oneshot::Sender<acp_thread::SelectedPermissionOutcome>,
|
|
pub context: Option<ToolPermissionContext>,
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
enum CompletionError {
|
|
#[error("max tokens")]
|
|
MaxTokens,
|
|
#[error("refusal")]
|
|
Refusal,
|
|
#[error(transparent)]
|
|
Other(#[from] anyhow::Error),
|
|
}
|
|
|
|
pub struct Thread {
|
|
id: acp::SessionId,
|
|
prompt_id: PromptId,
|
|
updated_at: DateTime<Utc>,
|
|
title: Option<SharedString>,
|
|
pending_title_generation: Option<Task<()>>,
|
|
title_generation_failed: bool,
|
|
pending_summary_generation: Option<Shared<Task<Option<SharedString>>>>,
|
|
summary: Option<SharedString>,
|
|
messages: Vec<Message>,
|
|
user_store: Entity<UserStore>,
|
|
/// Holds the task that handles agent interaction until the end of the turn.
|
|
/// Survives across multiple requests as the model performs tool calls and
|
|
/// we run tools, report their results.
|
|
running_turn: Option<RunningTurn>,
|
|
/// Flag indicating the UI has a queued message waiting to be sent.
|
|
/// Used to signal that the turn should end at the next message boundary.
|
|
has_queued_message: bool,
|
|
pending_message: Option<AgentMessage>,
|
|
pub(crate) tools: BTreeMap<SharedString, Arc<dyn AnyAgentTool>>,
|
|
request_token_usage: HashMap<UserMessageId, language_model::TokenUsage>,
|
|
#[allow(unused)]
|
|
cumulative_token_usage: TokenUsage,
|
|
#[allow(unused)]
|
|
initial_project_snapshot: Shared<Task<Option<Arc<ProjectSnapshot>>>>,
|
|
pub(crate) context_server_registry: Entity<ContextServerRegistry>,
|
|
profile_id: AgentProfileId,
|
|
project_context: Entity<ProjectContext>,
|
|
pub(crate) templates: Arc<Templates>,
|
|
model: Option<Arc<dyn LanguageModel>>,
|
|
summarization_model: Option<Arc<dyn LanguageModel>>,
|
|
thinking_enabled: bool,
|
|
thinking_effort: Option<String>,
|
|
speed: Option<Speed>,
|
|
prompt_capabilities_tx: watch::Sender<acp::PromptCapabilities>,
|
|
pub(crate) prompt_capabilities_rx: watch::Receiver<acp::PromptCapabilities>,
|
|
pub(crate) project: Entity<Project>,
|
|
pub(crate) action_log: Entity<ActionLog>,
|
|
/// True if this thread was imported from a shared thread and can be synced.
|
|
imported: bool,
|
|
/// If this is a subagent thread, contains context about the parent
|
|
subagent_context: Option<SubagentContext>,
|
|
/// The user's unsent prompt text, persisted so it can be restored when reloading the thread.
|
|
draft_prompt: Option<Vec<acp::ContentBlock>>,
|
|
ui_scroll_position: Option<gpui::ListOffset>,
|
|
/// Weak references to running subagent threads for cancellation propagation
|
|
running_subagents: Vec<WeakEntity<Thread>>,
|
|
}
|
|
|
|
impl Thread {
|
|
fn prompt_capabilities(model: Option<&dyn LanguageModel>) -> acp::PromptCapabilities {
|
|
let image = model.map_or(true, |model| model.supports_images());
|
|
acp::PromptCapabilities::new()
|
|
.image(image)
|
|
.embedded_context(true)
|
|
}
|
|
|
|
pub fn new_subagent(parent_thread: &Entity<Thread>, cx: &mut Context<Self>) -> Self {
|
|
let project = parent_thread.read(cx).project.clone();
|
|
let project_context = parent_thread.read(cx).project_context.clone();
|
|
let context_server_registry = parent_thread.read(cx).context_server_registry.clone();
|
|
let templates = parent_thread.read(cx).templates.clone();
|
|
let model = parent_thread.read(cx).model().cloned();
|
|
let parent_action_log = parent_thread.read(cx).action_log().clone();
|
|
let action_log =
|
|
cx.new(|_cx| ActionLog::new(project.clone()).with_linked_action_log(parent_action_log));
|
|
let mut thread = Self::new_internal(
|
|
project,
|
|
project_context,
|
|
context_server_registry,
|
|
templates,
|
|
model,
|
|
action_log,
|
|
cx,
|
|
);
|
|
thread.subagent_context = Some(SubagentContext {
|
|
parent_thread_id: parent_thread.read(cx).id().clone(),
|
|
depth: parent_thread.read(cx).depth() + 1,
|
|
});
|
|
thread.inherit_parent_settings(parent_thread, cx);
|
|
thread
|
|
}
|
|
|
|
pub fn new(
|
|
project: Entity<Project>,
|
|
project_context: Entity<ProjectContext>,
|
|
context_server_registry: Entity<ContextServerRegistry>,
|
|
templates: Arc<Templates>,
|
|
model: Option<Arc<dyn LanguageModel>>,
|
|
cx: &mut Context<Self>,
|
|
) -> Self {
|
|
Self::new_internal(
|
|
project.clone(),
|
|
project_context,
|
|
context_server_registry,
|
|
templates,
|
|
model,
|
|
cx.new(|_cx| ActionLog::new(project)),
|
|
cx,
|
|
)
|
|
}
|
|
|
|
fn new_internal(
|
|
project: Entity<Project>,
|
|
project_context: Entity<ProjectContext>,
|
|
context_server_registry: Entity<ContextServerRegistry>,
|
|
templates: Arc<Templates>,
|
|
model: Option<Arc<dyn LanguageModel>>,
|
|
action_log: Entity<ActionLog>,
|
|
cx: &mut Context<Self>,
|
|
) -> Self {
|
|
let settings = AgentSettings::get_global(cx);
|
|
let profile_id = settings.default_profile.clone();
|
|
let enable_thinking = settings
|
|
.default_model
|
|
.as_ref()
|
|
.is_some_and(|model| model.enable_thinking);
|
|
let thinking_effort = settings
|
|
.default_model
|
|
.as_ref()
|
|
.and_then(|model| model.effort.clone());
|
|
let speed = settings
|
|
.default_model
|
|
.as_ref()
|
|
.and_then(|model| model.speed);
|
|
let (prompt_capabilities_tx, prompt_capabilities_rx) =
|
|
watch::channel(Self::prompt_capabilities(model.as_deref()));
|
|
Self {
|
|
id: acp::SessionId::new(uuid::Uuid::new_v4().to_string()),
|
|
prompt_id: PromptId::new(),
|
|
updated_at: Utc::now(),
|
|
title: None,
|
|
pending_title_generation: None,
|
|
title_generation_failed: false,
|
|
pending_summary_generation: None,
|
|
summary: None,
|
|
messages: Vec::new(),
|
|
user_store: project.read(cx).user_store(),
|
|
running_turn: None,
|
|
has_queued_message: false,
|
|
pending_message: None,
|
|
tools: BTreeMap::default(),
|
|
request_token_usage: HashMap::default(),
|
|
cumulative_token_usage: TokenUsage::default(),
|
|
initial_project_snapshot: {
|
|
let project_snapshot = Self::project_snapshot(project.clone(), cx);
|
|
cx.foreground_executor()
|
|
.spawn(async move { Some(project_snapshot.await) })
|
|
.shared()
|
|
},
|
|
context_server_registry,
|
|
profile_id,
|
|
project_context,
|
|
templates,
|
|
model,
|
|
summarization_model: None,
|
|
thinking_enabled: enable_thinking,
|
|
speed,
|
|
thinking_effort,
|
|
prompt_capabilities_tx,
|
|
prompt_capabilities_rx,
|
|
project,
|
|
action_log,
|
|
imported: false,
|
|
subagent_context: None,
|
|
draft_prompt: None,
|
|
ui_scroll_position: None,
|
|
running_subagents: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Copies runtime-mutable settings from the parent thread so that
|
|
/// subagents start with the same configuration the user selected.
|
|
/// Every property that `set_*` propagates to `running_subagents`
|
|
/// should be inherited here as well.
|
|
fn inherit_parent_settings(&mut self, parent_thread: &Entity<Thread>, cx: &mut Context<Self>) {
|
|
let parent = parent_thread.read(cx);
|
|
self.speed = parent.speed;
|
|
self.thinking_enabled = parent.thinking_enabled;
|
|
self.thinking_effort = parent.thinking_effort.clone();
|
|
self.summarization_model = parent.summarization_model.clone();
|
|
self.profile_id = parent.profile_id.clone();
|
|
}
|
|
|
|
pub fn id(&self) -> &acp::SessionId {
|
|
&self.id
|
|
}
|
|
|
|
/// Returns true if this thread was imported from a shared thread.
|
|
pub fn is_imported(&self) -> bool {
|
|
self.imported
|
|
}
|
|
|
|
pub fn replay(
|
|
&mut self,
|
|
cx: &mut Context<Self>,
|
|
) -> mpsc::UnboundedReceiver<Result<ThreadEvent>> {
|
|
let (tx, rx) = mpsc::unbounded();
|
|
let stream = ThreadEventStream(tx);
|
|
for message in &self.messages {
|
|
match message {
|
|
Message::User(user_message) => stream.send_user_message(user_message),
|
|
Message::Agent(assistant_message) => {
|
|
for content in &assistant_message.content {
|
|
match content {
|
|
AgentMessageContent::Text(text) => stream.send_text(text),
|
|
AgentMessageContent::Thinking { text, .. } => {
|
|
stream.send_thinking(text)
|
|
}
|
|
AgentMessageContent::RedactedThinking(_) => {}
|
|
AgentMessageContent::ToolUse(tool_use) => {
|
|
self.replay_tool_call(
|
|
tool_use,
|
|
assistant_message.tool_results.get(&tool_use.id),
|
|
&stream,
|
|
cx,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Message::Resume => {}
|
|
}
|
|
}
|
|
rx
|
|
}
|
|
|
|
fn replay_tool_call(
|
|
&self,
|
|
tool_use: &LanguageModelToolUse,
|
|
tool_result: Option<&LanguageModelToolResult>,
|
|
stream: &ThreadEventStream,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
// Extract saved output and status first, so they're available even if tool is not found
|
|
let output = tool_result
|
|
.as_ref()
|
|
.and_then(|result| result.output.clone());
|
|
let status = tool_result
|
|
.as_ref()
|
|
.map_or(acp::ToolCallStatus::Failed, |result| {
|
|
if result.is_error {
|
|
acp::ToolCallStatus::Failed
|
|
} else {
|
|
acp::ToolCallStatus::Completed
|
|
}
|
|
});
|
|
|
|
let tool = self.tools.get(tool_use.name.as_ref()).cloned().or_else(|| {
|
|
self.context_server_registry
|
|
.read(cx)
|
|
.servers()
|
|
.find_map(|(_, tools)| {
|
|
if let Some(tool) = tools.get(tool_use.name.as_ref()) {
|
|
Some(tool.clone())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
});
|
|
|
|
let Some(tool) = tool else {
|
|
// Tool not found (e.g., MCP server not connected after restart),
|
|
// but still display the saved result if available.
|
|
// We need to send both ToolCall and ToolCallUpdate events because the UI
|
|
// only converts raw_output to displayable content in update_fields, not from_acp.
|
|
stream
|
|
.0
|
|
.unbounded_send(Ok(ThreadEvent::ToolCall(
|
|
acp::ToolCall::new(tool_use.id.to_string(), tool_use.name.to_string())
|
|
.status(status)
|
|
.raw_input(tool_use.input.clone()),
|
|
)))
|
|
.ok();
|
|
stream.update_tool_call_fields(
|
|
&tool_use.id,
|
|
acp::ToolCallUpdateFields::new()
|
|
.status(status)
|
|
.raw_output(output),
|
|
None,
|
|
);
|
|
return;
|
|
};
|
|
|
|
let title = tool.initial_title(tool_use.input.clone(), cx);
|
|
let kind = tool.kind();
|
|
stream.send_tool_call(
|
|
&tool_use.id,
|
|
&tool_use.name,
|
|
title,
|
|
kind,
|
|
tool_use.input.clone(),
|
|
);
|
|
|
|
if let Some(output) = output.clone() {
|
|
// For replay, we use a dummy cancellation receiver since the tool already completed
|
|
let (_cancellation_tx, cancellation_rx) = watch::channel(false);
|
|
let tool_event_stream = ToolCallEventStream::new(
|
|
tool_use.id.clone(),
|
|
stream.clone(),
|
|
Some(self.project.read(cx).fs().clone()),
|
|
cancellation_rx,
|
|
);
|
|
tool.replay(tool_use.input.clone(), output, tool_event_stream, cx)
|
|
.log_err();
|
|
}
|
|
|
|
stream.update_tool_call_fields(
|
|
&tool_use.id,
|
|
acp::ToolCallUpdateFields::new()
|
|
.status(status)
|
|
.raw_output(output),
|
|
None,
|
|
);
|
|
}
|
|
|
|
pub fn from_db(
|
|
id: acp::SessionId,
|
|
db_thread: DbThread,
|
|
project: Entity<Project>,
|
|
project_context: Entity<ProjectContext>,
|
|
context_server_registry: Entity<ContextServerRegistry>,
|
|
templates: Arc<Templates>,
|
|
cx: &mut Context<Self>,
|
|
) -> Self {
|
|
let settings = AgentSettings::get_global(cx);
|
|
let profile_id = db_thread
|
|
.profile
|
|
.unwrap_or_else(|| settings.default_profile.clone());
|
|
|
|
let mut model = LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
|
|
db_thread
|
|
.model
|
|
.and_then(|model| {
|
|
let model = SelectedModel {
|
|
provider: model.provider.clone().into(),
|
|
model: model.model.into(),
|
|
};
|
|
registry.select_model(&model, cx)
|
|
})
|
|
.or_else(|| registry.default_model())
|
|
.map(|model| model.model)
|
|
});
|
|
|
|
if model.is_none() {
|
|
model = Self::resolve_profile_model(&profile_id, cx);
|
|
}
|
|
if model.is_none() {
|
|
model = LanguageModelRegistry::global(cx).update(cx, |registry, _cx| {
|
|
registry.default_model().map(|model| model.model)
|
|
});
|
|
}
|
|
|
|
let (prompt_capabilities_tx, prompt_capabilities_rx) =
|
|
watch::channel(Self::prompt_capabilities(model.as_deref()));
|
|
|
|
let action_log = cx.new(|_| ActionLog::new(project.clone()));
|
|
|
|
Self {
|
|
id,
|
|
prompt_id: PromptId::new(),
|
|
title: if db_thread.title.is_empty() {
|
|
None
|
|
} else {
|
|
Some(db_thread.title.clone())
|
|
},
|
|
pending_title_generation: None,
|
|
title_generation_failed: false,
|
|
pending_summary_generation: None,
|
|
summary: db_thread.detailed_summary,
|
|
messages: db_thread.messages,
|
|
user_store: project.read(cx).user_store(),
|
|
running_turn: None,
|
|
has_queued_message: false,
|
|
pending_message: None,
|
|
tools: BTreeMap::default(),
|
|
request_token_usage: db_thread.request_token_usage.clone(),
|
|
cumulative_token_usage: db_thread.cumulative_token_usage,
|
|
initial_project_snapshot: Task::ready(db_thread.initial_project_snapshot).shared(),
|
|
context_server_registry,
|
|
profile_id,
|
|
project_context,
|
|
templates,
|
|
model,
|
|
summarization_model: None,
|
|
thinking_enabled: db_thread.thinking_enabled,
|
|
thinking_effort: db_thread.thinking_effort,
|
|
speed: db_thread.speed,
|
|
project,
|
|
action_log,
|
|
updated_at: db_thread.updated_at,
|
|
prompt_capabilities_tx,
|
|
prompt_capabilities_rx,
|
|
imported: db_thread.imported,
|
|
subagent_context: db_thread.subagent_context,
|
|
draft_prompt: db_thread.draft_prompt,
|
|
ui_scroll_position: db_thread.ui_scroll_position.map(|sp| gpui::ListOffset {
|
|
item_ix: sp.item_ix,
|
|
offset_in_item: gpui::px(sp.offset_in_item),
|
|
}),
|
|
running_subagents: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn to_db(&self, cx: &App) -> Task<DbThread> {
|
|
let initial_project_snapshot = self.initial_project_snapshot.clone();
|
|
let mut thread = DbThread {
|
|
title: self.title().unwrap_or_default(),
|
|
messages: self.messages.clone(),
|
|
updated_at: self.updated_at,
|
|
detailed_summary: self.summary.clone(),
|
|
initial_project_snapshot: None,
|
|
cumulative_token_usage: self.cumulative_token_usage,
|
|
request_token_usage: self.request_token_usage.clone(),
|
|
model: self.model.as_ref().map(|model| DbLanguageModel {
|
|
provider: model.provider_id().to_string(),
|
|
model: model.id().0.to_string(),
|
|
}),
|
|
profile: Some(self.profile_id.clone()),
|
|
imported: self.imported,
|
|
subagent_context: self.subagent_context.clone(),
|
|
speed: self.speed,
|
|
thinking_enabled: self.thinking_enabled,
|
|
thinking_effort: self.thinking_effort.clone(),
|
|
draft_prompt: self.draft_prompt.clone(),
|
|
ui_scroll_position: self.ui_scroll_position.map(|lo| {
|
|
crate::db::SerializedScrollPosition {
|
|
item_ix: lo.item_ix,
|
|
offset_in_item: lo.offset_in_item.as_f32(),
|
|
}
|
|
}),
|
|
};
|
|
|
|
cx.background_spawn(async move {
|
|
let initial_project_snapshot = initial_project_snapshot.await;
|
|
thread.initial_project_snapshot = initial_project_snapshot;
|
|
thread
|
|
})
|
|
}
|
|
|
|
/// Create a snapshot of the current project state including git information and unsaved buffers.
|
|
fn project_snapshot(
|
|
project: Entity<Project>,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Arc<ProjectSnapshot>> {
|
|
let task = project::telemetry_snapshot::TelemetrySnapshot::new(&project, cx);
|
|
cx.spawn(async move |_, _| {
|
|
let snapshot = task.await;
|
|
|
|
Arc::new(ProjectSnapshot {
|
|
worktree_snapshots: snapshot.worktree_snapshots,
|
|
timestamp: Utc::now(),
|
|
})
|
|
})
|
|
}
|
|
|
|
pub fn project_context(&self) -> &Entity<ProjectContext> {
|
|
&self.project_context
|
|
}
|
|
|
|
pub fn project(&self) -> &Entity<Project> {
|
|
&self.project
|
|
}
|
|
|
|
pub fn action_log(&self) -> &Entity<ActionLog> {
|
|
&self.action_log
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.messages.is_empty() && self.title.is_none()
|
|
}
|
|
|
|
pub fn draft_prompt(&self) -> Option<&[acp::ContentBlock]> {
|
|
self.draft_prompt.as_deref()
|
|
}
|
|
|
|
pub fn set_draft_prompt(&mut self, prompt: Option<Vec<acp::ContentBlock>>) {
|
|
self.draft_prompt = prompt;
|
|
}
|
|
|
|
pub fn ui_scroll_position(&self) -> Option<gpui::ListOffset> {
|
|
self.ui_scroll_position
|
|
}
|
|
|
|
pub fn set_ui_scroll_position(&mut self, position: Option<gpui::ListOffset>) {
|
|
self.ui_scroll_position = position;
|
|
}
|
|
|
|
pub fn model(&self) -> Option<&Arc<dyn LanguageModel>> {
|
|
self.model.as_ref()
|
|
}
|
|
|
|
pub fn set_model(&mut self, model: Arc<dyn LanguageModel>, cx: &mut Context<Self>) {
|
|
let old_usage = self.latest_token_usage();
|
|
self.model = Some(model.clone());
|
|
let new_caps = Self::prompt_capabilities(self.model.as_deref());
|
|
let new_usage = self.latest_token_usage();
|
|
if old_usage != new_usage {
|
|
cx.emit(TokenUsageUpdated(new_usage));
|
|
}
|
|
self.prompt_capabilities_tx.send(new_caps).log_err();
|
|
|
|
for subagent in &self.running_subagents {
|
|
subagent
|
|
.update(cx, |thread, cx| thread.set_model(model.clone(), cx))
|
|
.ok();
|
|
}
|
|
|
|
cx.notify()
|
|
}
|
|
|
|
pub fn summarization_model(&self) -> Option<&Arc<dyn LanguageModel>> {
|
|
self.summarization_model.as_ref()
|
|
}
|
|
|
|
pub fn set_summarization_model(
|
|
&mut self,
|
|
model: Option<Arc<dyn LanguageModel>>,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.summarization_model = model.clone();
|
|
|
|
for subagent in &self.running_subagents {
|
|
subagent
|
|
.update(cx, |thread, cx| {
|
|
thread.set_summarization_model(model.clone(), cx)
|
|
})
|
|
.ok();
|
|
}
|
|
cx.notify()
|
|
}
|
|
|
|
pub fn thinking_enabled(&self) -> bool {
|
|
self.thinking_enabled
|
|
}
|
|
|
|
pub fn set_thinking_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
|
|
self.thinking_enabled = enabled;
|
|
|
|
for subagent in &self.running_subagents {
|
|
subagent
|
|
.update(cx, |thread, cx| thread.set_thinking_enabled(enabled, cx))
|
|
.ok();
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
pub fn thinking_effort(&self) -> Option<&String> {
|
|
self.thinking_effort.as_ref()
|
|
}
|
|
|
|
pub fn set_thinking_effort(&mut self, effort: Option<String>, cx: &mut Context<Self>) {
|
|
self.thinking_effort = effort.clone();
|
|
|
|
for subagent in &self.running_subagents {
|
|
subagent
|
|
.update(cx, |thread, cx| {
|
|
thread.set_thinking_effort(effort.clone(), cx)
|
|
})
|
|
.ok();
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
pub fn speed(&self) -> Option<Speed> {
|
|
self.speed
|
|
}
|
|
|
|
pub fn set_speed(&mut self, speed: Speed, cx: &mut Context<Self>) {
|
|
self.speed = Some(speed);
|
|
|
|
for subagent in &self.running_subagents {
|
|
subagent
|
|
.update(cx, |thread, cx| thread.set_speed(speed, cx))
|
|
.ok();
|
|
}
|
|
cx.notify();
|
|
}
|
|
|
|
pub fn last_message(&self) -> Option<&Message> {
|
|
self.messages.last()
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub fn last_received_or_pending_message(&self) -> Option<Message> {
|
|
if let Some(message) = self.pending_message.clone() {
|
|
Some(Message::Agent(message))
|
|
} else {
|
|
self.messages.last().cloned()
|
|
}
|
|
}
|
|
|
|
pub fn add_default_tools(
|
|
&mut self,
|
|
environment: Rc<dyn ThreadEnvironment>,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
// Only update the agent location for the root thread, not for subagents.
|
|
let update_agent_location = self.parent_thread_id().is_none();
|
|
|
|
let language_registry = self.project.read(cx).languages().clone();
|
|
self.add_tool(CopyPathTool::new(self.project.clone()));
|
|
self.add_tool(CreateDirectoryTool::new(self.project.clone()));
|
|
self.add_tool(DeletePathTool::new(
|
|
self.project.clone(),
|
|
self.action_log.clone(),
|
|
));
|
|
self.add_tool(EditFileTool::new(
|
|
self.project.clone(),
|
|
cx.weak_entity(),
|
|
self.action_log.clone(),
|
|
language_registry.clone(),
|
|
));
|
|
self.add_tool(WriteFileTool::new(
|
|
self.project.clone(),
|
|
cx.weak_entity(),
|
|
self.action_log.clone(),
|
|
language_registry,
|
|
));
|
|
self.add_tool(FetchTool::new(self.project.read(cx).client().http_client()));
|
|
self.add_tool(FindPathTool::new(self.project.clone()));
|
|
self.add_tool(GrepTool::new(self.project.clone()));
|
|
self.add_tool(ListDirectoryTool::new(self.project.clone()));
|
|
self.add_tool(MovePathTool::new(self.project.clone()));
|
|
self.add_tool(NowTool);
|
|
self.add_tool(OpenTool::new(self.project.clone()));
|
|
if cx.has_flag::<UpdatePlanToolFeatureFlag>() {
|
|
self.add_tool(UpdatePlanTool);
|
|
}
|
|
self.add_tool(ReadFileTool::new(
|
|
self.project.clone(),
|
|
self.action_log.clone(),
|
|
update_agent_location,
|
|
));
|
|
self.add_tool(SaveFileTool::new(self.project.clone()));
|
|
self.add_tool(RestoreFileFromDiskTool::new(self.project.clone()));
|
|
self.add_tool(TerminalTool::new(self.project.clone(), environment.clone()));
|
|
self.add_tool(WebSearchTool);
|
|
|
|
self.add_tool(DiagnosticsTool::new(self.project.clone()));
|
|
|
|
let code_action_store: CodeActionStore = cx.new(|_cx| None);
|
|
self.add_tool(FindReferencesTool::new(self.project.clone()));
|
|
self.add_tool(GetCodeActionsTool::new(
|
|
self.project.clone(),
|
|
code_action_store.clone(),
|
|
));
|
|
self.add_tool(ApplyCodeActionTool::new(
|
|
self.project.clone(),
|
|
code_action_store,
|
|
));
|
|
self.add_tool(GoToDefinitionTool::new(self.project.clone()));
|
|
self.add_tool(RenameTool::new(self.project.clone()));
|
|
|
|
if self.depth() < MAX_SUBAGENT_DEPTH {
|
|
self.add_tool(SpawnAgentTool::new(environment));
|
|
}
|
|
}
|
|
|
|
pub fn add_tool<T: AgentTool>(&mut self, tool: T) {
|
|
debug_assert!(
|
|
!self.tools.contains_key(T::NAME),
|
|
"Duplicate tool name: {}",
|
|
T::NAME,
|
|
);
|
|
self.tools.insert(T::NAME.into(), tool.erase());
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub fn remove_tool(&mut self, name: &str) -> bool {
|
|
self.tools.remove(name).is_some()
|
|
}
|
|
|
|
pub fn profile(&self) -> &AgentProfileId {
|
|
&self.profile_id
|
|
}
|
|
|
|
pub fn set_profile(&mut self, profile_id: AgentProfileId, cx: &mut Context<Self>) {
|
|
if self.profile_id == profile_id {
|
|
return;
|
|
}
|
|
|
|
self.profile_id = profile_id.clone();
|
|
|
|
// Swap to the profile's preferred model when available.
|
|
if let Some(model) = Self::resolve_profile_model(&self.profile_id, cx) {
|
|
self.set_model(model, cx);
|
|
}
|
|
|
|
for subagent in &self.running_subagents {
|
|
subagent
|
|
.update(cx, |thread, cx| thread.set_profile(profile_id.clone(), cx))
|
|
.ok();
|
|
}
|
|
}
|
|
|
|
pub fn cancel(&mut self, cx: &mut Context<Self>) -> Task<()> {
|
|
for subagent in self.running_subagents.drain(..) {
|
|
if let Some(subagent) = subagent.upgrade() {
|
|
subagent.update(cx, |thread, cx| thread.cancel(cx)).detach();
|
|
}
|
|
}
|
|
|
|
let Some(running_turn) = self.running_turn.take() else {
|
|
self.flush_pending_message(cx);
|
|
return Task::ready(());
|
|
};
|
|
|
|
let turn_task = running_turn.cancel();
|
|
|
|
cx.spawn(async move |this, cx| {
|
|
turn_task.await;
|
|
this.update(cx, |this, cx| {
|
|
this.flush_pending_message(cx);
|
|
})
|
|
.ok();
|
|
})
|
|
}
|
|
|
|
pub fn set_has_queued_message(&mut self, has_queued: bool) {
|
|
self.has_queued_message = has_queued;
|
|
}
|
|
|
|
pub fn has_queued_message(&self) -> bool {
|
|
self.has_queued_message
|
|
}
|
|
|
|
fn update_token_usage(&mut self, update: language_model::TokenUsage, cx: &mut Context<Self>) {
|
|
let Some(last_user_message) = self.last_user_message() else {
|
|
return;
|
|
};
|
|
|
|
self.request_token_usage
|
|
.insert(last_user_message.id.clone(), update);
|
|
cx.emit(TokenUsageUpdated(self.latest_token_usage()));
|
|
cx.notify();
|
|
}
|
|
|
|
pub fn truncate(&mut self, message_id: UserMessageId, cx: &mut Context<Self>) -> Result<()> {
|
|
self.cancel(cx).detach();
|
|
// Clear pending message since cancel will try to flush it asynchronously,
|
|
// and we don't want that content to be added after we truncate
|
|
self.pending_message.take();
|
|
let Some(position) = self.messages.iter().position(
|
|
|msg| matches!(msg, Message::User(UserMessage { id, .. }) if id == &message_id),
|
|
) else {
|
|
return Err(anyhow!("Message not found"));
|
|
};
|
|
|
|
for message in self.messages.drain(position..) {
|
|
match message {
|
|
Message::User(message) => {
|
|
self.request_token_usage.remove(&message.id);
|
|
}
|
|
Message::Agent(_) | Message::Resume => {}
|
|
}
|
|
}
|
|
self.clear_summary();
|
|
cx.notify();
|
|
Ok(())
|
|
}
|
|
|
|
pub fn latest_request_token_usage(&self) -> Option<language_model::TokenUsage> {
|
|
let last_user_message = self.last_user_message()?;
|
|
let tokens = self.request_token_usage.get(&last_user_message.id)?;
|
|
Some(*tokens)
|
|
}
|
|
|
|
pub fn latest_token_usage(&self) -> Option<acp_thread::TokenUsage> {
|
|
let usage = self.latest_request_token_usage()?;
|
|
let model = self.model.clone()?;
|
|
Some(acp_thread::TokenUsage {
|
|
max_tokens: model.max_token_count(),
|
|
max_output_tokens: model.max_output_tokens(),
|
|
used_tokens: usage.total_tokens(),
|
|
input_tokens: usage.input_tokens,
|
|
output_tokens: usage.output_tokens,
|
|
})
|
|
}
|
|
|
|
/// Get the total input token count as of the message before the given message.
|
|
///
|
|
/// Returns `None` if:
|
|
/// - `target_id` is the first message (no previous message)
|
|
/// - The previous message hasn't received a response yet (no usage data)
|
|
/// - `target_id` is not found in the messages
|
|
pub fn tokens_before_message(&self, target_id: &UserMessageId) -> Option<u64> {
|
|
let mut previous_user_message_id: Option<&UserMessageId> = None;
|
|
|
|
for message in &self.messages {
|
|
if let Message::User(user_msg) = message {
|
|
if &user_msg.id == target_id {
|
|
let prev_id = previous_user_message_id?;
|
|
let usage = self.request_token_usage.get(prev_id)?;
|
|
return Some(usage.input_tokens);
|
|
}
|
|
previous_user_message_id = Some(&user_msg.id);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Look up the active profile and resolve its preferred model if one is configured.
|
|
fn resolve_profile_model(
|
|
profile_id: &AgentProfileId,
|
|
cx: &mut Context<Self>,
|
|
) -> Option<Arc<dyn LanguageModel>> {
|
|
let selection = AgentSettings::get_global(cx)
|
|
.profiles
|
|
.get(profile_id)?
|
|
.default_model
|
|
.clone()?;
|
|
Self::resolve_model_from_selection(&selection, cx)
|
|
}
|
|
|
|
/// Translate a stored model selection into the configured model from the registry.
|
|
fn resolve_model_from_selection(
|
|
selection: &LanguageModelSelection,
|
|
cx: &mut Context<Self>,
|
|
) -> Option<Arc<dyn LanguageModel>> {
|
|
let selected = SelectedModel {
|
|
provider: LanguageModelProviderId::from(selection.provider.0.clone()),
|
|
model: LanguageModelId::from(selection.model.clone()),
|
|
};
|
|
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
|
|
registry
|
|
.select_model(&selected, cx)
|
|
.map(|configured| configured.model)
|
|
})
|
|
}
|
|
|
|
pub fn resume(
|
|
&mut self,
|
|
cx: &mut Context<Self>,
|
|
) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>> {
|
|
self.messages.push(Message::Resume);
|
|
cx.notify();
|
|
|
|
log::debug!("Total messages in thread: {}", self.messages.len());
|
|
self.run_turn(cx)
|
|
}
|
|
|
|
/// Sending a message results in the model streaming a response, which could include tool calls.
|
|
/// After calling tools, the model will stops and waits for any outstanding tool calls to be completed and their results sent.
|
|
/// The returned channel will report all the occurrences in which the model stops before erroring or ending its turn.
|
|
pub fn send<T>(
|
|
&mut self,
|
|
id: UserMessageId,
|
|
content: impl IntoIterator<Item = T>,
|
|
cx: &mut Context<Self>,
|
|
) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>>
|
|
where
|
|
T: Into<UserMessageContent>,
|
|
{
|
|
let content = content.into_iter().map(Into::into).collect::<Vec<_>>();
|
|
log::debug!("Thread::send content: {:?}", content);
|
|
|
|
self.messages
|
|
.push(Message::User(UserMessage { id, content }));
|
|
cx.notify();
|
|
|
|
self.send_existing(cx)
|
|
}
|
|
|
|
pub fn send_existing(
|
|
&mut self,
|
|
cx: &mut Context<Self>,
|
|
) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>> {
|
|
let model = self
|
|
.model()
|
|
.ok_or_else(|| anyhow!(NoModelConfiguredError))?;
|
|
|
|
log::info!("Thread::send called with model: {}", model.name().0);
|
|
self.advance_prompt_id();
|
|
|
|
log::debug!("Total messages in thread: {}", self.messages.len());
|
|
self.run_turn(cx)
|
|
}
|
|
|
|
pub fn push_acp_user_block(
|
|
&mut self,
|
|
id: UserMessageId,
|
|
blocks: impl IntoIterator<Item = acp::ContentBlock>,
|
|
path_style: PathStyle,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let content = blocks
|
|
.into_iter()
|
|
.map(|block| UserMessageContent::from_content_block(block, path_style))
|
|
.collect::<Vec<_>>();
|
|
self.messages
|
|
.push(Message::User(UserMessage { id, content }));
|
|
cx.notify();
|
|
}
|
|
|
|
pub fn push_acp_agent_block(&mut self, block: acp::ContentBlock, cx: &mut Context<Self>) {
|
|
let text = match block {
|
|
acp::ContentBlock::Text(text_content) => text_content.text,
|
|
acp::ContentBlock::Image(_) => "[image]".to_string(),
|
|
acp::ContentBlock::Audio(_) => "[audio]".to_string(),
|
|
acp::ContentBlock::ResourceLink(resource_link) => resource_link.uri,
|
|
acp::ContentBlock::Resource(resource) => match resource.resource {
|
|
acp::EmbeddedResourceResource::TextResourceContents(resource) => resource.uri,
|
|
acp::EmbeddedResourceResource::BlobResourceContents(resource) => resource.uri,
|
|
_ => "[resource]".to_string(),
|
|
},
|
|
_ => "[unknown]".to_string(),
|
|
};
|
|
|
|
self.messages.push(Message::Agent(AgentMessage {
|
|
content: vec![AgentMessageContent::Text(text)],
|
|
..Default::default()
|
|
}));
|
|
cx.notify();
|
|
}
|
|
|
|
fn run_turn(
|
|
&mut self,
|
|
cx: &mut Context<Self>,
|
|
) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>> {
|
|
// Flush the old pending message synchronously before cancelling,
|
|
// to avoid a race where the detached cancel task might flush the NEW
|
|
// turn's pending message instead of the old one.
|
|
self.flush_pending_message(cx);
|
|
self.cancel(cx).detach();
|
|
|
|
let (events_tx, events_rx) = mpsc::unbounded::<Result<ThreadEvent>>();
|
|
let event_stream = ThreadEventStream(events_tx);
|
|
let message_ix = self.messages.len().saturating_sub(1);
|
|
self.clear_summary();
|
|
let (cancellation_tx, mut cancellation_rx) = watch::channel(false);
|
|
self.running_turn = Some(RunningTurn {
|
|
event_stream: event_stream.clone(),
|
|
tools: self.enabled_tools(cx),
|
|
cancellation_tx,
|
|
streaming_tool_inputs: HashMap::default(),
|
|
_task: cx.spawn(async move |this, cx| {
|
|
log::debug!("Starting agent turn execution");
|
|
|
|
let turn_result =
|
|
Self::run_turn_internal(&this, &event_stream, cancellation_rx.clone(), cx)
|
|
.await;
|
|
|
|
// Check if we were cancelled - if so, cancel() already took running_turn
|
|
// and we shouldn't touch it (it might be a NEW turn now)
|
|
let was_cancelled = *cancellation_rx.borrow();
|
|
if was_cancelled {
|
|
log::debug!("Turn was cancelled, skipping cleanup");
|
|
return;
|
|
}
|
|
|
|
_ = this.update(cx, |this, cx| this.flush_pending_message(cx));
|
|
|
|
match turn_result {
|
|
Ok(()) => {
|
|
log::debug!("Turn execution completed");
|
|
event_stream.send_stop(acp::StopReason::EndTurn);
|
|
}
|
|
Err(error) => {
|
|
log::error!("Turn execution failed: {:?}", error);
|
|
match error.downcast::<CompletionError>() {
|
|
Ok(CompletionError::Refusal) => {
|
|
event_stream.send_stop(acp::StopReason::Refusal);
|
|
_ = this.update(cx, |this, _| this.messages.truncate(message_ix));
|
|
}
|
|
Ok(CompletionError::MaxTokens) => {
|
|
event_stream.send_stop(acp::StopReason::MaxTokens);
|
|
}
|
|
Ok(CompletionError::Other(error)) | Err(error) => {
|
|
event_stream.send_error(error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
_ = this.update(cx, |this, _| this.running_turn.take());
|
|
}),
|
|
});
|
|
Ok(events_rx)
|
|
}
|
|
|
|
async fn run_turn_internal(
|
|
this: &WeakEntity<Self>,
|
|
event_stream: &ThreadEventStream,
|
|
mut cancellation_rx: watch::Receiver<bool>,
|
|
cx: &mut AsyncApp,
|
|
) -> Result<()> {
|
|
let mut attempt = 0;
|
|
let mut intent = CompletionIntent::UserPrompt;
|
|
loop {
|
|
// Re-read the model and refresh tools on each iteration so that
|
|
// mid-turn changes (e.g. the user switches model, toggles tools,
|
|
// or changes profile) take effect between tool-call rounds.
|
|
let (model, request) = this.update(cx, |this, cx| {
|
|
let model = this
|
|
.model
|
|
.clone()
|
|
.ok_or_else(|| anyhow!(NoModelConfiguredError))?;
|
|
this.refresh_turn_tools(cx);
|
|
let request = this.build_completion_request(intent, cx)?;
|
|
anyhow::Ok((model, request))
|
|
})??;
|
|
|
|
telemetry::event!(
|
|
"Agent Thread Completion",
|
|
thread_id = this.read_with(cx, |this, _| this.id.to_string())?,
|
|
parent_thread_id = this.read_with(cx, |this, _| this
|
|
.parent_thread_id()
|
|
.map(|id| id.to_string()))?,
|
|
prompt_id = this.read_with(cx, |this, _| this.prompt_id.to_string())?,
|
|
model = model.telemetry_id(),
|
|
model_provider = model.provider_id().to_string(),
|
|
attempt
|
|
);
|
|
|
|
log::debug!("Calling model.stream_completion, attempt {}", attempt);
|
|
|
|
let (mut events, mut error) = match model.stream_completion(request, cx).await {
|
|
Ok(events) => (events.fuse(), None),
|
|
Err(err) => (stream::empty().boxed().fuse(), Some(err)),
|
|
};
|
|
let mut tool_results: FuturesUnordered<Task<LanguageModelToolResult>> =
|
|
FuturesUnordered::new();
|
|
let mut early_tool_results: Vec<LanguageModelToolResult> = Vec::new();
|
|
let mut cancelled = false;
|
|
loop {
|
|
// Race between getting the first event, tool completion, and cancellation.
|
|
let first_event = futures::select! {
|
|
event = events.next().fuse() => event,
|
|
tool_result = futures::StreamExt::select_next_some(&mut tool_results) => {
|
|
let is_error = tool_result.is_error;
|
|
let is_still_streaming = this
|
|
.read_with(cx, |this, _cx| {
|
|
this.running_turn
|
|
.as_ref()
|
|
.and_then(|turn| turn.streaming_tool_inputs.get(&tool_result.tool_use_id))
|
|
.map_or(false, |inputs| !inputs.has_received_final())
|
|
})
|
|
.unwrap_or(false);
|
|
|
|
early_tool_results.push(tool_result);
|
|
|
|
// Only break if the tool errored and we are still
|
|
// streaming the input of the tool. If the tool errored
|
|
// but we are no longer streaming its input (i.e. there
|
|
// are parallel tool calls) we want to continue
|
|
// processing those tool inputs.
|
|
if is_error && is_still_streaming {
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
_ = cancellation_rx.changed().fuse() => {
|
|
if *cancellation_rx.borrow() {
|
|
cancelled = true;
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
};
|
|
let Some(first_event) = first_event else {
|
|
break;
|
|
};
|
|
|
|
// Collect all immediately available events to process as a batch
|
|
let mut batch = vec![first_event];
|
|
while let Some(event) = events.next().now_or_never().flatten() {
|
|
batch.push(event);
|
|
}
|
|
|
|
// Process the batch in a single update
|
|
let batch_result = this.update(cx, |this, cx| {
|
|
let mut batch_tool_results = Vec::new();
|
|
let mut batch_error = None;
|
|
|
|
for event in batch {
|
|
log::trace!("Received completion event: {:?}", event);
|
|
match event {
|
|
Ok(event) => {
|
|
match this.handle_completion_event(
|
|
event,
|
|
event_stream,
|
|
cancellation_rx.clone(),
|
|
cx,
|
|
) {
|
|
Ok(Some(task)) => batch_tool_results.push(task),
|
|
Ok(None) => {}
|
|
Err(err) => {
|
|
batch_error = Some(err);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
Err(err) => {
|
|
batch_error = Some(err.into());
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
cx.notify();
|
|
(batch_tool_results, batch_error)
|
|
})?;
|
|
|
|
tool_results.extend(batch_result.0);
|
|
if let Some(err) = batch_result.1 {
|
|
error = Some(err.downcast()?);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Drop the stream to release the rate limit permit before tool execution.
|
|
// The stream holds a semaphore guard that limits concurrent requests.
|
|
// Without this, the permit would be held during potentially long-running
|
|
// tool execution, which could cause deadlocks when tools spawn subagents
|
|
// that need their own permits.
|
|
drop(events);
|
|
|
|
// Drop streaming tool input senders that never received their final input.
|
|
// This prevents deadlock when the LLM stream ends (e.g. because of an error)
|
|
// before sending a tool use with `is_input_complete: true`.
|
|
this.update(cx, |this, _cx| {
|
|
if let Some(running_turn) = this.running_turn.as_mut() {
|
|
if running_turn.streaming_tool_inputs.is_empty() {
|
|
return;
|
|
}
|
|
log::warn!("Dropping partial tool inputs because the stream ended");
|
|
running_turn.streaming_tool_inputs.drain();
|
|
}
|
|
})?;
|
|
|
|
let end_turn = tool_results.is_empty() && early_tool_results.is_empty();
|
|
|
|
for tool_result in early_tool_results {
|
|
Self::process_tool_result(this, event_stream, cx, tool_result)?;
|
|
}
|
|
while let Some(tool_result) = tool_results.next().await {
|
|
Self::process_tool_result(this, event_stream, cx, tool_result)?;
|
|
}
|
|
|
|
this.update(cx, |this, cx| {
|
|
this.flush_pending_message(cx);
|
|
if this.title.is_none() && this.pending_title_generation.is_none() {
|
|
this.generate_title(cx);
|
|
}
|
|
})?;
|
|
|
|
if cancelled {
|
|
log::debug!("Turn cancelled by user, exiting");
|
|
return Ok(());
|
|
}
|
|
|
|
if let Some(error) = error {
|
|
attempt += 1;
|
|
let retry = this.update(cx, |this, cx| {
|
|
let user_store = this.user_store.read(cx);
|
|
this.handle_completion_error(error, attempt, user_store.plan())
|
|
})??;
|
|
let timer = cx.background_executor().timer(retry.duration);
|
|
event_stream.send_retry(retry);
|
|
futures::select! {
|
|
_ = timer.fuse() => {}
|
|
_ = cancellation_rx.changed().fuse() => {
|
|
if *cancellation_rx.borrow() {
|
|
log::debug!("Turn cancelled during retry delay, exiting");
|
|
return Ok(());
|
|
}
|
|
}
|
|
}
|
|
this.update(cx, |this, _cx| {
|
|
if let Some(Message::Agent(message)) = this.messages.last() {
|
|
if message.tool_results.is_empty() {
|
|
intent = CompletionIntent::UserPrompt;
|
|
this.messages.push(Message::Resume);
|
|
}
|
|
}
|
|
})?;
|
|
} else if end_turn {
|
|
return Ok(());
|
|
} else {
|
|
let has_queued = this.update(cx, |this, _| this.has_queued_message())?;
|
|
if has_queued {
|
|
log::debug!("Queued message found, ending turn at message boundary");
|
|
return Ok(());
|
|
}
|
|
intent = CompletionIntent::ToolResults;
|
|
attempt = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn process_tool_result(
|
|
this: &WeakEntity<Thread>,
|
|
event_stream: &ThreadEventStream,
|
|
cx: &mut AsyncApp,
|
|
tool_result: LanguageModelToolResult,
|
|
) -> Result<(), anyhow::Error> {
|
|
log::debug!("Tool finished {:?}", tool_result);
|
|
|
|
event_stream.update_tool_call_fields(
|
|
&tool_result.tool_use_id,
|
|
acp::ToolCallUpdateFields::new()
|
|
.status(if tool_result.is_error {
|
|
acp::ToolCallStatus::Failed
|
|
} else {
|
|
acp::ToolCallStatus::Completed
|
|
})
|
|
.raw_output(tool_result.output.clone()),
|
|
None,
|
|
);
|
|
this.update(cx, |this, _cx| {
|
|
this.pending_message()
|
|
.tool_results
|
|
.insert(tool_result.tool_use_id.clone(), tool_result)
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
fn handle_completion_error(
|
|
&mut self,
|
|
error: LanguageModelCompletionError,
|
|
attempt: u8,
|
|
plan: Option<Plan>,
|
|
) -> Result<acp_thread::RetryStatus> {
|
|
let Some(model) = self.model.as_ref() else {
|
|
return Err(anyhow!(error));
|
|
};
|
|
|
|
let auto_retry = if model.provider_id() == ZED_CLOUD_PROVIDER_ID {
|
|
plan.is_some()
|
|
} else {
|
|
true
|
|
};
|
|
|
|
if !auto_retry {
|
|
return Err(anyhow!(error));
|
|
}
|
|
|
|
let Some(strategy) = Self::retry_strategy_for(&error) else {
|
|
return Err(anyhow!(error));
|
|
};
|
|
|
|
let max_attempts = match &strategy {
|
|
RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts,
|
|
RetryStrategy::Fixed { max_attempts, .. } => *max_attempts,
|
|
};
|
|
|
|
if attempt > max_attempts {
|
|
return Err(anyhow!(error));
|
|
}
|
|
|
|
let delay = match &strategy {
|
|
RetryStrategy::ExponentialBackoff { initial_delay, .. } => {
|
|
let delay_secs = initial_delay.as_secs() * 2u64.pow((attempt - 1) as u32);
|
|
Duration::from_secs(delay_secs)
|
|
}
|
|
RetryStrategy::Fixed { delay, .. } => *delay,
|
|
};
|
|
log::debug!("Retry attempt {attempt} with delay {delay:?}");
|
|
|
|
Ok(acp_thread::RetryStatus {
|
|
last_error: error.to_string().into(),
|
|
attempt: attempt as usize,
|
|
max_attempts: max_attempts as usize,
|
|
started_at: Instant::now(),
|
|
duration: delay,
|
|
})
|
|
}
|
|
|
|
/// A helper method that's called on every streamed completion event.
|
|
/// Returns an optional tool result task, which the main agentic loop will
|
|
/// send back to the model when it resolves.
|
|
fn handle_completion_event(
|
|
&mut self,
|
|
event: LanguageModelCompletionEvent,
|
|
event_stream: &ThreadEventStream,
|
|
cancellation_rx: watch::Receiver<bool>,
|
|
cx: &mut Context<Self>,
|
|
) -> Result<Option<Task<LanguageModelToolResult>>> {
|
|
log::trace!("Handling streamed completion event: {:?}", event);
|
|
use LanguageModelCompletionEvent::*;
|
|
|
|
match event {
|
|
StartMessage { .. } => {
|
|
self.flush_pending_message(cx);
|
|
self.pending_message = Some(AgentMessage::default());
|
|
}
|
|
Text(new_text) => self.handle_text_event(new_text, event_stream),
|
|
Thinking { text, signature } => {
|
|
self.handle_thinking_event(text, signature, event_stream)
|
|
}
|
|
RedactedThinking { data } => self.handle_redacted_thinking_event(data),
|
|
ReasoningDetails(details) => {
|
|
let last_message = self.pending_message();
|
|
// Store the last non-empty reasoning_details (overwrites earlier ones)
|
|
// This ensures we keep the encrypted reasoning with signatures, not the early text reasoning
|
|
if let serde_json::Value::Array(ref arr) = details {
|
|
if !arr.is_empty() {
|
|
last_message.reasoning_details = Some(details);
|
|
}
|
|
} else {
|
|
last_message.reasoning_details = Some(details);
|
|
}
|
|
}
|
|
ToolUse(tool_use) => {
|
|
return Ok(self.handle_tool_use_event(tool_use, event_stream, cancellation_rx, cx));
|
|
}
|
|
ToolUseJsonParseError {
|
|
id,
|
|
tool_name,
|
|
raw_input,
|
|
json_parse_error,
|
|
} => {
|
|
return Ok(self.handle_tool_use_json_parse_error_event(
|
|
id,
|
|
tool_name,
|
|
raw_input,
|
|
json_parse_error,
|
|
event_stream,
|
|
cancellation_rx,
|
|
cx,
|
|
));
|
|
}
|
|
UsageUpdate(usage) => {
|
|
telemetry::event!(
|
|
"Agent Thread Completion Usage Updated",
|
|
thread_id = self.id.to_string(),
|
|
parent_thread_id = self.parent_thread_id().map(|id| id.to_string()),
|
|
prompt_id = self.prompt_id.to_string(),
|
|
model = self.model.as_ref().map(|m| m.telemetry_id()),
|
|
model_provider = self.model.as_ref().map(|m| m.provider_id().to_string()),
|
|
input_tokens = usage.input_tokens,
|
|
output_tokens = usage.output_tokens,
|
|
cache_creation_input_tokens = usage.cache_creation_input_tokens,
|
|
cache_read_input_tokens = usage.cache_read_input_tokens,
|
|
);
|
|
self.update_token_usage(usage, cx);
|
|
}
|
|
Stop(StopReason::Refusal) => return Err(CompletionError::Refusal.into()),
|
|
Stop(StopReason::MaxTokens) => return Err(CompletionError::MaxTokens.into()),
|
|
Stop(StopReason::ToolUse | StopReason::EndTurn) => {}
|
|
Started | Queued { .. } => {}
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
fn handle_text_event(&mut self, new_text: String, event_stream: &ThreadEventStream) {
|
|
event_stream.send_text(&new_text);
|
|
|
|
let last_message = self.pending_message();
|
|
if let Some(AgentMessageContent::Text(text)) = last_message.content.last_mut() {
|
|
text.push_str(&new_text);
|
|
} else {
|
|
last_message
|
|
.content
|
|
.push(AgentMessageContent::Text(new_text));
|
|
}
|
|
}
|
|
|
|
fn handle_thinking_event(
|
|
&mut self,
|
|
new_text: String,
|
|
new_signature: Option<String>,
|
|
event_stream: &ThreadEventStream,
|
|
) {
|
|
event_stream.send_thinking(&new_text);
|
|
|
|
let last_message = self.pending_message();
|
|
if let Some(AgentMessageContent::Thinking { text, signature }) =
|
|
last_message.content.last_mut()
|
|
{
|
|
text.push_str(&new_text);
|
|
*signature = new_signature.or(signature.take());
|
|
} else {
|
|
last_message.content.push(AgentMessageContent::Thinking {
|
|
text: new_text,
|
|
signature: new_signature,
|
|
});
|
|
}
|
|
}
|
|
|
|
fn handle_redacted_thinking_event(&mut self, data: String) {
|
|
let last_message = self.pending_message();
|
|
last_message
|
|
.content
|
|
.push(AgentMessageContent::RedactedThinking(data));
|
|
}
|
|
|
|
fn handle_tool_use_event(
|
|
&mut self,
|
|
tool_use: LanguageModelToolUse,
|
|
event_stream: &ThreadEventStream,
|
|
cancellation_rx: watch::Receiver<bool>,
|
|
cx: &mut Context<Self>,
|
|
) -> Option<Task<LanguageModelToolResult>> {
|
|
cx.notify();
|
|
|
|
let tool = self.tool(tool_use.name.as_ref());
|
|
let mut title = SharedString::from(&tool_use.name);
|
|
let mut kind = acp::ToolKind::Other;
|
|
if let Some(tool) = tool.as_ref() {
|
|
title = tool.initial_title(tool_use.input.clone(), cx);
|
|
kind = tool.kind();
|
|
}
|
|
|
|
self.send_or_update_tool_use(&tool_use, title, kind, event_stream);
|
|
|
|
let Some(tool) = tool else {
|
|
let content = format!("No tool named {} exists", tool_use.name);
|
|
return Some(Task::ready(LanguageModelToolResult {
|
|
content: vec![LanguageModelToolResultContent::Text(Arc::from(content))],
|
|
tool_use_id: tool_use.id,
|
|
tool_name: tool_use.name,
|
|
is_error: true,
|
|
output: None,
|
|
}));
|
|
};
|
|
|
|
if !tool_use.is_input_complete {
|
|
if tool.supports_input_streaming() {
|
|
let running_turn = self.running_turn.as_mut()?;
|
|
if let Some(sender) = running_turn.streaming_tool_inputs.get_mut(&tool_use.id) {
|
|
sender.send_partial(tool_use.input);
|
|
return None;
|
|
}
|
|
|
|
let (mut sender, tool_input) = ToolInputSender::channel();
|
|
sender.send_partial(tool_use.input);
|
|
running_turn
|
|
.streaming_tool_inputs
|
|
.insert(tool_use.id.clone(), sender);
|
|
|
|
let tool = tool.clone();
|
|
log::debug!("Running streaming tool {}", tool_use.name);
|
|
return Some(self.run_tool(
|
|
tool,
|
|
tool_input,
|
|
tool_use.id,
|
|
tool_use.name,
|
|
event_stream,
|
|
cancellation_rx,
|
|
cx,
|
|
));
|
|
} else {
|
|
return None;
|
|
}
|
|
}
|
|
|
|
if let Some(mut sender) = self
|
|
.running_turn
|
|
.as_mut()?
|
|
.streaming_tool_inputs
|
|
.remove(&tool_use.id)
|
|
{
|
|
sender.send_full(tool_use.input);
|
|
return None;
|
|
}
|
|
|
|
log::debug!("Running tool {}", tool_use.name);
|
|
let tool_input = ToolInput::ready(tool_use.input);
|
|
Some(self.run_tool(
|
|
tool,
|
|
tool_input,
|
|
tool_use.id,
|
|
tool_use.name,
|
|
event_stream,
|
|
cancellation_rx,
|
|
cx,
|
|
))
|
|
}
|
|
|
|
fn run_tool(
|
|
&self,
|
|
tool: Arc<dyn AnyAgentTool>,
|
|
tool_input: ToolInput<serde_json::Value>,
|
|
tool_use_id: LanguageModelToolUseId,
|
|
tool_name: Arc<str>,
|
|
event_stream: &ThreadEventStream,
|
|
cancellation_rx: watch::Receiver<bool>,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<LanguageModelToolResult> {
|
|
let fs = self.project.read(cx).fs().clone();
|
|
let tool_event_stream = ToolCallEventStream::new(
|
|
tool_use_id.clone(),
|
|
event_stream.clone(),
|
|
Some(fs),
|
|
cancellation_rx,
|
|
);
|
|
tool_event_stream.update_fields(
|
|
acp::ToolCallUpdateFields::new().status(acp::ToolCallStatus::InProgress),
|
|
);
|
|
let supports_images = self.model().is_some_and(|model| model.supports_images());
|
|
let tool_result = tool.run(tool_input, tool_event_stream, cx);
|
|
cx.foreground_executor().spawn(async move {
|
|
let (is_error, output) = match tool_result.await {
|
|
Ok(mut output) => {
|
|
let contains_image = output
|
|
.llm_output
|
|
.iter()
|
|
.any(|part| matches!(part, LanguageModelToolResultContent::Image(_)));
|
|
if contains_image && !supports_images {
|
|
// Replace each image part with an inline placeholder so
|
|
// any accompanying text is still presented to the model.
|
|
// If there's nothing else in the output, surface an error
|
|
// to match the pre-multi-part behavior for image-only
|
|
// tool results.
|
|
let placeholder = LanguageModelToolResultContent::Text(Arc::from(
|
|
"[Tool responded with an image, but this model doesn't support images]",
|
|
));
|
|
let has_non_image = output
|
|
.llm_output
|
|
.iter()
|
|
.any(|part| !matches!(part, LanguageModelToolResultContent::Image(_)));
|
|
if has_non_image {
|
|
output.llm_output = output
|
|
.llm_output
|
|
.into_iter()
|
|
.map(|part| match part {
|
|
LanguageModelToolResultContent::Image(_) => placeholder.clone(),
|
|
other => other,
|
|
})
|
|
.collect();
|
|
(false, output)
|
|
} else {
|
|
let output = anyhow::anyhow!(
|
|
"Attempted to read an image, but this model doesn't support it.",
|
|
)
|
|
.into();
|
|
(true, output)
|
|
}
|
|
} else {
|
|
(false, output)
|
|
}
|
|
}
|
|
Err(output) => (true, output),
|
|
};
|
|
|
|
LanguageModelToolResult {
|
|
tool_use_id,
|
|
tool_name,
|
|
is_error,
|
|
content: output.llm_output,
|
|
output: Some(output.raw_output),
|
|
}
|
|
})
|
|
}
|
|
|
|
fn handle_tool_use_json_parse_error_event(
|
|
&mut self,
|
|
tool_use_id: LanguageModelToolUseId,
|
|
tool_name: Arc<str>,
|
|
raw_input: Arc<str>,
|
|
json_parse_error: String,
|
|
event_stream: &ThreadEventStream,
|
|
cancellation_rx: watch::Receiver<bool>,
|
|
cx: &mut Context<Self>,
|
|
) -> Option<Task<LanguageModelToolResult>> {
|
|
let tool_use = LanguageModelToolUse {
|
|
id: tool_use_id,
|
|
name: tool_name,
|
|
raw_input: raw_input.to_string(),
|
|
input: serde_json::json!({}),
|
|
is_input_complete: true,
|
|
thought_signature: None,
|
|
};
|
|
self.send_or_update_tool_use(
|
|
&tool_use,
|
|
SharedString::from(&tool_use.name),
|
|
acp::ToolKind::Other,
|
|
event_stream,
|
|
);
|
|
|
|
let tool = self.tool(tool_use.name.as_ref());
|
|
|
|
let Some(tool) = tool else {
|
|
let content = format!("No tool named {} exists", tool_use.name);
|
|
return Some(Task::ready(LanguageModelToolResult {
|
|
content: vec![LanguageModelToolResultContent::Text(Arc::from(content))],
|
|
tool_use_id: tool_use.id,
|
|
tool_name: tool_use.name,
|
|
is_error: true,
|
|
output: None,
|
|
}));
|
|
};
|
|
|
|
let error_message = format!("Error parsing input JSON: {json_parse_error}");
|
|
|
|
if tool.supports_input_streaming()
|
|
&& let Some(mut sender) = self
|
|
.running_turn
|
|
.as_mut()?
|
|
.streaming_tool_inputs
|
|
.remove(&tool_use.id)
|
|
{
|
|
sender.send_invalid_json(error_message);
|
|
return None;
|
|
}
|
|
|
|
log::debug!("Running tool {}. Received invalid JSON", tool_use.name);
|
|
let tool_input = ToolInput::invalid_json(error_message);
|
|
Some(self.run_tool(
|
|
tool,
|
|
tool_input,
|
|
tool_use.id,
|
|
tool_use.name,
|
|
event_stream,
|
|
cancellation_rx,
|
|
cx,
|
|
))
|
|
}
|
|
|
|
fn send_or_update_tool_use(
|
|
&mut self,
|
|
tool_use: &LanguageModelToolUse,
|
|
title: SharedString,
|
|
kind: acp::ToolKind,
|
|
event_stream: &ThreadEventStream,
|
|
) {
|
|
// Ensure the last message ends in the current tool use
|
|
let last_message = self.pending_message();
|
|
|
|
let has_tool_use = last_message.content.iter_mut().rev().any(|content| {
|
|
if let AgentMessageContent::ToolUse(last_tool_use) = content {
|
|
if last_tool_use.id == tool_use.id {
|
|
*last_tool_use = tool_use.clone();
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
});
|
|
|
|
if !has_tool_use {
|
|
event_stream.send_tool_call(
|
|
&tool_use.id,
|
|
&tool_use.name,
|
|
title,
|
|
kind,
|
|
tool_use.input.clone(),
|
|
);
|
|
last_message
|
|
.content
|
|
.push(AgentMessageContent::ToolUse(tool_use.clone()));
|
|
} else {
|
|
event_stream.update_tool_call_fields(
|
|
&tool_use.id,
|
|
acp::ToolCallUpdateFields::new()
|
|
.title(title.as_str())
|
|
.kind(kind)
|
|
.raw_input(tool_use.input.clone()),
|
|
None,
|
|
);
|
|
}
|
|
}
|
|
|
|
pub fn title(&self) -> Option<SharedString> {
|
|
self.title.clone()
|
|
}
|
|
|
|
pub fn is_generating_summary(&self) -> bool {
|
|
self.pending_summary_generation.is_some()
|
|
}
|
|
|
|
pub fn is_generating_title(&self) -> bool {
|
|
self.pending_title_generation.is_some()
|
|
}
|
|
|
|
pub fn has_failed_title_generation(&self) -> bool {
|
|
self.title_generation_failed
|
|
}
|
|
|
|
pub fn summary(&mut self, cx: &mut Context<Self>) -> Shared<Task<Option<SharedString>>> {
|
|
if let Some(summary) = self.summary.as_ref() {
|
|
return Task::ready(Some(summary.clone())).shared();
|
|
}
|
|
if let Some(task) = self.pending_summary_generation.clone() {
|
|
return task;
|
|
}
|
|
let Some(model) = self.summarization_model.clone() else {
|
|
log::error!("No summarization model available");
|
|
return Task::ready(None).shared();
|
|
};
|
|
let mut request = LanguageModelRequest {
|
|
intent: Some(CompletionIntent::ThreadContextSummarization),
|
|
temperature: AgentSettings::temperature_for_model(&model, cx),
|
|
..Default::default()
|
|
};
|
|
|
|
for message in &self.messages {
|
|
request.messages.extend(message.to_request());
|
|
}
|
|
|
|
request.messages.push(LanguageModelRequestMessage {
|
|
role: Role::User,
|
|
content: vec![SUMMARIZE_THREAD_DETAILED_PROMPT.into()],
|
|
cache: false,
|
|
reasoning_details: None,
|
|
});
|
|
|
|
let task = cx
|
|
.spawn(async move |this, cx| {
|
|
let mut summary = String::new();
|
|
let mut messages = model.stream_completion(request, cx).await.log_err()?;
|
|
while let Some(event) = messages.next().await {
|
|
let event = event.log_err()?;
|
|
let text = match event {
|
|
LanguageModelCompletionEvent::Text(text) => text,
|
|
_ => continue,
|
|
};
|
|
|
|
let mut lines = text.lines();
|
|
summary.extend(lines.next());
|
|
}
|
|
|
|
log::debug!("Setting summary: {}", summary);
|
|
let summary = SharedString::from(summary);
|
|
|
|
this.update(cx, |this, cx| {
|
|
this.summary = Some(summary.clone());
|
|
this.pending_summary_generation = None;
|
|
cx.notify()
|
|
})
|
|
.ok()?;
|
|
|
|
Some(summary)
|
|
})
|
|
.shared();
|
|
self.pending_summary_generation = Some(task.clone());
|
|
task
|
|
}
|
|
|
|
pub fn generate_title(&mut self, cx: &mut Context<Self>) {
|
|
self.title_generation_failed = false;
|
|
let Some(model) = self.summarization_model.clone() else {
|
|
return;
|
|
};
|
|
|
|
log::debug!(
|
|
"Generating title with model: {:?}",
|
|
self.summarization_model.as_ref().map(|model| model.name())
|
|
);
|
|
let mut request = LanguageModelRequest {
|
|
intent: Some(CompletionIntent::ThreadSummarization),
|
|
temperature: AgentSettings::temperature_for_model(&model, cx),
|
|
..Default::default()
|
|
};
|
|
|
|
for message in &self.messages {
|
|
request.messages.extend(message.to_request());
|
|
}
|
|
|
|
request.messages.push(LanguageModelRequestMessage {
|
|
role: Role::User,
|
|
content: vec![SUMMARIZE_THREAD_PROMPT.into()],
|
|
cache: false,
|
|
reasoning_details: None,
|
|
});
|
|
self.pending_title_generation = Some(cx.spawn(async move |this, cx| {
|
|
let mut title = String::new();
|
|
|
|
let generate = async {
|
|
let mut messages = model.stream_completion(request, cx).await?;
|
|
while let Some(event) = messages.next().await {
|
|
let event = event?;
|
|
let text = match event {
|
|
LanguageModelCompletionEvent::Text(text) => text,
|
|
_ => continue,
|
|
};
|
|
|
|
let mut lines = text.lines();
|
|
title.extend(lines.next());
|
|
|
|
// Stop if the LLM generated multiple lines.
|
|
if lines.next().is_some() {
|
|
break;
|
|
}
|
|
}
|
|
anyhow::Ok(())
|
|
};
|
|
|
|
let succeeded = generate
|
|
.await
|
|
.context("failed to generate thread title")
|
|
.log_err()
|
|
.is_some();
|
|
_ = this.update(cx, |this, cx| {
|
|
this.pending_title_generation = None;
|
|
if succeeded {
|
|
this.set_title(title.into(), cx);
|
|
} else {
|
|
this.title_generation_failed = true;
|
|
cx.emit(TitleUpdated);
|
|
cx.notify();
|
|
}
|
|
});
|
|
}));
|
|
}
|
|
|
|
pub fn set_title(&mut self, title: SharedString, cx: &mut Context<Self>) {
|
|
self.pending_title_generation = None;
|
|
self.title_generation_failed = false;
|
|
if Some(&title) != self.title.as_ref() {
|
|
self.title = Some(title);
|
|
cx.emit(TitleUpdated);
|
|
cx.notify();
|
|
}
|
|
}
|
|
|
|
fn clear_summary(&mut self) {
|
|
self.summary = None;
|
|
self.pending_summary_generation = None;
|
|
}
|
|
|
|
fn last_user_message(&self) -> Option<&UserMessage> {
|
|
self.messages
|
|
.iter()
|
|
.rev()
|
|
.find_map(|message| match message {
|
|
Message::User(user_message) => Some(user_message),
|
|
Message::Agent(_) => None,
|
|
Message::Resume => None,
|
|
})
|
|
}
|
|
|
|
fn pending_message(&mut self) -> &mut AgentMessage {
|
|
self.pending_message.get_or_insert_default()
|
|
}
|
|
|
|
fn flush_pending_message(&mut self, cx: &mut Context<Self>) {
|
|
let Some(mut message) = self.pending_message.take() else {
|
|
return;
|
|
};
|
|
|
|
if message.content.is_empty() {
|
|
return;
|
|
}
|
|
|
|
for content in &message.content {
|
|
let AgentMessageContent::ToolUse(tool_use) = content else {
|
|
continue;
|
|
};
|
|
|
|
if !message.tool_results.contains_key(&tool_use.id) {
|
|
message.tool_results.insert(
|
|
tool_use.id.clone(),
|
|
LanguageModelToolResult {
|
|
tool_use_id: tool_use.id.clone(),
|
|
tool_name: tool_use.name.clone(),
|
|
is_error: true,
|
|
content: vec![LanguageModelToolResultContent::Text(
|
|
TOOL_CANCELED_MESSAGE.into(),
|
|
)],
|
|
output: None,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
self.messages.push(Message::Agent(message));
|
|
self.updated_at = Utc::now();
|
|
self.clear_summary();
|
|
cx.notify()
|
|
}
|
|
|
|
pub(crate) fn build_completion_request(
|
|
&self,
|
|
completion_intent: CompletionIntent,
|
|
cx: &App,
|
|
) -> Result<LanguageModelRequest> {
|
|
let completion_intent =
|
|
if self.is_subagent() && completion_intent == CompletionIntent::UserPrompt {
|
|
CompletionIntent::Subagent
|
|
} else {
|
|
completion_intent
|
|
};
|
|
|
|
let model = self
|
|
.model()
|
|
.ok_or_else(|| anyhow!(NoModelConfiguredError))?;
|
|
let tools = if let Some(turn) = self.running_turn.as_ref() {
|
|
turn.tools
|
|
.iter()
|
|
.filter_map(|(tool_name, tool)| {
|
|
log::trace!("Including tool: {}", tool_name);
|
|
Some(LanguageModelRequestTool {
|
|
name: tool_name.to_string(),
|
|
description: tool.description().to_string(),
|
|
input_schema: tool.input_schema(model.tool_input_format()).log_err()?,
|
|
use_input_streaming: tool.supports_input_streaming(),
|
|
})
|
|
})
|
|
.collect::<Vec<_>>()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
log::debug!("Building completion request");
|
|
log::debug!("Completion intent: {:?}", completion_intent);
|
|
|
|
let available_tools: Vec<_> = self
|
|
.running_turn
|
|
.as_ref()
|
|
.map(|turn| turn.tools.keys().cloned().collect())
|
|
.unwrap_or_default();
|
|
|
|
log::debug!("Request includes {} tools", available_tools.len());
|
|
let messages = self.build_request_messages(available_tools, cx);
|
|
log::debug!("Request will include {} messages", messages.len());
|
|
|
|
let request = LanguageModelRequest {
|
|
thread_id: Some(self.id.to_string()),
|
|
prompt_id: Some(self.prompt_id.to_string()),
|
|
intent: Some(completion_intent),
|
|
messages,
|
|
tools,
|
|
tool_choice: None,
|
|
stop: Vec::new(),
|
|
temperature: AgentSettings::temperature_for_model(model, cx),
|
|
thinking_allowed: self.thinking_enabled,
|
|
thinking_effort: self.thinking_effort.clone(),
|
|
speed: self.speed(),
|
|
};
|
|
|
|
log::debug!("Completion request built successfully");
|
|
Ok(request)
|
|
}
|
|
|
|
fn enabled_tools(&self, cx: &App) -> BTreeMap<SharedString, Arc<dyn AnyAgentTool>> {
|
|
let Some(model) = self.model.as_ref() else {
|
|
return BTreeMap::new();
|
|
};
|
|
let Some(profile) = AgentSettings::get_global(cx).profiles.get(&self.profile_id) else {
|
|
return BTreeMap::new();
|
|
};
|
|
fn truncate(tool_name: &SharedString) -> SharedString {
|
|
if tool_name.len() > MAX_TOOL_NAME_LENGTH {
|
|
let mut truncated = tool_name.to_string();
|
|
truncated.truncate(MAX_TOOL_NAME_LENGTH);
|
|
truncated.into()
|
|
} else {
|
|
tool_name.clone()
|
|
}
|
|
}
|
|
|
|
let mut tools = self
|
|
.tools
|
|
.iter()
|
|
.filter_map(|(tool_name, tool)| {
|
|
if tool.supports_provider(&model.provider_id())
|
|
&& profile.is_tool_enabled(tool_name)
|
|
{
|
|
Some((truncate(tool_name), tool.clone()))
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.filter(|(tool_name, _)| {
|
|
cx.has_flag::<LspToolFeatureFlag>()
|
|
|| !matches!(
|
|
tool_name.as_ref(),
|
|
FindReferencesTool::NAME
|
|
| GetCodeActionsTool::NAME
|
|
| ApplyCodeActionTool::NAME
|
|
| GoToDefinitionTool::NAME
|
|
| RenameTool::NAME
|
|
)
|
|
})
|
|
.collect::<BTreeMap<_, _>>();
|
|
|
|
let mut context_server_tools = Vec::new();
|
|
let mut seen_tools = tools.keys().cloned().collect::<HashSet<_>>();
|
|
let mut duplicate_tool_names = HashSet::default();
|
|
for (server_id, server_tools) in self.context_server_registry.read(cx).servers() {
|
|
for (tool_name, tool) in server_tools {
|
|
if profile.is_context_server_tool_enabled(&server_id.0, &tool_name) {
|
|
let tool_name = truncate(tool_name);
|
|
if !seen_tools.insert(tool_name.clone()) {
|
|
duplicate_tool_names.insert(tool_name.clone());
|
|
}
|
|
context_server_tools.push((server_id.clone(), tool_name, tool.clone()));
|
|
}
|
|
}
|
|
}
|
|
|
|
// When there are duplicate tool names, disambiguate by prefixing them
|
|
// with the server ID (converted to snake_case for API compatibility).
|
|
// In the rare case there isn't enough space for the disambiguated tool
|
|
// name, keep only the last tool with this name.
|
|
for (server_id, tool_name, tool) in context_server_tools {
|
|
if duplicate_tool_names.contains(&tool_name) {
|
|
let available = MAX_TOOL_NAME_LENGTH.saturating_sub(tool_name.len());
|
|
if available >= 2 {
|
|
let mut disambiguated = server_id.0.to_snake_case();
|
|
disambiguated.truncate(available - 1);
|
|
disambiguated.push('_');
|
|
disambiguated.push_str(&tool_name);
|
|
tools.insert(disambiguated.into(), tool.clone());
|
|
} else {
|
|
tools.insert(tool_name, tool.clone());
|
|
}
|
|
} else {
|
|
tools.insert(tool_name, tool.clone());
|
|
}
|
|
}
|
|
|
|
tools
|
|
}
|
|
|
|
fn refresh_turn_tools(&mut self, cx: &App) {
|
|
let tools = self.enabled_tools(cx);
|
|
if let Some(turn) = self.running_turn.as_mut() {
|
|
turn.tools = tools;
|
|
}
|
|
}
|
|
|
|
fn tool(&self, name: &str) -> Option<Arc<dyn AnyAgentTool>> {
|
|
self.running_turn.as_ref()?.tools.get(name).cloned()
|
|
}
|
|
|
|
pub fn has_tool(&self, name: &str) -> bool {
|
|
self.running_turn
|
|
.as_ref()
|
|
.is_some_and(|turn| turn.tools.contains_key(name))
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub fn has_registered_tool(&self, name: &str) -> bool {
|
|
self.tools.contains_key(name)
|
|
}
|
|
|
|
pub(crate) fn register_running_subagent(&mut self, subagent: WeakEntity<Thread>) {
|
|
self.running_subagents.push(subagent);
|
|
}
|
|
|
|
pub(crate) fn unregister_running_subagent(
|
|
&mut self,
|
|
subagent_session_id: &acp::SessionId,
|
|
cx: &App,
|
|
) {
|
|
self.running_subagents.retain(|s| {
|
|
s.upgrade()
|
|
.map_or(false, |s| s.read(cx).id() != subagent_session_id)
|
|
});
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub fn running_subagent_ids(&self, cx: &App) -> Vec<acp::SessionId> {
|
|
self.running_subagents
|
|
.iter()
|
|
.filter_map(|s| s.upgrade().map(|s| s.read(cx).id().clone()))
|
|
.collect()
|
|
}
|
|
|
|
pub fn is_subagent(&self) -> bool {
|
|
self.subagent_context.is_some()
|
|
}
|
|
|
|
pub fn parent_thread_id(&self) -> Option<acp::SessionId> {
|
|
self.subagent_context
|
|
.as_ref()
|
|
.map(|c| c.parent_thread_id.clone())
|
|
}
|
|
|
|
pub fn depth(&self) -> u8 {
|
|
self.subagent_context.as_ref().map(|c| c.depth).unwrap_or(0)
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub fn set_subagent_context(&mut self, context: SubagentContext) {
|
|
self.subagent_context = Some(context);
|
|
}
|
|
|
|
pub fn is_turn_complete(&self) -> bool {
|
|
self.running_turn.is_none()
|
|
}
|
|
|
|
fn build_request_messages(
|
|
&self,
|
|
available_tools: Vec<SharedString>,
|
|
cx: &App,
|
|
) -> Vec<LanguageModelRequestMessage> {
|
|
log::trace!(
|
|
"Building request messages from {} thread messages",
|
|
self.messages.len()
|
|
);
|
|
|
|
let system_prompt = SystemPromptTemplate {
|
|
project: self.project_context.read(cx),
|
|
available_tools,
|
|
model_name: self.model.as_ref().map(|m| m.name().0.to_string()),
|
|
}
|
|
.render(&self.templates)
|
|
.context("failed to build system prompt")
|
|
.expect("Invalid template");
|
|
let mut messages = vec![LanguageModelRequestMessage {
|
|
role: Role::System,
|
|
content: vec![system_prompt.into()],
|
|
cache: false,
|
|
reasoning_details: None,
|
|
}];
|
|
for message in &self.messages {
|
|
messages.extend(message.to_request());
|
|
}
|
|
|
|
if let Some(last_message) = messages.last_mut() {
|
|
last_message.cache = true;
|
|
}
|
|
|
|
if let Some(message) = self.pending_message.as_ref() {
|
|
messages.extend(message.to_request());
|
|
}
|
|
|
|
messages
|
|
}
|
|
|
|
pub fn to_markdown(&self) -> String {
|
|
let mut markdown = String::new();
|
|
for (ix, message) in self.messages.iter().enumerate() {
|
|
if ix > 0 {
|
|
markdown.push('\n');
|
|
}
|
|
match message {
|
|
Message::User(_) => markdown.push_str("## User\n\n"),
|
|
Message::Agent(_) => markdown.push_str("## Assistant\n\n"),
|
|
Message::Resume => {}
|
|
}
|
|
markdown.push_str(&message.to_markdown());
|
|
}
|
|
|
|
if let Some(message) = self.pending_message.as_ref() {
|
|
markdown.push_str("\n## Assistant\n\n");
|
|
markdown.push_str(&message.to_markdown());
|
|
}
|
|
|
|
markdown
|
|
}
|
|
|
|
fn advance_prompt_id(&mut self) {
|
|
self.prompt_id = PromptId::new();
|
|
}
|
|
|
|
fn retry_strategy_for(error: &LanguageModelCompletionError) -> Option<RetryStrategy> {
|
|
use LanguageModelCompletionError::*;
|
|
use http_client::StatusCode;
|
|
|
|
// General strategy here:
|
|
// - If retrying won't help (e.g. invalid API key or payload too large), return None so we don't retry at all.
|
|
// - If it's a time-based issue (e.g. server overloaded, rate limit exceeded), retry up to 4 times with exponential backoff.
|
|
// - If it's an issue that *might* be fixed by retrying (e.g. internal server error), retry up to 3 times.
|
|
match error {
|
|
HttpResponseError {
|
|
status_code: StatusCode::TOO_MANY_REQUESTS,
|
|
..
|
|
} => Some(RetryStrategy::ExponentialBackoff {
|
|
initial_delay: BASE_RETRY_DELAY,
|
|
max_attempts: MAX_RETRY_ATTEMPTS,
|
|
}),
|
|
ServerOverloaded { retry_after, .. } | RateLimitExceeded { retry_after, .. } => {
|
|
Some(RetryStrategy::Fixed {
|
|
delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
|
|
max_attempts: MAX_RETRY_ATTEMPTS,
|
|
})
|
|
}
|
|
UpstreamProviderError {
|
|
status,
|
|
retry_after,
|
|
..
|
|
} => match *status {
|
|
StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE => {
|
|
Some(RetryStrategy::Fixed {
|
|
delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
|
|
max_attempts: MAX_RETRY_ATTEMPTS,
|
|
})
|
|
}
|
|
StatusCode::INTERNAL_SERVER_ERROR => Some(RetryStrategy::Fixed {
|
|
delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
|
|
// Internal Server Error could be anything, retry up to 3 times.
|
|
max_attempts: 3,
|
|
}),
|
|
status => {
|
|
// There is no StatusCode variant for the unofficial HTTP 529 ("The service is overloaded"),
|
|
// but we frequently get them in practice. See https://http.dev/529
|
|
if status.as_u16() == 529 {
|
|
Some(RetryStrategy::Fixed {
|
|
delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
|
|
max_attempts: MAX_RETRY_ATTEMPTS,
|
|
})
|
|
} else {
|
|
Some(RetryStrategy::Fixed {
|
|
delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
|
|
max_attempts: 2,
|
|
})
|
|
}
|
|
}
|
|
},
|
|
ApiInternalServerError { .. } => Some(RetryStrategy::Fixed {
|
|
delay: BASE_RETRY_DELAY,
|
|
max_attempts: 3,
|
|
}),
|
|
ApiReadResponseError { .. }
|
|
| HttpSend { .. }
|
|
| DeserializeResponse { .. }
|
|
| BadRequestFormat { .. } => Some(RetryStrategy::Fixed {
|
|
delay: BASE_RETRY_DELAY,
|
|
max_attempts: 3,
|
|
}),
|
|
// Retrying these errors definitely shouldn't help.
|
|
HttpResponseError {
|
|
status_code:
|
|
StatusCode::PAYLOAD_TOO_LARGE | StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED,
|
|
..
|
|
}
|
|
| AuthenticationError { .. }
|
|
| PermissionError { .. }
|
|
| NoApiKey { .. }
|
|
| ApiEndpointNotFound { .. }
|
|
| PromptTooLarge { .. } => None,
|
|
// These errors might be transient, so retry them
|
|
SerializeRequest { .. } | BuildRequestBody { .. } | StreamEndedUnexpectedly { .. } => {
|
|
Some(RetryStrategy::Fixed {
|
|
delay: BASE_RETRY_DELAY,
|
|
max_attempts: 1,
|
|
})
|
|
}
|
|
// Retry all other 4xx and 5xx errors once.
|
|
HttpResponseError { status_code, .. }
|
|
if status_code.is_client_error() || status_code.is_server_error() =>
|
|
{
|
|
Some(RetryStrategy::Fixed {
|
|
delay: BASE_RETRY_DELAY,
|
|
max_attempts: 3,
|
|
})
|
|
}
|
|
Other(err) if err.is::<language_model::PaymentRequiredError>() => {
|
|
// Retrying won't help for Payment Required errors.
|
|
None
|
|
}
|
|
// Conservatively assume that any other errors are non-retryable
|
|
HttpResponseError { .. } | Other(..) => Some(RetryStrategy::Fixed {
|
|
delay: BASE_RETRY_DELAY,
|
|
max_attempts: 2,
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
struct RunningTurn {
|
|
/// Holds the task that handles agent interaction until the end of the turn.
|
|
/// Survives across multiple requests as the model performs tool calls and
|
|
/// we run tools, report their results.
|
|
_task: Task<()>,
|
|
/// The current event stream for the running turn. Used to report a final
|
|
/// cancellation event if we cancel the turn.
|
|
event_stream: ThreadEventStream,
|
|
/// The tools that are enabled for the current iteration of the turn.
|
|
/// Refreshed at the start of each iteration via `refresh_turn_tools`.
|
|
tools: BTreeMap<SharedString, Arc<dyn AnyAgentTool>>,
|
|
/// Sender to signal tool cancellation. When cancel is called, this is
|
|
/// set to true so all tools can detect user-initiated cancellation.
|
|
cancellation_tx: watch::Sender<bool>,
|
|
/// Senders for tools that support input streaming and have already been
|
|
/// started but are still receiving input from the LLM.
|
|
streaming_tool_inputs: HashMap<LanguageModelToolUseId, ToolInputSender>,
|
|
}
|
|
|
|
impl RunningTurn {
|
|
fn cancel(mut self) -> Task<()> {
|
|
log::debug!("Cancelling in progress turn");
|
|
self.cancellation_tx.send(true).ok();
|
|
self.event_stream.send_canceled();
|
|
self._task
|
|
}
|
|
}
|
|
|
|
pub struct TokenUsageUpdated(pub Option<acp_thread::TokenUsage>);
|
|
|
|
impl EventEmitter<TokenUsageUpdated> for Thread {}
|
|
|
|
pub struct TitleUpdated;
|
|
|
|
impl EventEmitter<TitleUpdated> for Thread {}
|
|
|
|
/// A channel-based wrapper that delivers tool input to a running tool.
|
|
///
|
|
/// For non-streaming tools, created via `ToolInput::ready()` so `.recv()` resolves immediately.
|
|
/// For streaming tools, partial JSON snapshots arrive via `.recv_partial()` as the LLM streams
|
|
/// them, followed by the final complete input available through `.recv()`.
|
|
pub struct ToolInput<T> {
|
|
rx: mpsc::UnboundedReceiver<ToolInputPayload<serde_json::Value>>,
|
|
_phantom: PhantomData<T>,
|
|
}
|
|
|
|
impl<T: DeserializeOwned> ToolInput<T> {
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub fn resolved(input: impl Serialize) -> Self {
|
|
let value = serde_json::to_value(input).expect("failed to serialize tool input");
|
|
Self::ready(value)
|
|
}
|
|
|
|
pub fn ready(value: serde_json::Value) -> Self {
|
|
let (tx, rx) = mpsc::unbounded();
|
|
tx.unbounded_send(ToolInputPayload::Full(value)).ok();
|
|
Self {
|
|
rx,
|
|
_phantom: PhantomData,
|
|
}
|
|
}
|
|
|
|
pub fn invalid_json(error_message: String) -> Self {
|
|
let (tx, rx) = mpsc::unbounded();
|
|
tx.unbounded_send(ToolInputPayload::InvalidJson { error_message })
|
|
.ok();
|
|
Self {
|
|
rx,
|
|
_phantom: PhantomData,
|
|
}
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub fn test() -> (ToolInputSender, Self) {
|
|
let (sender, input) = ToolInputSender::channel();
|
|
(sender, input.cast())
|
|
}
|
|
|
|
/// Wait for the final deserialized input, ignoring all partial updates.
|
|
/// Non-streaming tools can use this to wait until the whole input is available.
|
|
pub async fn recv(mut self) -> Result<T> {
|
|
while let Ok(value) = self.next().await {
|
|
match value {
|
|
ToolInputPayload::Full(value) => return Ok(value),
|
|
ToolInputPayload::Partial(_) => {}
|
|
ToolInputPayload::InvalidJson { error_message } => {
|
|
return Err(anyhow!(error_message));
|
|
}
|
|
}
|
|
}
|
|
Err(anyhow!("tool input was not fully received"))
|
|
}
|
|
|
|
pub async fn next(&mut self) -> Result<ToolInputPayload<T>> {
|
|
let value = self
|
|
.rx
|
|
.next()
|
|
.await
|
|
.ok_or_else(|| anyhow!("tool input was not fully received"))?;
|
|
|
|
Ok(match value {
|
|
ToolInputPayload::Partial(payload) => ToolInputPayload::Partial(payload),
|
|
ToolInputPayload::Full(payload) => {
|
|
ToolInputPayload::Full(serde_json::from_value(payload)?)
|
|
}
|
|
ToolInputPayload::InvalidJson { error_message } => {
|
|
ToolInputPayload::InvalidJson { error_message }
|
|
}
|
|
})
|
|
}
|
|
|
|
fn cast<U: DeserializeOwned>(self) -> ToolInput<U> {
|
|
ToolInput {
|
|
rx: self.rx,
|
|
_phantom: PhantomData,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub enum ToolInputPayload<T> {
|
|
Partial(serde_json::Value),
|
|
Full(T),
|
|
InvalidJson { error_message: String },
|
|
}
|
|
|
|
pub struct ToolInputSender {
|
|
has_received_final: bool,
|
|
tx: mpsc::UnboundedSender<ToolInputPayload<serde_json::Value>>,
|
|
}
|
|
|
|
impl ToolInputSender {
|
|
pub(crate) fn channel() -> (Self, ToolInput<serde_json::Value>) {
|
|
let (tx, rx) = mpsc::unbounded();
|
|
let sender = Self {
|
|
tx,
|
|
has_received_final: false,
|
|
};
|
|
let input = ToolInput {
|
|
rx,
|
|
_phantom: PhantomData,
|
|
};
|
|
(sender, input)
|
|
}
|
|
|
|
pub(crate) fn has_received_final(&self) -> bool {
|
|
self.has_received_final
|
|
}
|
|
|
|
pub fn send_partial(&mut self, payload: serde_json::Value) {
|
|
self.tx
|
|
.unbounded_send(ToolInputPayload::Partial(payload))
|
|
.ok();
|
|
}
|
|
|
|
pub fn send_full(&mut self, payload: serde_json::Value) {
|
|
self.has_received_final = true;
|
|
self.tx.unbounded_send(ToolInputPayload::Full(payload)).ok();
|
|
}
|
|
|
|
pub fn send_invalid_json(&mut self, error_message: String) {
|
|
self.has_received_final = true;
|
|
self.tx
|
|
.unbounded_send(ToolInputPayload::InvalidJson { error_message })
|
|
.ok();
|
|
}
|
|
}
|
|
|
|
pub trait AgentTool
|
|
where
|
|
Self: 'static + Sized,
|
|
{
|
|
type Input: for<'de> Deserialize<'de> + Serialize + JsonSchema;
|
|
type Output: for<'de> Deserialize<'de> + Serialize + Into<LanguageModelToolResultContent>;
|
|
|
|
const NAME: &'static str;
|
|
|
|
fn description() -> SharedString {
|
|
let schema = schemars::schema_for!(Self::Input);
|
|
SharedString::new(
|
|
schema
|
|
.get("description")
|
|
.and_then(|description| description.as_str())
|
|
.unwrap_or_default(),
|
|
)
|
|
}
|
|
|
|
fn kind() -> acp::ToolKind;
|
|
|
|
/// The initial tool title to display. Can be updated during the tool run.
|
|
fn initial_title(
|
|
&self,
|
|
input: Result<Self::Input, serde_json::Value>,
|
|
cx: &mut App,
|
|
) -> SharedString;
|
|
|
|
/// Returns the JSON schema that describes the tool's input.
|
|
fn input_schema(format: LanguageModelToolSchemaFormat) -> Schema {
|
|
language_model::tool_schema::root_schema_for::<Self::Input>(format)
|
|
}
|
|
|
|
/// Returns whether the tool supports streaming of tool use parameters.
|
|
fn supports_input_streaming() -> bool {
|
|
false
|
|
}
|
|
|
|
/// Some tools rely on a provider for the underlying billing or other reasons.
|
|
/// Allow the tool to check if they are compatible, or should be filtered out.
|
|
fn supports_provider(_provider: &LanguageModelProviderId) -> bool {
|
|
true
|
|
}
|
|
|
|
/// Runs the tool with the provided input.
|
|
///
|
|
/// Returns `Result<Self::Output, Self::Output>` rather than `Result<Self::Output, anyhow::Error>`
|
|
/// because tool errors are sent back to the model as tool results. This means error output must
|
|
/// be structured and readable by the agent — not an arbitrary `anyhow::Error`. Returning the
|
|
/// same `Output` type for both success and failure lets tools provide structured data while
|
|
/// still signaling whether the invocation succeeded or failed.
|
|
fn run(
|
|
self: Arc<Self>,
|
|
input: ToolInput<Self::Input>,
|
|
event_stream: ToolCallEventStream,
|
|
cx: &mut App,
|
|
) -> Task<Result<Self::Output, Self::Output>>;
|
|
|
|
/// Emits events for a previous execution of the tool.
|
|
fn replay(
|
|
&self,
|
|
_input: Self::Input,
|
|
_output: Self::Output,
|
|
_event_stream: ToolCallEventStream,
|
|
_cx: &mut App,
|
|
) -> Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
fn erase(self) -> Arc<dyn AnyAgentTool> {
|
|
Arc::new(Erased(Arc::new(self)))
|
|
}
|
|
}
|
|
|
|
pub struct Erased<T>(T);
|
|
|
|
pub struct AgentToolOutput {
|
|
pub llm_output: Vec<LanguageModelToolResultContent>,
|
|
pub raw_output: serde_json::Value,
|
|
}
|
|
|
|
impl From<anyhow::Error> for AgentToolOutput {
|
|
fn from(error: anyhow::Error) -> Self {
|
|
let llm_output = vec![error.into()];
|
|
let raw_output = serde_json::to_value(&llm_output).unwrap_or_else(|e| {
|
|
log::error!("Failed to serialize tool output: {e}");
|
|
serde_json::Value::Null
|
|
});
|
|
Self {
|
|
raw_output,
|
|
llm_output,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub trait AnyAgentTool {
|
|
fn name(&self) -> SharedString;
|
|
fn description(&self) -> SharedString;
|
|
fn kind(&self) -> acp::ToolKind;
|
|
fn initial_title(&self, input: serde_json::Value, _cx: &mut App) -> SharedString;
|
|
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value>;
|
|
fn supports_input_streaming(&self) -> bool {
|
|
false
|
|
}
|
|
fn supports_provider(&self, _provider: &LanguageModelProviderId) -> bool {
|
|
true
|
|
}
|
|
/// See [`AgentTool::run`] for why this returns `Result<AgentToolOutput, AgentToolOutput>`.
|
|
fn run(
|
|
self: Arc<Self>,
|
|
input: ToolInput<serde_json::Value>,
|
|
event_stream: ToolCallEventStream,
|
|
cx: &mut App,
|
|
) -> Task<Result<AgentToolOutput, AgentToolOutput>>;
|
|
fn replay(
|
|
&self,
|
|
input: serde_json::Value,
|
|
output: serde_json::Value,
|
|
event_stream: ToolCallEventStream,
|
|
cx: &mut App,
|
|
) -> Result<()>;
|
|
}
|
|
|
|
impl<T> AnyAgentTool for Erased<Arc<T>>
|
|
where
|
|
T: AgentTool,
|
|
{
|
|
fn name(&self) -> SharedString {
|
|
T::NAME.into()
|
|
}
|
|
|
|
fn description(&self) -> SharedString {
|
|
T::description()
|
|
}
|
|
|
|
fn kind(&self) -> acp::ToolKind {
|
|
T::kind()
|
|
}
|
|
|
|
fn supports_input_streaming(&self) -> bool {
|
|
T::supports_input_streaming()
|
|
}
|
|
|
|
fn initial_title(&self, input: serde_json::Value, _cx: &mut App) -> SharedString {
|
|
let parsed_input = serde_json::from_value(input.clone()).map_err(|_| input);
|
|
self.0.initial_title(parsed_input, _cx)
|
|
}
|
|
|
|
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
|
|
let mut json = serde_json::to_value(T::input_schema(format))?;
|
|
language_model::tool_schema::adapt_schema_to_format(&mut json, format)?;
|
|
Ok(json)
|
|
}
|
|
|
|
fn supports_provider(&self, provider: &LanguageModelProviderId) -> bool {
|
|
T::supports_provider(provider)
|
|
}
|
|
|
|
fn run(
|
|
self: Arc<Self>,
|
|
input: ToolInput<serde_json::Value>,
|
|
event_stream: ToolCallEventStream,
|
|
cx: &mut App,
|
|
) -> Task<Result<AgentToolOutput, AgentToolOutput>> {
|
|
let tool_input: ToolInput<T::Input> = input.cast();
|
|
let task = self.0.clone().run(tool_input, event_stream, cx);
|
|
cx.spawn(async move |_cx| match task.await {
|
|
Ok(output) => {
|
|
let raw_output = serde_json::to_value(&output).unwrap_or_else(|e| {
|
|
log::error!("Failed to serialize tool output: {e}");
|
|
serde_json::Value::Null
|
|
});
|
|
Ok(AgentToolOutput {
|
|
raw_output,
|
|
llm_output: vec![output.into()],
|
|
})
|
|
}
|
|
Err(error_output) => {
|
|
let raw_output = serde_json::to_value(&error_output).unwrap_or_else(|e| {
|
|
log::error!("Failed to serialize tool error output: {e}");
|
|
serde_json::Value::Null
|
|
});
|
|
Err(AgentToolOutput {
|
|
llm_output: vec![error_output.into()],
|
|
raw_output,
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
fn replay(
|
|
&self,
|
|
input: serde_json::Value,
|
|
output: serde_json::Value,
|
|
event_stream: ToolCallEventStream,
|
|
cx: &mut App,
|
|
) -> Result<()> {
|
|
let input = serde_json::from_value(input)?;
|
|
let output = serde_json::from_value(output)?;
|
|
self.0.replay(input, output, event_stream, cx)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct ThreadEventStream(mpsc::UnboundedSender<Result<ThreadEvent>>);
|
|
|
|
impl ThreadEventStream {
|
|
fn send_user_message(&self, message: &UserMessage) {
|
|
self.0
|
|
.unbounded_send(Ok(ThreadEvent::UserMessage(message.clone())))
|
|
.ok();
|
|
}
|
|
|
|
fn send_text(&self, text: &str) {
|
|
self.0
|
|
.unbounded_send(Ok(ThreadEvent::AgentText(text.to_string())))
|
|
.ok();
|
|
}
|
|
|
|
fn send_thinking(&self, text: &str) {
|
|
self.0
|
|
.unbounded_send(Ok(ThreadEvent::AgentThinking(text.to_string())))
|
|
.ok();
|
|
}
|
|
|
|
fn send_tool_call(
|
|
&self,
|
|
id: &LanguageModelToolUseId,
|
|
tool_name: &str,
|
|
title: SharedString,
|
|
kind: acp::ToolKind,
|
|
input: serde_json::Value,
|
|
) {
|
|
self.0
|
|
.unbounded_send(Ok(ThreadEvent::ToolCall(Self::initial_tool_call(
|
|
id,
|
|
tool_name,
|
|
title.to_string(),
|
|
kind,
|
|
input,
|
|
))))
|
|
.ok();
|
|
}
|
|
|
|
fn initial_tool_call(
|
|
id: &LanguageModelToolUseId,
|
|
tool_name: &str,
|
|
title: String,
|
|
kind: acp::ToolKind,
|
|
input: serde_json::Value,
|
|
) -> acp::ToolCall {
|
|
acp::ToolCall::new(id.to_string(), title)
|
|
.kind(kind)
|
|
.raw_input(input)
|
|
.meta(acp_thread::meta_with_tool_name(tool_name))
|
|
}
|
|
|
|
fn update_tool_call_fields(
|
|
&self,
|
|
tool_use_id: &LanguageModelToolUseId,
|
|
fields: acp::ToolCallUpdateFields,
|
|
meta: Option<acp::Meta>,
|
|
) {
|
|
self.0
|
|
.unbounded_send(Ok(ThreadEvent::ToolCallUpdate(
|
|
acp::ToolCallUpdate::new(tool_use_id.to_string(), fields)
|
|
.meta(meta)
|
|
.into(),
|
|
)))
|
|
.ok();
|
|
}
|
|
|
|
fn send_plan(&self, plan: acp::Plan) {
|
|
self.0.unbounded_send(Ok(ThreadEvent::Plan(plan))).ok();
|
|
}
|
|
|
|
fn send_retry(&self, status: acp_thread::RetryStatus) {
|
|
self.0.unbounded_send(Ok(ThreadEvent::Retry(status))).ok();
|
|
}
|
|
|
|
fn send_stop(&self, reason: acp::StopReason) {
|
|
self.0.unbounded_send(Ok(ThreadEvent::Stop(reason))).ok();
|
|
}
|
|
|
|
fn send_canceled(&self) {
|
|
self.0
|
|
.unbounded_send(Ok(ThreadEvent::Stop(acp::StopReason::Cancelled)))
|
|
.ok();
|
|
}
|
|
|
|
fn send_error(&self, error: impl Into<anyhow::Error>) {
|
|
self.0.unbounded_send(Err(error.into())).ok();
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct ToolCallEventStream {
|
|
tool_use_id: LanguageModelToolUseId,
|
|
stream: ThreadEventStream,
|
|
fs: Option<Arc<dyn Fs>>,
|
|
cancellation_rx: watch::Receiver<bool>,
|
|
}
|
|
|
|
impl ToolCallEventStream {
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub fn test() -> (Self, ToolCallEventStreamReceiver) {
|
|
let (stream, receiver, _cancellation_tx) = Self::test_with_cancellation();
|
|
(stream, receiver)
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub fn test_with_cancellation() -> (Self, ToolCallEventStreamReceiver, watch::Sender<bool>) {
|
|
let (events_tx, events_rx) = mpsc::unbounded::<Result<ThreadEvent>>();
|
|
let (cancellation_tx, cancellation_rx) = watch::channel(false);
|
|
|
|
let stream = ToolCallEventStream::new(
|
|
"test_id".into(),
|
|
ThreadEventStream(events_tx),
|
|
None,
|
|
cancellation_rx,
|
|
);
|
|
|
|
(
|
|
stream,
|
|
ToolCallEventStreamReceiver(events_rx),
|
|
cancellation_tx,
|
|
)
|
|
}
|
|
|
|
/// Signal cancellation for this event stream. Only available in tests.
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub fn signal_cancellation_with_sender(cancellation_tx: &mut watch::Sender<bool>) {
|
|
cancellation_tx.send(true).ok();
|
|
}
|
|
|
|
fn new(
|
|
tool_use_id: LanguageModelToolUseId,
|
|
stream: ThreadEventStream,
|
|
fs: Option<Arc<dyn Fs>>,
|
|
cancellation_rx: watch::Receiver<bool>,
|
|
) -> Self {
|
|
Self {
|
|
tool_use_id,
|
|
stream,
|
|
fs,
|
|
cancellation_rx,
|
|
}
|
|
}
|
|
|
|
/// Returns a future that resolves when the user cancels the tool call.
|
|
/// Tools should select on this alongside their main work to detect user cancellation.
|
|
pub fn cancelled_by_user(&self) -> impl std::future::Future<Output = ()> + '_ {
|
|
let mut rx = self.cancellation_rx.clone();
|
|
async move {
|
|
loop {
|
|
if *rx.borrow() {
|
|
return;
|
|
}
|
|
if rx.changed().await.is_err() {
|
|
// Sender dropped, will never be cancelled
|
|
std::future::pending::<()>().await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns true if the user has cancelled this tool call.
|
|
/// This is useful for checking cancellation state after an operation completes,
|
|
/// to determine if the completion was due to user cancellation.
|
|
pub fn was_cancelled_by_user(&self) -> bool {
|
|
*self.cancellation_rx.clone().borrow()
|
|
}
|
|
|
|
pub fn tool_use_id(&self) -> &LanguageModelToolUseId {
|
|
&self.tool_use_id
|
|
}
|
|
|
|
pub fn update_fields(&self, fields: acp::ToolCallUpdateFields) {
|
|
self.stream
|
|
.update_tool_call_fields(&self.tool_use_id, fields, None);
|
|
}
|
|
|
|
pub fn update_fields_with_meta(
|
|
&self,
|
|
fields: acp::ToolCallUpdateFields,
|
|
meta: Option<acp::Meta>,
|
|
) {
|
|
self.stream
|
|
.update_tool_call_fields(&self.tool_use_id, fields, meta);
|
|
}
|
|
|
|
pub fn update_diff(&self, diff: Entity<acp_thread::Diff>) {
|
|
self.stream
|
|
.0
|
|
.unbounded_send(Ok(ThreadEvent::ToolCallUpdate(
|
|
acp_thread::ToolCallUpdateDiff {
|
|
id: acp::ToolCallId::new(self.tool_use_id.to_string()),
|
|
diff,
|
|
}
|
|
.into(),
|
|
)))
|
|
.ok();
|
|
}
|
|
|
|
pub fn subagent_spawned(&self, id: acp::SessionId) {
|
|
self.stream
|
|
.0
|
|
.unbounded_send(Ok(ThreadEvent::SubagentSpawned(id)))
|
|
.ok();
|
|
}
|
|
|
|
pub fn update_plan(&self, plan: acp::Plan) {
|
|
self.stream.send_plan(plan);
|
|
}
|
|
|
|
/// Authorize a third-party tool (e.g., MCP tool from a context server).
|
|
///
|
|
/// Unlike built-in tools, third-party tools don't support pattern-based permissions.
|
|
/// They only support `default` (allow/deny/confirm) per tool.
|
|
///
|
|
/// Uses the dropdown authorization flow with two granularities:
|
|
/// - "Always for <display_name> MCP tool" → sets `tools.<tool_id>.default = "allow"` or "deny"
|
|
/// - "Only this time" → allow/deny once
|
|
pub fn authorize_third_party_tool(
|
|
&self,
|
|
title: impl Into<String>,
|
|
tool_id: String,
|
|
display_name: String,
|
|
cx: &mut App,
|
|
) -> Task<Result<()>> {
|
|
let title = title.into();
|
|
let options = acp_thread::PermissionOptions::Dropdown(vec![
|
|
acp_thread::PermissionOptionChoice {
|
|
allow: acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new(format!("always_allow_mcp:{tool_id}")),
|
|
format!("Always for {display_name} MCP tool"),
|
|
acp::PermissionOptionKind::AllowAlways,
|
|
),
|
|
deny: acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new(format!("always_deny_mcp:{tool_id}")),
|
|
format!("Always for {display_name} MCP tool"),
|
|
acp::PermissionOptionKind::RejectAlways,
|
|
),
|
|
sub_patterns: vec![],
|
|
},
|
|
acp_thread::PermissionOptionChoice {
|
|
allow: acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new("allow"),
|
|
"Only this time",
|
|
acp::PermissionOptionKind::AllowOnce,
|
|
),
|
|
deny: acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new("deny"),
|
|
"Only this time",
|
|
acp::PermissionOptionKind::RejectOnce,
|
|
),
|
|
sub_patterns: vec![],
|
|
},
|
|
]);
|
|
|
|
// MCP tools are gated only by tool id (no per-input pattern
|
|
// matching), so we pass a single empty input value just to satisfy
|
|
// `decide_permission_from_settings`' signature.
|
|
let check_settings: Box<dyn Fn(&App) -> ToolPermissionDecision> =
|
|
Box::new(move |cx: &App| {
|
|
let settings = agent_settings::AgentSettings::get_global(cx);
|
|
decide_permission_from_settings(&tool_id, &[String::new()], settings)
|
|
});
|
|
|
|
self.run_authorization_loop(title, options, None, Some(check_settings), cx)
|
|
}
|
|
|
|
/// Gate a tool call on user permission, driven by the agent's
|
|
/// tool-permission settings.
|
|
///
|
|
/// Evaluates the current settings up-front: returns `Ok(())` immediately
|
|
/// if the tool is already allowed, an error if it is denied, and
|
|
/// otherwise prompts the user for a decision. While a prompt is pending,
|
|
/// a subscription to `SettingsStore` watches for changes (for example,
|
|
/// when the user clicks "Always for …" on a sibling tool call and the
|
|
/// new rule becomes globally visible). When settings change, the current
|
|
/// prompt is dismissed and the decision is re-evaluated. This closes the
|
|
/// gap where an "Always for …" decision on one pending tool call would
|
|
/// not propagate to other pending tool calls in the same turn or in
|
|
/// subagent turns.
|
|
///
|
|
/// For authorizations that must always prompt regardless of settings
|
|
/// (e.g. symlink-escape confirmations, sensitive settings-file edits),
|
|
/// use [`Self::prompt`] instead.
|
|
pub fn authorize(
|
|
&self,
|
|
title: impl Into<String>,
|
|
context: ToolPermissionContext,
|
|
cx: &mut App,
|
|
) -> Task<Result<()>> {
|
|
let title = title.into();
|
|
let options = context.build_permission_options();
|
|
|
|
let tool_name = context.tool_name.clone();
|
|
let input_values = context.input_values.clone();
|
|
let check_settings: Box<dyn Fn(&App) -> ToolPermissionDecision> =
|
|
Box::new(move |cx: &App| {
|
|
decide_permission_from_settings(
|
|
&tool_name,
|
|
&input_values,
|
|
agent_settings::AgentSettings::get_global(cx),
|
|
)
|
|
});
|
|
|
|
self.run_authorization_loop(title, options, Some(context), Some(check_settings), cx)
|
|
}
|
|
|
|
/// Like [`Self::authorize`], but always prompts the user without
|
|
/// consulting settings. Use this for authorizations that must be
|
|
/// confirmed even when the user has configured `always_allow` rules —
|
|
/// for example, symlink-escape confirmations or edits that target
|
|
/// sensitive settings files.
|
|
pub fn authorize_always_prompt(
|
|
&self,
|
|
title: impl Into<String>,
|
|
context: ToolPermissionContext,
|
|
cx: &mut App,
|
|
) -> Task<Result<()>> {
|
|
let title = title.into();
|
|
let options = context.build_permission_options();
|
|
self.run_authorization_loop(title, options, Some(context), None, cx)
|
|
}
|
|
|
|
/// Prompts the user for authorization.
|
|
///
|
|
/// When `check_settings` is `Some`, this gate is settings-driven: the
|
|
/// settings are evaluated up-front (an Allow or Deny result resolves the
|
|
/// task immediately without prompting), and while a prompt is pending a
|
|
/// `SettingsStore` subscription watches for changes. A subsequent Allow
|
|
/// or Deny dismisses the prompt UI and resolves the task without user
|
|
/// interaction.
|
|
///
|
|
/// When `check_settings` is `None`, the user is always prompted and
|
|
/// settings changes are ignored. This suits prompts that aren't
|
|
/// settings-driven (e.g. symlink-escape confirmations).
|
|
fn run_authorization_loop(
|
|
&self,
|
|
title: String,
|
|
options: acp_thread::PermissionOptions,
|
|
context: Option<ToolPermissionContext>,
|
|
check_settings: Option<Box<dyn Fn(&App) -> ToolPermissionDecision>>,
|
|
cx: &mut App,
|
|
) -> Task<Result<()>> {
|
|
// Short-circuit when current settings yield a definitive answer.
|
|
if let Some(check) = check_settings.as_ref() {
|
|
match check(cx) {
|
|
ToolPermissionDecision::Allow => return Task::ready(Ok(())),
|
|
ToolPermissionDecision::Deny(reason) => {
|
|
return Task::ready(Err(anyhow!(reason)));
|
|
}
|
|
ToolPermissionDecision::Confirm => {}
|
|
}
|
|
}
|
|
|
|
let fs = self.fs.clone();
|
|
let stream = self.stream.clone();
|
|
let tool_use_id = self.tool_use_id.clone();
|
|
cx.spawn(async move |cx| {
|
|
let (response_tx, mut response_rx) = oneshot::channel();
|
|
if let Err(error) = stream
|
|
.0
|
|
.unbounded_send(Ok(ThreadEvent::ToolCallAuthorization(
|
|
ToolCallAuthorization {
|
|
tool_call: acp::ToolCallUpdate::new(
|
|
tool_use_id.to_string(),
|
|
acp::ToolCallUpdateFields::new().title(title),
|
|
),
|
|
options,
|
|
response: response_tx,
|
|
context,
|
|
},
|
|
)))
|
|
{
|
|
log::error!("Failed to send tool call authorization: {error}");
|
|
return Err(anyhow!("Failed to send tool call authorization: {error}"));
|
|
}
|
|
|
|
let Some(check_settings) = check_settings else {
|
|
let outcome = response_rx
|
|
.await
|
|
.map_err(|_| anyhow!("authorization channel closed"))?;
|
|
|
|
return Self::persist_permission_outcome(&outcome, fs, cx);
|
|
};
|
|
|
|
let (mut settings_tx, mut settings_rx) = watch::channel(());
|
|
let _settings_subscription = cx.update(|cx| {
|
|
cx.observe_global::<SettingsStore>(move |_cx| {
|
|
settings_tx.send(()).ok();
|
|
})
|
|
});
|
|
|
|
// Race the user's response against settings changes. On each
|
|
// settings change, re-evaluate `check_settings`: if it now
|
|
// yields a definitive Allow or Deny, resolve the prompt
|
|
// without user interaction. Otherwise keep waiting on the
|
|
// same prompt.
|
|
loop {
|
|
let settings_changed = async {
|
|
if settings_rx.changed().await.is_err() {
|
|
std::future::pending::<()>().await;
|
|
}
|
|
};
|
|
futures::select_biased! {
|
|
outcome = (&mut response_rx).fuse() => {
|
|
let outcome = outcome
|
|
.map_err(|_| anyhow!("authorization channel closed"))?;
|
|
return Self::persist_permission_outcome(&outcome, fs.clone(), cx);
|
|
}
|
|
_ = settings_changed.fuse() => {
|
|
// On auto-resolve, we dismiss the prompt UI by
|
|
// replacing the tool call's `WaitingForConfirmation`
|
|
// status with `InProgress` (or `Failed`). Dropping
|
|
// `response_rx` closes the `oneshot` held by the
|
|
// UI, so any late click by the user is a no-op.
|
|
match cx.update(|cx| check_settings(cx)) {
|
|
ToolPermissionDecision::Allow => {
|
|
drop(response_rx);
|
|
stream.update_tool_call_fields(
|
|
&tool_use_id,
|
|
acp::ToolCallUpdateFields::new()
|
|
.status(acp::ToolCallStatus::InProgress),
|
|
None,
|
|
);
|
|
return Ok(());
|
|
}
|
|
ToolPermissionDecision::Deny(reason) => {
|
|
drop(response_rx);
|
|
stream.update_tool_call_fields(
|
|
&tool_use_id,
|
|
acp::ToolCallUpdateFields::new()
|
|
.status(acp::ToolCallStatus::Failed),
|
|
None,
|
|
);
|
|
return Err(anyhow!(reason));
|
|
}
|
|
ToolPermissionDecision::Confirm => continue,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Interprets a `SelectedPermissionOutcome` and persists any settings changes.
|
|
/// Returns `true` if the tool call should be allowed, `false` if denied.
|
|
fn persist_permission_outcome(
|
|
outcome: &acp_thread::SelectedPermissionOutcome,
|
|
fs: Option<Arc<dyn Fs>>,
|
|
cx: &AsyncApp,
|
|
) -> Result<()> {
|
|
let option_id = outcome.option_id.0.as_ref();
|
|
let err = || Err(anyhow!("Permission to run tool denied by user"));
|
|
|
|
let always_permission = option_id
|
|
.strip_prefix("always_allow:")
|
|
.map(|tool| (tool, ToolPermissionMode::Allow))
|
|
.or_else(|| {
|
|
option_id
|
|
.strip_prefix("always_deny:")
|
|
.map(|tool| (tool, ToolPermissionMode::Deny))
|
|
})
|
|
.or_else(|| {
|
|
option_id
|
|
.strip_prefix("always_allow_mcp:")
|
|
.map(|tool| (tool, ToolPermissionMode::Allow))
|
|
})
|
|
.or_else(|| {
|
|
option_id
|
|
.strip_prefix("always_deny_mcp:")
|
|
.map(|tool| (tool, ToolPermissionMode::Deny))
|
|
});
|
|
|
|
if let Some((tool, mode)) = always_permission {
|
|
let params = outcome.params.as_ref();
|
|
Self::persist_always_permission(tool, mode, params, fs, cx);
|
|
return if mode == ToolPermissionMode::Allow {
|
|
Ok(())
|
|
} else {
|
|
err()
|
|
};
|
|
}
|
|
|
|
// Handle simple "allow" / "deny" (once, no persistence)
|
|
if option_id == "allow" || option_id == "deny" {
|
|
debug_assert!(
|
|
outcome.params.is_none(),
|
|
"unexpected params for once-only permission"
|
|
);
|
|
return if option_id == "allow" { Ok(()) } else { err() };
|
|
}
|
|
|
|
debug_assert!(false, "unexpected permission option_id: {option_id}");
|
|
|
|
err()
|
|
}
|
|
|
|
/// Persists an "always allow" or "always deny" permission, using sub_patterns
|
|
/// from params when present.
|
|
fn persist_always_permission(
|
|
tool: &str,
|
|
mode: ToolPermissionMode,
|
|
params: Option<&acp_thread::SelectedPermissionParams>,
|
|
fs: Option<Arc<dyn Fs>>,
|
|
cx: &AsyncApp,
|
|
) {
|
|
let Some(fs) = fs else {
|
|
return;
|
|
};
|
|
|
|
match params {
|
|
Some(acp_thread::SelectedPermissionParams::Terminal {
|
|
patterns: sub_patterns,
|
|
}) => {
|
|
debug_assert!(
|
|
!sub_patterns.is_empty(),
|
|
"empty sub_patterns for tool {tool} — callers should pass None instead"
|
|
);
|
|
let tool = tool.to_string();
|
|
let sub_patterns = sub_patterns.clone();
|
|
cx.update(|cx| {
|
|
update_settings_file(fs, cx, move |settings, _| {
|
|
let agent = settings.agent.get_or_insert_default();
|
|
for pattern in sub_patterns {
|
|
match mode {
|
|
ToolPermissionMode::Allow => {
|
|
agent.add_tool_allow_pattern(&tool, pattern);
|
|
}
|
|
ToolPermissionMode::Deny => {
|
|
agent.add_tool_deny_pattern(&tool, pattern);
|
|
}
|
|
// If there's no matching pattern this will
|
|
// default to confirm, so falling through is
|
|
// fine here.
|
|
ToolPermissionMode::Confirm => (),
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
None => {
|
|
let tool = tool.to_string();
|
|
cx.update(|cx| {
|
|
update_settings_file(fs, cx, move |settings, _| {
|
|
settings
|
|
.agent
|
|
.get_or_insert_default()
|
|
.set_tool_default_permission(&tool, mode);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub struct ToolCallEventStreamReceiver(mpsc::UnboundedReceiver<Result<ThreadEvent>>);
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
impl ToolCallEventStreamReceiver {
|
|
pub async fn expect_authorization(&mut self) -> ToolCallAuthorization {
|
|
let event = self.0.next().await;
|
|
if let Some(Ok(ThreadEvent::ToolCallAuthorization(auth))) = event {
|
|
auth
|
|
} else {
|
|
panic!("Expected ToolCallAuthorization but got: {:?}", event);
|
|
}
|
|
}
|
|
|
|
pub async fn expect_update_fields(&mut self) -> acp::ToolCallUpdateFields {
|
|
let event = self.0.next().await;
|
|
if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(
|
|
update,
|
|
)))) = event
|
|
{
|
|
update.fields
|
|
} else {
|
|
panic!("Expected update fields but got: {:?}", event);
|
|
}
|
|
}
|
|
|
|
pub async fn expect_diff(&mut self) -> Entity<acp_thread::Diff> {
|
|
let event = self.0.next().await;
|
|
if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateDiff(
|
|
update,
|
|
)))) = event
|
|
{
|
|
update.diff
|
|
} else {
|
|
panic!("Expected diff but got: {:?}", event);
|
|
}
|
|
}
|
|
|
|
pub async fn expect_terminal(&mut self) -> Entity<acp_thread::Terminal> {
|
|
let event = self.0.next().await;
|
|
if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateTerminal(
|
|
update,
|
|
)))) = event
|
|
{
|
|
update.terminal
|
|
} else {
|
|
panic!("Expected terminal but got: {:?}", event);
|
|
}
|
|
}
|
|
|
|
pub async fn expect_plan(&mut self) -> acp::Plan {
|
|
let event = self.0.next().await;
|
|
if let Some(Ok(ThreadEvent::Plan(plan))) = event {
|
|
plan
|
|
} else {
|
|
panic!("Expected plan but got: {:?}", event);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
impl std::ops::Deref for ToolCallEventStreamReceiver {
|
|
type Target = mpsc::UnboundedReceiver<Result<ThreadEvent>>;
|
|
|
|
fn deref(&self) -> &Self::Target {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
impl std::ops::DerefMut for ToolCallEventStreamReceiver {
|
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
|
&mut self.0
|
|
}
|
|
}
|
|
|
|
impl From<&str> for UserMessageContent {
|
|
fn from(text: &str) -> Self {
|
|
Self::Text(text.into())
|
|
}
|
|
}
|
|
|
|
impl From<String> for UserMessageContent {
|
|
fn from(text: String) -> Self {
|
|
Self::Text(text)
|
|
}
|
|
}
|
|
|
|
impl UserMessageContent {
|
|
pub fn from_content_block(value: acp::ContentBlock, path_style: PathStyle) -> Self {
|
|
match value {
|
|
acp::ContentBlock::Text(text_content) => Self::Text(text_content.text),
|
|
acp::ContentBlock::Image(image_content) => Self::Image(convert_image(image_content)),
|
|
acp::ContentBlock::Audio(_) => {
|
|
// TODO
|
|
Self::Text("[audio]".to_string())
|
|
}
|
|
acp::ContentBlock::ResourceLink(resource_link) => {
|
|
match MentionUri::parse(&resource_link.uri, path_style) {
|
|
Ok(uri) => Self::Mention {
|
|
uri,
|
|
content: String::new(),
|
|
},
|
|
Err(err) => {
|
|
log::error!("Failed to parse mention link: {}", err);
|
|
Self::Text(format!("[{}]({})", resource_link.name, resource_link.uri))
|
|
}
|
|
}
|
|
}
|
|
acp::ContentBlock::Resource(resource) => match resource.resource {
|
|
acp::EmbeddedResourceResource::TextResourceContents(resource) => {
|
|
match MentionUri::parse(&resource.uri, path_style) {
|
|
Ok(uri) => Self::Mention {
|
|
uri,
|
|
content: resource.text,
|
|
},
|
|
Err(err) => {
|
|
log::error!("Failed to parse mention link: {}", err);
|
|
Self::Text(
|
|
MarkdownCodeBlock {
|
|
tag: &resource.uri,
|
|
text: &resource.text,
|
|
}
|
|
.to_string(),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
acp::EmbeddedResourceResource::BlobResourceContents(_) => {
|
|
// TODO
|
|
Self::Text("[blob]".to_string())
|
|
}
|
|
other => {
|
|
log::warn!("Unexpected content type: {:?}", other);
|
|
Self::Text("[unknown]".to_string())
|
|
}
|
|
},
|
|
other => {
|
|
log::warn!("Unexpected content type: {:?}", other);
|
|
Self::Text("[unknown]".to_string())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<UserMessageContent> for acp::ContentBlock {
|
|
fn from(content: UserMessageContent) -> Self {
|
|
match content {
|
|
UserMessageContent::Text(text) => text.into(),
|
|
UserMessageContent::Image(image) => {
|
|
acp::ContentBlock::Image(acp::ImageContent::new(image.source, "image/png"))
|
|
}
|
|
UserMessageContent::Mention { uri, content } => acp::ContentBlock::Resource(
|
|
acp::EmbeddedResource::new(acp::EmbeddedResourceResource::TextResourceContents(
|
|
acp::TextResourceContents::new(content, uri.to_uri().to_string()),
|
|
)),
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn convert_image(image_content: acp::ImageContent) -> LanguageModelImage {
|
|
LanguageModelImage {
|
|
source: image_content.data.into(),
|
|
size: None,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use gpui::TestAppContext;
|
|
use language_model::LanguageModelToolUseId;
|
|
use language_model::fake_provider::FakeLanguageModel;
|
|
use serde_json::json;
|
|
use std::sync::Arc;
|
|
|
|
async fn setup_thread_for_test(cx: &mut TestAppContext) -> (Entity<Thread>, ThreadEventStream) {
|
|
cx.update(|cx| {
|
|
let settings_store = settings::SettingsStore::test(cx);
|
|
cx.set_global(settings_store);
|
|
});
|
|
|
|
let fs = fs::FakeFs::new(cx.background_executor.clone());
|
|
let templates = Templates::new();
|
|
let project = Project::test(fs.clone(), [], cx).await;
|
|
|
|
cx.update(|cx| {
|
|
let project_context = cx.new(|_cx| prompt_store::ProjectContext::default());
|
|
let context_server_store = project.read(cx).context_server_store();
|
|
let context_server_registry =
|
|
cx.new(|cx| ContextServerRegistry::new(context_server_store, cx));
|
|
|
|
let thread = cx.new(|cx| {
|
|
Thread::new(
|
|
project,
|
|
project_context,
|
|
context_server_registry,
|
|
templates,
|
|
None,
|
|
cx,
|
|
)
|
|
});
|
|
|
|
let (event_tx, _event_rx) = mpsc::unbounded();
|
|
let event_stream = ThreadEventStream(event_tx);
|
|
|
|
(thread, event_stream)
|
|
})
|
|
}
|
|
|
|
fn setup_parent_with_subagents(
|
|
cx: &mut TestAppContext,
|
|
parent: &Entity<Thread>,
|
|
count: usize,
|
|
) -> Vec<Entity<Thread>> {
|
|
cx.update(|cx| {
|
|
let mut subagents = Vec::new();
|
|
for _ in 0..count {
|
|
let subagent = cx.new(|cx| Thread::new_subagent(parent, cx));
|
|
parent.update(cx, |thread, _cx| {
|
|
thread.register_running_subagent(subagent.downgrade());
|
|
});
|
|
subagents.push(subagent);
|
|
}
|
|
subagents
|
|
})
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_set_model_propagates_to_subagents(cx: &mut TestAppContext) {
|
|
let (parent, _event_stream) = setup_thread_for_test(cx).await;
|
|
let subagents = setup_parent_with_subagents(cx, &parent, 2);
|
|
|
|
let new_model: Arc<dyn LanguageModel> = Arc::new(FakeLanguageModel::with_id_and_thinking(
|
|
"test-provider",
|
|
"new-model",
|
|
"New Model",
|
|
false,
|
|
));
|
|
|
|
cx.update(|cx| {
|
|
parent.update(cx, |thread, cx| {
|
|
thread.set_model(new_model, cx);
|
|
});
|
|
|
|
for subagent in &subagents {
|
|
let subagent_model_id = subagent.read(cx).model().unwrap().id();
|
|
assert_eq!(
|
|
subagent_model_id.0.as_ref(),
|
|
"new-model",
|
|
"Subagent model should match parent model after set_model"
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_set_summarization_model_propagates_to_subagents(cx: &mut TestAppContext) {
|
|
let (parent, _event_stream) = setup_thread_for_test(cx).await;
|
|
let subagents = setup_parent_with_subagents(cx, &parent, 2);
|
|
|
|
let summary_model: Arc<dyn LanguageModel> =
|
|
Arc::new(FakeLanguageModel::with_id_and_thinking(
|
|
"test-provider",
|
|
"summary-model",
|
|
"Summary Model",
|
|
false,
|
|
));
|
|
|
|
cx.update(|cx| {
|
|
parent.update(cx, |thread, cx| {
|
|
thread.set_summarization_model(Some(summary_model), cx);
|
|
});
|
|
|
|
for subagent in &subagents {
|
|
let subagent_summary_id = subagent.read(cx).summarization_model().unwrap().id();
|
|
assert_eq!(
|
|
subagent_summary_id.0.as_ref(),
|
|
"summary-model",
|
|
"Subagent summarization model should match parent after set_summarization_model"
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_set_thinking_enabled_propagates_to_subagents(cx: &mut TestAppContext) {
|
|
let (parent, _event_stream) = setup_thread_for_test(cx).await;
|
|
let subagents = setup_parent_with_subagents(cx, &parent, 2);
|
|
|
|
cx.update(|cx| {
|
|
parent.update(cx, |thread, cx| {
|
|
thread.set_thinking_enabled(true, cx);
|
|
});
|
|
|
|
for subagent in &subagents {
|
|
assert!(
|
|
subagent.read(cx).thinking_enabled(),
|
|
"Subagent thinking should be enabled after parent enables it"
|
|
);
|
|
}
|
|
|
|
parent.update(cx, |thread, cx| {
|
|
thread.set_thinking_enabled(false, cx);
|
|
});
|
|
|
|
for subagent in &subagents {
|
|
assert!(
|
|
!subagent.read(cx).thinking_enabled(),
|
|
"Subagent thinking should be disabled after parent disables it"
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_set_thinking_effort_propagates_to_subagents(cx: &mut TestAppContext) {
|
|
let (parent, _event_stream) = setup_thread_for_test(cx).await;
|
|
let subagents = setup_parent_with_subagents(cx, &parent, 2);
|
|
|
|
cx.update(|cx| {
|
|
parent.update(cx, |thread, cx| {
|
|
thread.set_thinking_effort(Some("high".to_string()), cx);
|
|
});
|
|
|
|
for subagent in &subagents {
|
|
assert_eq!(
|
|
subagent.read(cx).thinking_effort().map(|s| s.as_str()),
|
|
Some("high"),
|
|
"Subagent thinking effort should match parent"
|
|
);
|
|
}
|
|
|
|
parent.update(cx, |thread, cx| {
|
|
thread.set_thinking_effort(None, cx);
|
|
});
|
|
|
|
for subagent in &subagents {
|
|
assert_eq!(
|
|
subagent.read(cx).thinking_effort(),
|
|
None,
|
|
"Subagent thinking effort should be None after parent clears it"
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_subagent_inherits_settings_at_creation(cx: &mut TestAppContext) {
|
|
let (parent, _event_stream) = setup_thread_for_test(cx).await;
|
|
|
|
cx.update(|cx| {
|
|
parent.update(cx, |thread, cx| {
|
|
thread.set_speed(Speed::Fast, cx);
|
|
thread.set_thinking_enabled(true, cx);
|
|
thread.set_thinking_effort(Some("high".to_string()), cx);
|
|
thread.set_profile(AgentProfileId("custom-profile".into()), cx);
|
|
});
|
|
});
|
|
|
|
let subagents = setup_parent_with_subagents(cx, &parent, 1);
|
|
|
|
cx.update(|cx| {
|
|
let sub = subagents[0].read(cx);
|
|
assert_eq!(sub.speed(), Some(Speed::Fast));
|
|
assert!(sub.thinking_enabled());
|
|
assert_eq!(sub.thinking_effort().map(|s| s.as_str()), Some("high"));
|
|
assert_eq!(sub.profile(), &AgentProfileId("custom-profile".into()));
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_set_speed_propagates_to_subagents(cx: &mut TestAppContext) {
|
|
let (parent, _event_stream) = setup_thread_for_test(cx).await;
|
|
let subagents = setup_parent_with_subagents(cx, &parent, 2);
|
|
|
|
cx.update(|cx| {
|
|
parent.update(cx, |thread, cx| {
|
|
thread.set_speed(Speed::Fast, cx);
|
|
});
|
|
|
|
for subagent in &subagents {
|
|
assert_eq!(
|
|
subagent.read(cx).speed(),
|
|
Some(Speed::Fast),
|
|
"Subagent speed should match parent after set_speed"
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_dropped_subagent_does_not_panic(cx: &mut TestAppContext) {
|
|
let (parent, _event_stream) = setup_thread_for_test(cx).await;
|
|
let subagents = setup_parent_with_subagents(cx, &parent, 1);
|
|
|
|
// Drop the subagent so the WeakEntity can no longer be upgraded
|
|
drop(subagents);
|
|
|
|
// Should not panic even though the subagent was dropped
|
|
cx.update(|cx| {
|
|
parent.update(cx, |thread, cx| {
|
|
thread.set_thinking_enabled(true, cx);
|
|
thread.set_speed(Speed::Fast, cx);
|
|
thread.set_thinking_effort(Some("high".to_string()), cx);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_handle_tool_use_json_parse_error_adds_tool_use_to_content(
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
let (thread, event_stream) = setup_thread_for_test(cx).await;
|
|
|
|
let tool_use_id = LanguageModelToolUseId::from("test_tool_id");
|
|
let tool_name: Arc<str> = Arc::from("test_tool");
|
|
let raw_input: Arc<str> = Arc::from("{invalid json");
|
|
let json_parse_error = "expected value at line 1 column 1".to_string();
|
|
|
|
let (_cancellation_tx, cancellation_rx) = watch::channel(false);
|
|
|
|
let result = cx
|
|
.update(|cx| {
|
|
thread.update(cx, |thread, cx| {
|
|
// Call the function under test
|
|
thread
|
|
.handle_tool_use_json_parse_error_event(
|
|
tool_use_id.clone(),
|
|
tool_name.clone(),
|
|
raw_input.clone(),
|
|
json_parse_error,
|
|
&event_stream,
|
|
cancellation_rx,
|
|
cx,
|
|
)
|
|
.unwrap()
|
|
})
|
|
})
|
|
.await;
|
|
|
|
// Verify the result is an error
|
|
assert!(result.is_error);
|
|
assert_eq!(result.tool_use_id, tool_use_id);
|
|
assert_eq!(result.tool_name, tool_name);
|
|
assert!(matches!(
|
|
result.content.as_slice(),
|
|
[LanguageModelToolResultContent::Text(_)]
|
|
));
|
|
|
|
thread.update(cx, |thread, _cx| {
|
|
// Verify the tool use was added to the message content
|
|
{
|
|
let last_message = thread.pending_message();
|
|
assert_eq!(
|
|
last_message.content.len(),
|
|
1,
|
|
"Should have one tool_use in content"
|
|
);
|
|
|
|
match &last_message.content[0] {
|
|
AgentMessageContent::ToolUse(tool_use) => {
|
|
assert_eq!(tool_use.id, tool_use_id);
|
|
assert_eq!(tool_use.name, tool_name);
|
|
assert_eq!(tool_use.raw_input, raw_input.to_string());
|
|
assert!(tool_use.is_input_complete);
|
|
// Should fall back to empty object for invalid JSON
|
|
assert_eq!(tool_use.input, json!({}));
|
|
}
|
|
_ => panic!("Expected ToolUse content"),
|
|
}
|
|
}
|
|
|
|
// Insert the tool result (simulating what the caller does)
|
|
thread
|
|
.pending_message()
|
|
.tool_results
|
|
.insert(result.tool_use_id.clone(), result);
|
|
|
|
// Verify the tool result was added
|
|
let last_message = thread.pending_message();
|
|
assert_eq!(
|
|
last_message.tool_results.len(),
|
|
1,
|
|
"Should have one tool_result"
|
|
);
|
|
assert!(last_message.tool_results.contains_key(&tool_use_id));
|
|
})
|
|
}
|
|
}
|