use crate::{ ApplyCodeActionTool, CodeActionStore, ContextServerRegistry, CopyPathTool, CreateDirectoryTool, CreateThreadTool, DbLanguageModel, DbThread, DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool, ListAgentsAndModelsTool, ListDirectoryTool, MovePathTool, ProjectSnapshot, ReadFileTool, RenameTool, SandboxedTerminalTool, SpawnAgentTool, SystemPromptTemplate, Template, Templates, TerminalTool, ToolPermissionDecision, WebSearchTool, WriteFileTool, decide_permission_from_settings, }; use acp_thread::{ClientUserMessageId, MentionUri}; use action_log::ActionLog; use agent_settings::UserAgentsMd; use crate::sandboxing::{ SandboxRequest, ThreadSandbox, ThreadSandboxGrants, sandbox_git_dirs, sandbox_worktree_writable_paths, sandboxing_available_for_project, sandboxing_enabled_for_project, }; use agent_client_protocol::schema::v1 as acp; use agent_settings::{ AgentProfileId, AgentProfileSettings, AgentSettings, AutoCompactThreshold, COMPACTION_PROMPT, SUMMARIZE_THREAD_DETAILED_PROMPT, SUMMARIZE_THREAD_PROMPT, builtin_profiles, }; use anyhow::{Context as _, Result, anyhow}; use chrono::{DateTime, Local, 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, ReadGlobal as _, 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, MessageContent, Role, SelectedModel, Speed, StopReason, TokenUsage, ZED_CLOUD_PROVIDER_ID, }; use project::{Project, trusted_worktrees::TrustedWorktrees}; 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::fmt::Write; use std::{cell::RefCell, ops::ControlFlow}; use std::{ collections::BTreeMap, marker::PhantomData, ops::RangeInclusive, path::{Path, PathBuf}, rc::Rc, sync::Arc, time::{Duration, Instant}, }; use util::{ResultExt, debug_panic, markdown::MarkdownCodeBlock, paths::PathStyle}; use uuid::Uuid; const TOOL_CANCELED_MESSAGE: &str = "Tool canceled by user"; const TOOL_CALL_INTERRUPTED_BY_FOLLOW_UP_MESSAGE: &str = "Permission denied: user sent a follow-up message instead of approving the tool call."; pub(crate) const FOLLOW_UP_PERMISSION_DENIED_OPTION_ID: &str = "follow_up_permission_denied"; pub const MAX_TOOL_NAME_LENGTH: usize = 64; pub const MAX_SUBAGENT_DEPTH: u8 = 1; pub(crate) fn provider_compatible_tool_name(tool_name: &str) -> String { let mut sanitized = String::new(); for character in tool_name.chars() { if sanitized.len() >= MAX_TOOL_NAME_LENGTH { break; } if character.is_ascii_alphanumeric() || character == '_' || character == '-' { sanitized.push(character); } else { sanitized.push('_'); } } if sanitized.is_empty() { sanitized.push_str("tool"); } sanitized } #[derive(Clone, Debug, Eq, PartialEq)] pub struct SandboxStatusKey { pub settings_sandbox: ThreadSandbox, pub thread_sandbox: ThreadSandbox, pub baseline_writable_paths: Vec, pub git_paths: Vec, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct VerifiedSandboxStatus { pub settings_sandbox: ThreadSandbox, pub thread_sandbox: ThreadSandbox, pub baseline_writable_paths: Vec, } pub enum SandboxStatusRefresh { Ready(VerifiedSandboxStatus), Pending(Task), } /// Auto-compaction is only available for models whose context window is at least /// this large. For smaller models there isn't enough headroom for a compaction /// pass to be worthwhile, so we leave the thread uncompacted and let the UI warn /// the user instead. pub const MIN_COMPACTION_CONTEXT_WINDOW: u64 = 80_000; // Using the heuristic that 1 token is about 4 bytes, keep the last 80K bytes of user-message content (~20k tokens). const COMPACTION_RETAINED_USER_MESSAGES_BYTE_BUDGET: usize = 80_000; /// 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); 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, PartialEq, Serialize, Deserialize)] pub enum Message { User(UserMessage), Agent(AgentMessage), Resume, Compaction(CompactionInfo), } #[derive(Debug, PartialEq, Serialize, Deserialize)] pub enum CompactionInfo { Summary(SharedString), ProviderNative { provider: LanguageModelProviderId, items: Vec, }, } impl CompactionInfo { fn to_request(&self) -> Vec { match self { Self::Summary(summary) => vec![LanguageModelRequestMessage { role: Role::User, content: vec![format!( "The previous conversation was compacted. Use this summary as context:\n\n{}", summary ) .into()], cache: false, reasoning_details: None, }], Self::ProviderNative { .. } => Vec::new(), } } } 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 { match self { Message::User(message) => { if message.content.is_empty() { vec![] } else { vec![message.to_request()] } } Message::Agent(message) => message.to_request(), Message::Compaction(info) => info.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(), Message::Compaction(_) => "--- Context Compacted ---\n".into(), } } pub fn role(&self) -> Role { match self { Message::User(_) | Message::Resume | Message::Compaction(_) => Role::User, Message::Agent(_) => Role::Assistant, } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct UserMessage { pub id: ClientUserMessageId, pub content: Arc<[UserMessageContent]>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum UserMessageContent { Text(String), Mention { uri: MentionUri, content: SharedString, }, 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("\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 = "\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 = ""; const OPEN_DIRECTORIES_TAG: &str = ""; const OPEN_SYMBOLS_TAG: &str = ""; const OPEN_SELECTIONS_TAG: &str = ""; const OPEN_THREADS_TAG: &str = ""; const OPEN_FETCH_TAG: &str = ""; const OPEN_RULES_TAG: &str = "\nThe user has specified the following rules that should be applied:\n"; const OPEN_DIAGNOSTICS_TAG: &str = ""; const OPEN_DIFFS_TAG: &str = ""; const MERGE_CONFLICT_TAG: &str = ""; const OPEN_SKILLS_TAG: &str = "\nThe user has attached the following agent skills:\n"; 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(); let mut skills_context = OPEN_SKILLS_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, } ) .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 { .. } => { // Deprecated: keeps legacy rule mentions as context. 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(); } MentionUri::Skill { name, source, .. } => { let label = format!("{} ({})", name, source); write!(&mut skills_context, "\nSkill: {}\n{}\n", label, 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("\n"); message .content .push(language_model::MessageContent::Text(file_context)); } if directory_context.len() > OPEN_DIRECTORIES_TAG.len() { directory_context.push_str("\n"); message .content .push(language_model::MessageContent::Text(directory_context)); } if symbol_context.len() > OPEN_SYMBOLS_TAG.len() { symbol_context.push_str("\n"); message .content .push(language_model::MessageContent::Text(symbol_context)); } if selection_context.len() > OPEN_SELECTIONS_TAG.len() { selection_context.push_str("\n"); message .content .push(language_model::MessageContent::Text(selection_context)); } if diffs_context.len() > OPEN_DIFFS_TAG.len() { diffs_context.push_str("\n"); message .content .push(language_model::MessageContent::Text(diffs_context)); } if thread_context.len() > OPEN_THREADS_TAG.len() { thread_context.push_str("\n"); message .content .push(language_model::MessageContent::Text(thread_context)); } if fetch_context.len() > OPEN_FETCH_TAG.len() { fetch_context.push_str("\n"); message .content .push(language_model::MessageContent::Text(fetch_context)); } if rules_context.len() > OPEN_RULES_TAG.len() { rules_context.push_str("\n"); message .content .push(language_model::MessageContent::Text(rules_context)); } if diagnostics_context.len() > OPEN_DIAGNOSTICS_TAG.len() { diagnostics_context.push_str("\n"); message .content .push(language_model::MessageContent::Text(diagnostics_context)); } if skills_context.len() > OPEN_SKILLS_TAG.len() { skills_context.push_str("\n"); message .content .push(language_model::MessageContent::Text(skills_context)); } if merge_conflict_context.len() > MERGE_CONFLICT_TAG.len() { merge_conflict_context.push_str("\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("".into())); } message } } fn codeblock_tag(full_path: &Path, line_range: Option<&RangeInclusive>) -> 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(""); markdown.push_str(text); markdown.push_str("\n"); } AgentMessageContent::RedactedThinking(_) => { markdown.push_str("\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.to_display_json()) } )); } } } 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, "\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 { 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!["".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(crate) content: Vec, pub(crate) tool_results: IndexMap, pub(crate) reasoning_details: Option>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum AgentMessageContent { Text(String), Thinking { text: String, signature: Option, }, RedactedThinking(String), ToolUse(LanguageModelToolUse), } pub trait TerminalHandle { fn id(&self, cx: &AsyncApp) -> Result; fn current_output(&self, cx: &AsyncApp) -> Result; fn wait_for_exit(&self, cx: &AsyncApp) -> Result>>; fn kill(&self, cx: &AsyncApp) -> Result<()>; fn was_stopped_by_user(&self, cx: &AsyncApp) -> Result; } 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>; } pub trait ThreadEnvironment { fn create_terminal( &self, command: String, extra_env: Vec, cwd: Option, output_byte_limit: Option, sandbox_wrap: Option, cx: &mut AsyncApp, ) -> Task>>; fn create_subagent(&self, label: String, cx: &mut App) -> Result>; fn resume_subagent( &self, _session_id: acp::SessionId, _cx: &mut App, ) -> Result> { Err(anyhow::anyhow!( "Resuming subagent sessions is not supported" )) } /// Creates an independent sibling thread visible in the agent sidebar. /// Unlike subagents, sibling threads are first-class threads that persist /// and run in parallel without reporting results back to the parent. fn create_sibling_thread( &self, request: SiblingThreadRequest, cx: &mut AsyncApp, ) -> Task> { let _ = request; let _ = cx; Task::ready(Err(anyhow::anyhow!( "Creating sibling threads is not supported in this environment" ))) } /// Lists the agents and models available for use with `create_sibling_thread`. fn list_available_agents(&self, cx: &mut App) -> Result { let _ = cx; Err(anyhow::anyhow!( "Listing available agents is not supported in this environment" )) } } /// A request to create a new sibling thread. #[derive(Debug, Clone)] pub struct SiblingThreadRequest { /// A short title for the new thread, shown in the sidebar. pub title: SharedString, /// The initial prompt to send to the new thread. pub prompt: String, /// Optional agent ID to use. Defaults to the native Zed agent. pub agent_id: Option, /// Optional model override, as `provider/model-id`. /// Defaults to the user's configured default model for the agent. pub model: Option, /// Whether to create the thread in a new git worktree workspace. pub use_new_worktree: bool, /// Optional worktree directory name. When `None`, the UI generates a /// random non-colliding name (matching the manual "Create worktree" /// flow). Only relevant when `use_new_worktree` is true. pub worktree_name: Option, /// Git ref (branch, tag, or commit) to base the new worktree on. /// Only relevant when `use_new_worktree` is true. pub base_ref: Option, } /// Information returned when a sibling thread is successfully created. #[derive(Debug, Clone)] pub struct SiblingThreadInfo { /// The title assigned to the thread. pub title: SharedString, /// The agent ID used for the thread. pub agent_id: String, /// The model ID used for the thread, if known. pub model: Option, /// An optional, non-fatal heads-up about the created thread that the /// caller should relay or take into account (e.g., the project had an /// unusual worktree layout that affected how the new worktree was set /// up). Empty when nothing noteworthy happened. pub warning: Option, } /// A list of agents and, for each, the models available for use. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AvailableAgents { pub agents: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AvailableAgent { /// Identifier used when creating a thread. pub id: String, /// Human-readable name shown in the UI. pub name: SharedString, /// Whether this is Zed's built-in native agent. pub is_native: bool, /// Models available for this agent. May be empty if models are not /// enumerated up front (e.g., external agents that choose their own). pub models: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AvailableModel { /// Identifier to pass as the `model` field when creating a thread. pub id: String, /// Human-readable name. pub name: SharedString, /// Whether this is the default model for the agent. pub is_default: bool, } #[derive(Debug)] pub enum ThreadEvent { UserMessage(UserMessage), AgentText(String), AgentThinking(String), ToolCall(acp::ToolCall), ToolCallUpdate(acp_thread::ToolCallUpdate), ToolCallAuthorization(ToolCallAuthorization), ToolCallAuthorizationResolved { tool_call_id: acp::ToolCallId, outcome: acp_thread::SelectedPermissionOutcome, }, SubagentSpawned(acp::SessionId), Retry(acp_thread::RetryStatus), ContextCompaction(acp_thread::ContextCompaction), ContextCompactionUpdate(acp_thread::ContextCompactionUpdate), Stop(acp::StopReason), } #[derive(Debug)] pub struct NewTerminal { pub command: String, pub output_byte_limit: Option, pub cwd: Option, pub response: oneshot::Sender>>, } #[derive(Debug, Clone)] pub struct ToolPermissionContext { pub tool_name: String, pub input_values: Vec, pub scope: ToolPermissionScope, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToolPermissionScope { ToolInput, SymlinkTarget, AgentSkills, } impl ToolPermissionContext { pub fn new(tool_name: impl Into, input_values: Vec) -> Self { Self { tool_name: tool_name.into(), input_values, scope: ToolPermissionScope::ToolInput, } } pub fn symlink_target(tool_name: impl Into, target_paths: Vec) -> Self { Self { tool_name: tool_name.into(), input_values: target_paths, scope: ToolPermissionScope::SymlinkTarget, } } pub fn for_agent_skills(mut self) -> Self { self.scope = ToolPermissionScope::AgentSkills; self } /// 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, ), ]); } // Skills always prompt, so offer only once-only allow/deny. if self.scope == ToolPermissionScope::AgentSkills { return acp_thread::PermissionOptions::Flat(vec![ acp::PermissionOption::new( acp::PermissionOptionId::new("allow"), "Allow", acp::PermissionOptionKind::AllowOnce, ), acp::PermissionOption::new( acp::PermissionOptionId::new("deny"), "Deny", 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, Option) { 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 { ( 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| { 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, pub context: Option, pub kind: acp_thread::AuthorizationKind, } fn ensure_tool_call_authorization_not_interrupted( outcome: &acp_thread::SelectedPermissionOutcome, ) -> Result<()> { if outcome.option_id.0.as_ref() == FOLLOW_UP_PERMISSION_DENIED_OPTION_ID { Err(anyhow!(TOOL_CALL_INTERRUPTED_BY_FOLLOW_UP_MESSAGE)) } else { Ok(()) } } fn auto_resolve_permission_outcome( options: &acp_thread::PermissionOptions, is_allow: bool, ) -> Result { let kind = if is_allow { acp::PermissionOptionKind::AllowOnce } else { acp::PermissionOptionKind::RejectOnce }; let option = options .first_option_of_kind(kind) .ok_or_else(|| anyhow!("permission prompt has no auto-resolution option"))?; Ok(acp_thread::SelectedPermissionOutcome::new( option.option_id.clone(), option.kind, )) } #[derive(Debug, thiserror::Error)] enum CompletionError { #[error("max tokens")] MaxTokens, #[error("refusal")] Refusal, #[error(transparent)] Other(#[from] anyhow::Error), } pub enum ThreadModel { Ready(Arc), Unresolved(SelectedModel), Unset, } impl ThreadModel { fn as_model(&self) -> Option<&Arc> { match self { Self::Ready(model) => Some(model), Self::Unresolved(_) | Self::Unset => None, } } } impl From<&ThreadModel> for Option { fn from(model: &ThreadModel) -> Self { match model { ThreadModel::Ready(model) => Some(DbLanguageModel { provider: model.provider_id().to_string(), model: model.id().0.to_string(), }), ThreadModel::Unresolved(selection) => Some(DbLanguageModel { provider: selection.provider.0.to_string(), model: selection.model.0.to_string(), }), ThreadModel::Unset => None, } } } pub struct Thread { id: acp::SessionId, prompt_id: PromptId, updated_at: DateTime, title: Option, pending_title_generation: Option>, title_generation_error: Option, pending_summary_generation: Option>>>, summary: Option, messages: Vec>, user_store: Entity, /// 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, /// When set, the current turn ends at the next message boundary instead of /// running to completion. The UI sets this to deliver a "steering" queued /// message mid-task; by default queued messages wait for the turn to finish. end_turn_at_next_boundary: bool, pending_message: Option, pub(crate) tools: BTreeMap>, request_token_usage: HashMap, cumulative_token_usage: TokenUsage, /// The per-field maximum usage snapshot already added to /// `cumulative_token_usage` for the in-flight completion request. Reset at /// the start of each request. current_request_token_usage: TokenUsage, pending_compaction_telemetry: Option, #[allow(unused)] initial_project_snapshot: Shared>>>, pub(crate) context_server_registry: Entity, profile_id: AgentProfileId, /// Whether `profile_id` was downgraded to `minimal` at thread start because /// the workspace is restricted. Used purely to surface a warning in the UI. profile_downgraded_for_restricted_workspace: bool, project_context: Entity, pub(crate) templates: Arc, model: ThreadModel, summarization_model: Option>, thinking_enabled: bool, thinking_effort: Option, speed: Option, prompt_capabilities_tx: watch::Sender, pub(crate) prompt_capabilities_rx: watch::Receiver, pub(crate) project: Entity, pub(crate) action_log: Entity, /// If this is a subagent thread, contains context about the parent subagent_context: Option, /// The user's unsent prompt text, persisted so it can be restored when reloading the thread. draft_prompt: Option>, ui_scroll_position: Option, /// Weak references to running subagent threads for cancellation propagation running_subagents: Vec>, inherits_parent_model_settings: bool, sandboxed_terminal_temp_dir: Option, /// Sandbox permissions the user approved "for the rest of the thread". /// Shared with each tool call's event stream so repeated requests for /// already-granted permissions skip the approval prompt. /// Never persisted — lives and dies with this thread. sandbox_grants: Rc>, } 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, cx: &mut Context) -> 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); if let Some(subagent_model) = AgentSettings::get_global(cx).subagent_model.clone() { thread.inherits_parent_model_settings = false; thread.apply_model_selection(&subagent_model, cx); } thread } pub fn new( project: Entity, project_context: Entity, context_server_registry: Entity, templates: Arc, model: Option>, cx: &mut Context, ) -> 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_context: Entity, context_server_registry: Entity, templates: Arc, model: Option>, action_log: Entity, cx: &mut Context, ) -> Self { let settings = AgentSettings::get_global(cx); let (profile_id, profile_downgraded_for_restricted_workspace) = Self::profile_for_restricted_workspace(settings.default_profile.clone(), &project, cx); 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())); let model = match model { Some(model) => ThreadModel::Ready(model), None => Self::user_configured_model_selection(cx) .map_or(ThreadModel::Unset, ThreadModel::Unresolved), }; 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_error: None, pending_summary_generation: None, summary: None, messages: Vec::new(), user_store: project.read(cx).user_store(), running_turn: None, end_turn_at_next_boundary: false, pending_message: None, tools: BTreeMap::default(), request_token_usage: HashMap::default(), cumulative_token_usage: TokenUsage::default(), current_request_token_usage: TokenUsage::default(), pending_compaction_telemetry: None, 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, profile_downgraded_for_restricted_workspace, project_context, templates, model, summarization_model: None, thinking_enabled: enable_thinking, speed, thinking_effort, prompt_capabilities_tx, prompt_capabilities_rx, project, action_log, subagent_context: None, draft_prompt: None, ui_scroll_position: None, running_subagents: Vec::new(), inherits_parent_model_settings: true, sandboxed_terminal_temp_dir: None, sandbox_grants: Rc::new(RefCell::new(ThreadSandboxGrants::default())), } } /// 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, cx: &mut Context) { 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(); self.profile_downgraded_for_restricted_workspace = parent.profile_downgraded_for_restricted_workspace; } fn apply_model_selection( &mut self, selection: &LanguageModelSelection, cx: &mut Context, ) { let Some(model) = Self::resolve_model_from_selection(selection, cx) else { log::warn!( "failed to resolve configured subagent model: {}/{}", selection.provider.0, selection.model ); return; }; self.thinking_enabled = selection.enable_thinking && model.supports_thinking(); self.thinking_effort = selection.effort.clone(); self.speed = selection.speed.filter(|_| model.supports_fast_mode()); self.prompt_capabilities_tx .send(Self::prompt_capabilities(Some(model.as_ref()))) .log_err(); self.model = ThreadModel::Ready(model); } pub fn id(&self) -> &acp::SessionId { &self.id } // Only used by Seatbelt-style sandboxes (macOS); Linux relies on bwrap's // tmpfs `/tmp` and Windows on the WSL bwrap tmpfs, so neither needs a // per-thread temp directory. #[cfg(not(any(target_os = "linux", target_os = "windows")))] pub(crate) fn sandboxed_terminal_temp_dir( &mut self, cx: &mut Context, ) -> Result { if let Some(temp_dir) = &self.sandboxed_terminal_temp_dir { std::fs::create_dir_all(temp_dir).with_context(|| { format!( "failed to recreate sandboxed terminal temp directory {}", temp_dir.display() ) })?; return Ok(temp_dir.clone()); } let temp_dir = tempfile::Builder::new() .prefix("zed-agent-terminal-") .tempdir() .context("failed to create sandboxed terminal temp directory")?; let temp_dir = temp_dir.keep(); self.sandboxed_terminal_temp_dir = Some(temp_dir.clone()); cx.notify(); Ok(temp_dir) } pub fn replay( &mut self, cx: &mut Context, ) -> mpsc::UnboundedReceiver> { let (tx, rx) = mpsc::unbounded(); let stream = ThreadEventStream(tx); for (message_ix, message) in self.messages.iter().enumerate() { 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), message_ix, &stream, cx, ); } } } } Message::Resume => {} Message::Compaction(info) => { let compaction_id = acp_thread::ContextCompactionId( format!("replay-compaction-{message_ix}").into(), ); match info { CompactionInfo::Summary(summary) => { stream.send_context_compaction( compaction_id.clone(), acp_thread::ContextCompactionStatus::Completed, ); stream.send_context_compaction_update(compaction_id.clone(), summary); } CompactionInfo::ProviderNative { .. } => { stream.send_context_compaction( compaction_id, acp_thread::ContextCompactionStatus::Completed, ); } } } } } rx } fn replay_tool_call( &self, tool_use: &LanguageModelToolUse, tool_result: Option<&LanguageModelToolResult>, owning_message_ix: usize, stream: &ThreadEventStream, cx: &mut Context, ) { // A tool call left only with the canceled sentinel produced nothing useful // (the sentinel is model-facing only, and is inserted exactly when a tool // had no real result). Don't replay it into the UI at all. if tool_result.is_some_and(Self::is_canceled_tool_result) { return; } let tool_call_id = scoped_tool_call_id(owning_message_ix, &tool_use.id); let output = tool_result .as_ref() .and_then(|result| result.output.clone()); let replay_content = tool_result.and_then(Self::tool_result_content_for_replay); let status = tool_result .as_ref() .map_or(acp::ToolCallStatus::Failed, |result| { if result.is_error { acp::ToolCallStatus::Failed } else { acp::ToolCallStatus::Completed } }); // Recorded tool calls use the model-facing name, so a terminal call is // always keyed as `terminal` and resolves to the non-sandboxed // `TerminalTool` here, even if it originally ran under // `SandboxedTerminalTool`. That's safe because both variants share the // same `replay` behavior; replay only reconstructs UI state and never // re-runs the command or re-applies sandbox policy. 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_call_id.clone(), tool_use.name.to_string()) .status(status) .raw_input(tool_use.input.to_display_json()), ))) .ok(); let mut fields = acp::ToolCallUpdateFields::new() .status(status) .raw_output(output); if let Some(content) = replay_content { fields = fields.content(content); } stream.update_tool_call_fields(&tool_call_id, fields, None); return; }; let Ok(input) = tool_use.input.clone().into_json() else { return; }; let title = tool.initial_title(input.clone(), cx); let kind = tool.kind(); stream.send_tool_call(&tool_call_id, &tool_use.name, title, kind, input.clone()); if let Some(content) = replay_content { stream.update_tool_call_fields( &tool_call_id, acp::ToolCallUpdateFields::new().content(content), None, ); } 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(), tool_call_id.clone(), stream.clone(), Some(self.project.read(cx).fs().clone()), cancellation_rx, self.sandbox_grants.clone(), Some(cx.weak_entity()), ); tool.replay(input, output, tool_event_stream, cx).log_err(); } stream.update_tool_call_fields( &tool_call_id, acp::ToolCallUpdateFields::new() .status(status) .raw_output(output), None, ); } /// A canceled tool result carries only the model-facing `TOOL_CANCELED_MESSAGE` /// sentinel (inserted exactly when a tool had no real result). It's never /// meaningful to the user, so we detect it to skip replaying the tool call. fn is_canceled_tool_result(tool_result: &LanguageModelToolResult) -> bool { tool_result.is_error && matches!( tool_result.content.as_slice(), [LanguageModelToolResultContent::Text(text)] if text.as_ref() == TOOL_CANCELED_MESSAGE ) } fn tool_result_content_for_replay( tool_result: &LanguageModelToolResult, ) -> Option> { let has_image = tool_result .content .iter() .any(|part| matches!(part, LanguageModelToolResultContent::Image(_))); if !has_image && tool_result.output.is_some() { return None; } let content = tool_result .content .iter() .filter_map(|part| match part { LanguageModelToolResultContent::Text(text) => { if text.is_empty() { None } else { Some(acp::ToolCallContent::Content(acp::Content::new( acp::ContentBlock::Text(acp::TextContent::new(text.to_string())), ))) } } LanguageModelToolResultContent::Image(image) => Some( acp::ToolCallContent::Content(acp::Content::new(acp::ContentBlock::Image( acp::ImageContent::new(image.source.clone(), "image/png"), ))), ), }) .collect::>(); if content.is_empty() { None } else { Some(content) } } pub fn from_db( id: acp::SessionId, db_thread: DbThread, project: Entity, project_context: Entity, context_server_registry: Entity, templates: Arc, cx: &mut Context, ) -> Self { let settings = AgentSettings::get_global(cx); let profile_id = db_thread .profile .unwrap_or_else(|| settings.default_profile.clone()); let saved_selection = db_thread.model.map(|model| SelectedModel { provider: model.provider.into(), model: model.model.into(), }); let resolved_saved_model = LanguageModelRegistry::global(cx).update(cx, |registry, cx| { saved_selection .as_ref() .and_then(|selection| registry.select_model(selection, cx)) .map(|configured| configured.model) }); let model = match (resolved_saved_model, saved_selection) { (Some(model), _) => ThreadModel::Ready(model), (None, Some(selection)) => ThreadModel::Unresolved(selection), (None, None) => Self::resolve_profile_model(&profile_id, cx) .or_else(|| { LanguageModelRegistry::global(cx).update(cx, |registry, _cx| { registry.default_model().map(|model| model.model) }) }) .map_or(ThreadModel::Unset, ThreadModel::Ready), }; let (prompt_capabilities_tx, prompt_capabilities_rx) = watch::channel( Self::prompt_capabilities(model.as_model().map(|model| model.as_ref())), ); 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_error: None, pending_summary_generation: None, summary: db_thread.detailed_summary, messages: db_thread.messages, user_store: project.read(cx).user_store(), running_turn: None, end_turn_at_next_boundary: false, pending_message: None, tools: BTreeMap::default(), request_token_usage: db_thread.request_token_usage.clone(), cumulative_token_usage: db_thread.cumulative_token_usage, current_request_token_usage: TokenUsage::default(), pending_compaction_telemetry: None, initial_project_snapshot: Task::ready(db_thread.initial_project_snapshot).shared(), context_server_registry, profile_id, profile_downgraded_for_restricted_workspace: false, 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, 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(), inherits_parent_model_settings: true, sandboxed_terminal_temp_dir: db_thread.sandboxed_terminal_temp_dir, sandbox_grants: Rc::new(RefCell::new(ThreadSandboxGrants::from_db( &db_thread.sandbox_grants, ))), } } pub fn sandbox_status(&self, cx: &App) -> Option<(ThreadSandbox, ThreadSandbox)> { if !self.sandboxing_available(cx) { return None; } let persistent = AgentSettings::get_global(cx).sandbox_permissions.clone(); let git_dirs = sandbox_git_dirs(self.project.read(cx), cx); let grants = self.sandbox_grants.borrow(); let settings = crate::sandboxing::settings_thread_sandbox(&persistent) .with_protected_paths(git_dirs.clone()); let thread = grants.thread_sandbox().with_protected_paths(git_dirs); Some((settings, thread)) } pub fn refresh_verified_sandbox_status( &self, cx: &mut Context, ) -> Option<(SandboxStatusKey, SandboxStatusRefresh)> { if !self.sandboxing_available(cx) { return None; } let persistent = AgentSettings::get_global(cx).sandbox_permissions.clone(); let settings_sandbox = crate::sandboxing::settings_thread_sandbox(&persistent); let grants = self.sandbox_grants.borrow(); let thread_sandbox = grants.thread_sandbox(); drop(grants); let project = self.project.read(cx); let baseline_writable_paths = sandbox_worktree_writable_paths(project, cx); let git_paths = sandbox_git_dirs(project, cx); let key = SandboxStatusKey { settings_sandbox: settings_sandbox.clone(), thread_sandbox: thread_sandbox.clone(), baseline_writable_paths: baseline_writable_paths.clone(), git_paths: git_paths.clone(), }; Some(( key, SandboxStatusRefresh::Ready(VerifiedSandboxStatus { settings_sandbox: settings_sandbox.with_protected_paths(git_paths.clone()), thread_sandbox: thread_sandbox.with_protected_paths(git_paths), baseline_writable_paths, }), )) } /// Whether agent terminal commands are sandboxed for this thread's project, /// so the UI can decide whether to surface the sandbox status at all. pub fn sandboxing_enabled(&self, cx: &App) -> bool { sandboxing_enabled_for_project(self.project.read(cx), cx) } /// Whether sandboxing is *applicable* for this thread's project (feature on, /// local project, supported platform), regardless of whether it's been /// turned off in settings. The UI shows the sandbox indicator whenever this /// is true, drawing it struck-out when sandboxing is disabled. pub fn sandboxing_available(&self, cx: &App) -> bool { sandboxing_available_for_project(self.project.read(cx), cx) } /// The directory subtrees the sandbox always grants write access to for this /// thread's project (its worktree roots), derived from the same source the /// terminal tool uses when it actually builds the sandbox. pub fn sandbox_baseline_writable_paths(&self, cx: &App) -> Vec { crate::sandboxing::sandbox_worktree_writable_paths(self.project.read(cx), cx) } pub fn to_db(&self, cx: &App) -> Task { 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).into(), profile: Some(self.profile_id.clone()), 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(), } }), sandboxed_terminal_temp_dir: self.sandboxed_terminal_temp_dir.clone(), sandbox_grants: self.sandbox_grants.borrow().to_db(), }; 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, cx: &mut Context, ) -> Task> { 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 { &self.project_context } pub fn project(&self) -> &Entity { &self.project } pub fn action_log(&self) -> &Entity { &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>) { self.draft_prompt = prompt; } pub fn ui_scroll_position(&self) -> Option { self.ui_scroll_position } pub fn set_ui_scroll_position(&mut self, position: Option) { self.ui_scroll_position = position; } pub fn model(&self) -> Option<&Arc> { self.model.as_model() } pub fn thread_model(&self) -> &ThreadModel { &self.model } pub(crate) fn ensure_model( &mut self, default_model: Option<&Arc>, cx: &mut Context, ) { let resolved = match &self.model { ThreadModel::Ready(_) => return, ThreadModel::Unresolved(selection) => { LanguageModelRegistry::global(cx).update(cx, |registry, cx| { registry .select_model(selection, cx) .map(|configured| configured.model) }) } ThreadModel::Unset => default_model.cloned(), }; if let Some(model) = resolved { self.set_model(model, cx); } } pub fn set_model(&mut self, model: Arc, cx: &mut Context) { let old_usage = self.latest_token_usage(); self.model = ThreadModel::Ready(model.clone()); let new_caps = Self::prompt_capabilities(self.model.as_model().map(|model| model.as_ref())); 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(); cx.emit(ModelChanged); for subagent in &self.running_subagents { subagent .update(cx, |thread, cx| { if thread.inherits_parent_model_settings { thread.set_model(model.clone(), cx); } }) .ok(); } cx.notify() } pub fn summarization_model(&self) -> Option<&Arc> { self.summarization_model.as_ref() } pub fn set_summarization_model( &mut self, model: Option>, cx: &mut Context, ) { 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.thinking_enabled = enabled; for subagent in &self.running_subagents { subagent .update(cx, |thread, cx| { if thread.inherits_parent_model_settings { 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, cx: &mut Context) { self.thinking_effort = effort.clone(); for subagent in &self.running_subagents { subagent .update(cx, |thread, cx| { if thread.inherits_parent_model_settings { thread.set_thinking_effort(effort.clone(), cx) } }) .ok(); } cx.notify(); } pub fn speed(&self) -> Option { self.speed } pub fn set_speed(&mut self, speed: Speed, cx: &mut Context) { self.speed = Some(speed); for subagent in &self.running_subagents { subagent .update(cx, |thread, cx| { if thread.inherits_parent_model_settings { thread.set_speed(speed, cx); } }) .ok(); } cx.notify(); } pub fn last_message(&self) -> Option<&Message> { self.messages.last().map(std::ops::Deref::deref) } #[cfg(any(test, feature = "test-support"))] pub fn last_received_or_pending_message(&self) -> Option> { if let Some(message) = self.pending_message.clone() { Some(Arc::new(Message::Agent(message))) } else { self.messages.last().cloned() } } pub fn add_default_tools( &mut self, environment: Rc, cx: &mut Context, ) { // 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(ReadFileTool::new( self.project.clone(), self.action_log.clone(), update_agent_location, )); // Register terminal tool variants; `enabled_tools` exposes the one // matching the current sandbox state to the model as `terminal`. self.add_tool(TerminalTool::new(self.project.clone(), environment.clone())); self.add_tool(SandboxedTerminalTool::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.clone())); } // Sibling-thread tools are exposed at every depth: a subagent should // still be able to kick off independent sibling work on behalf of the // user, even when it can no longer nest further subagents. Visibility // to the model is gated by `CreateThreadToolFeatureFlag` in // `Thread::enabled_tools`. self.add_tool(CreateThreadTool::new(environment.clone())); self.add_tool(ListAgentsAndModelsTool::new(environment)); } pub fn add_tool(&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 } /// Whether this thread's profile was downgraded to `minimal` at thread start /// because the workspace is restricted. pub fn profile_was_downgraded(&self) -> bool { self.profile_downgraded_for_restricted_workspace } /// Computes the profile a thread should start with, given the user's chosen /// profile. In a restricted workspace, the built-in `write`/`ask` profiles /// are downgraded to `minimal` — but only when both the chosen profile and /// `minimal` are unmodified, shipped defaults, so we never override a user's /// custom or customized profiles. /// /// Returns the (possibly downgraded) profile and whether a downgrade /// happened. fn profile_for_restricted_workspace( profile_id: AgentProfileId, project: &Entity, cx: &App, ) -> (AgentProfileId, bool) { let is_write_or_ask = profile_id.as_str() == builtin_profiles::WRITE || profile_id.as_str() == builtin_profiles::ASK; let minimal = AgentProfileId(builtin_profiles::MINIMAL.into()); if is_write_or_ask && TrustedWorktrees::has_restricted_worktrees(&project.read(cx).worktree_store(), cx) && AgentProfileSettings::is_unmodified_default(&profile_id, cx) && AgentProfileSettings::is_unmodified_default(&minimal, cx) { (minimal, true) } else { (profile_id, false) } } pub fn set_profile(&mut self, profile_id: AgentProfileId, cx: &mut Context) { // An explicit selection means any earlier automatic downgrade no longer // applies, even if the user re-selects the same profile. self.profile_downgraded_for_restricted_workspace = false; 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) -> 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_end_turn_at_next_boundary(&mut self, end_at_boundary: bool) { self.end_turn_at_next_boundary = end_at_boundary; } pub fn end_turn_at_next_boundary(&self) -> bool { self.end_turn_at_next_boundary } fn accumulate_token_usage(&mut self, update: language_model::TokenUsage) { let previous_accounted_usage = self.current_request_token_usage; let current_accounted_usage = TokenUsage { input_tokens: previous_accounted_usage .input_tokens .max(update.input_tokens), output_tokens: previous_accounted_usage .output_tokens .max(update.output_tokens), cache_creation_input_tokens: previous_accounted_usage .cache_creation_input_tokens .max(update.cache_creation_input_tokens), cache_read_input_tokens: previous_accounted_usage .cache_read_input_tokens .max(update.cache_read_input_tokens), }; self.current_request_token_usage = current_accounted_usage; self.cumulative_token_usage = self.cumulative_token_usage + TokenUsage { input_tokens: current_accounted_usage .input_tokens .saturating_sub(previous_accounted_usage.input_tokens), output_tokens: current_accounted_usage .output_tokens .saturating_sub(previous_accounted_usage.output_tokens), cache_creation_input_tokens: current_accounted_usage .cache_creation_input_tokens .saturating_sub(previous_accounted_usage.cache_creation_input_tokens), cache_read_input_tokens: current_accounted_usage .cache_read_input_tokens .saturating_sub(previous_accounted_usage.cache_read_input_tokens), }; } fn update_token_usage(&mut self, update: language_model::TokenUsage, cx: &mut Context) { self.accumulate_token_usage(update); 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(); } /// Records that the last request overflowed the model's context window so /// the token usage indicator reports `Exceeded` instead of the stale usage /// from the last successful request. Providers don't report usage for /// failed requests, so we synthesize one from the reported overflow (when /// available) or the model's context size. fn mark_token_limit_exceeded(&mut self, tokens: Option, cx: &mut Context) { let Some(model) = self.model() else { return; }; let input_tokens = tokens.unwrap_or(0).max(model.max_token_count()); let Some(last_user_message) = self.last_user_message() else { return; }; self.request_token_usage.insert( last_user_message.id.clone(), language_model::TokenUsage { input_tokens, ..Default::default() }, ); cx.emit(TokenUsageUpdated(self.latest_token_usage())); cx.notify(); } pub fn truncate( &mut self, client_user_message_id: ClientUserMessageId, cx: &mut Context, ) -> 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 == &client_user_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 | Message::Compaction(_) => {} } } self.clear_summary(); cx.notify(); Ok(()) } pub fn latest_request_token_usage(&self) -> Option { let last_user_message = self.last_user_message()?; let tokens = self.request_token_usage.get(&last_user_message.id)?; Some(*tokens) } pub fn cumulative_token_usage(&self) -> language_model::TokenUsage { self.cumulative_token_usage } pub fn latest_token_usage(&self) -> Option { let usage = self.latest_request_token_usage()?; let model = self.model()?; let input_tokens = total_input_tokens(usage); Some(acp_thread::TokenUsage { max_tokens: model.max_token_count(), max_output_tokens: model.max_output_tokens(), used_tokens: usage.total_tokens(), 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: &ClientUserMessageId) -> Option { let mut previous_user_message_id: Option<&ClientUserMessageId> = 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(total_input_tokens(*usage)); } 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, ) -> Option> { let selection = AgentSettings::get_global(cx) .profiles .get(profile_id)? .default_model .clone()?; Self::resolve_model_from_selection(&selection, cx) } fn user_configured_model_selection(cx: &App) -> Option { let selection = SettingsStore::global(cx) .raw_user_settings()? .content .agent .as_ref()? .default_model .as_ref()?; Some(SelectedModel { provider: LanguageModelProviderId::from(selection.provider.0.clone()), model: LanguageModelId::from(selection.model.clone()), }) } /// Translate a stored model selection into the configured model from the registry. fn resolve_model_from_selection( selection: &LanguageModelSelection, cx: &mut Context, ) -> Option> { 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, ) -> Result>> { self.messages.push(Arc::new(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( &mut self, id: ClientUserMessageId, content: impl IntoIterator, cx: &mut Context, ) -> Result>> where T: Into, { let content = content.into_iter().map(Into::into).collect::>(); log::debug!("Thread::send content: {:?}", content); self.messages .push(Arc::new(Message::User(UserMessage { id, content }))); cx.notify(); self.send_existing(cx) } pub fn send_existing( &mut self, cx: &mut Context, ) -> Result>> { 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) } /// Force a manual context compaction using the summary strategy, /// regardless of the current token usage or context window size. pub fn compact( &mut self, id: ClientUserMessageId, cx: &mut Context, ) -> Result>> { let model = self .compaction_model(cx) .ok_or_else(|| anyhow!(NoModelConfiguredError))?; // Flush any pending message and cancel an in-flight turn before we // start, mirroring `run_turn` so a stray completion can't race with the // compaction we're about to perform. self.flush_pending_message(cx); self.cancel(cx).detach(); let compaction = self.forced_compaction_target_ix().map(|request_end_ix| { self.advance_prompt_id(); let request = self.build_compaction_request(request_end_ix, &model, cx); self.current_request_token_usage = TokenUsage::default(); (model.clone(), request) }); if compaction.is_some() { self.pending_compaction_telemetry = self.build_compaction_telemetry("manual", &model, cx); } self.clear_summary(); cx.notify(); let (events_tx, events_rx) = mpsc::unbounded::>(); let event_stream = ThreadEventStream(events_tx); let (cancellation_tx, mut cancellation_rx) = watch::channel(false); let task = cx.spawn({ let event_stream = event_stream.clone(); async move |this, cx| { let result = if let Some((model, request)) = compaction { Self::stream_compaction( &this, &event_stream, cancellation_rx.clone(), model, request, CompactionInsertion::Manual { marker_id: id }, cx, ) .await } else { Ok(ControlFlow::Continue(())) }; // If we were cancelled, `cancel()` already took `running_turn` // (possibly for a new turn), so leave it alone. if *cancellation_rx.borrow() { this.update(cx, |this, _| { this.emit_compaction_telemetry_outcome("canceled", None) }) .log_err(); return; } match result { // On success, the telemetry event is deferred until the next // completion reports usage (see `handle_completion_event`), // so we leave `pending_compaction_telemetry` in place here. Ok(_) => event_stream.send_stop(acp::StopReason::EndTurn), Err(error) => { log::error!("Manual compaction failed: {:?}", error); this.update(cx, |this, _| { this.emit_compaction_telemetry_outcome( "failed", Some(error.to_string()), ) }) .log_err(); event_stream.send_error(error); } } _ = this.update(cx, |this, _| this.running_turn.take()); } }); self.running_turn = Some(RunningTurn::new( event_stream, BTreeMap::default(), cancellation_tx, task, )); Ok(events_rx) } pub fn push_acp_user_block( &mut self, id: ClientUserMessageId, blocks: impl IntoIterator, path_style: PathStyle, cx: &mut Context, ) { let content = blocks .into_iter() .map(|block| UserMessageContent::from_content_block(block, path_style)) .collect::>(); self.messages .push(Arc::new(Message::User(UserMessage { id, content }))); cx.notify(); } pub fn push_acp_agent_block(&mut self, block: acp::ContentBlock, cx: &mut Context) { 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(Arc::new(Message::Agent(AgentMessage { content: vec![AgentMessageContent::Text(text)], ..Default::default() }))); cx.notify(); } fn run_turn( &mut self, cx: &mut Context, ) -> Result>> { // 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::>(); let event_stream = ThreadEventStream(events_tx); let message_ix = self.messages.len().saturating_sub(1); self.clear_summary(); let tools = self.enabled_tools(cx); let (cancellation_tx, mut cancellation_rx) = watch::channel(false); let task = cx.spawn({ let event_stream = event_stream.clone(); 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::() { 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()); } }); self.running_turn = Some(RunningTurn::new(event_stream, tools, cancellation_tx, task)); Ok(events_rx) } async fn run_turn_internal( this: &WeakEntity, event_stream: &ThreadEventStream, mut cancellation_rx: watch::Receiver, cx: &mut AsyncApp, ) -> Result<()> { let mut attempt = 0; let mut intent = CompletionIntent::UserPrompt; // Set when a refusal fallback occurs so subsequent iterations use the fallback model. let mut refusal_fallback_model: Option> = None; loop { match Self::perform_compaction_if_needed( this, event_stream, cancellation_rx.clone(), cx, ) .await { // On success the telemetry event is deferred until the // completion below reports usage, so we can record an // accurate post-compaction context size (see // `handle_completion_event`). Ok(ControlFlow::Continue(())) => {} Ok(ControlFlow::Break(())) => { this.update(cx, |this, _| { this.emit_compaction_telemetry_outcome("canceled", None) })?; return Ok(()); } Err(error) => { log::error!("Compaction failed: {}", error); let error_message = error.to_string(); match error.downcast::() { Ok(error) => { attempt += 1; match Self::retry_completion_error( this, event_stream, &mut cancellation_rx, error, attempt, cx, ) .await { Ok(ControlFlow::Break(())) => { this.update(cx, |this, _| { this.emit_compaction_telemetry_outcome("canceled", None) })?; return Ok(()); } Ok(ControlFlow::Continue(())) => { this.update(cx, |this, _| { if let Some(telemetry) = this.pending_compaction_telemetry.as_mut() { telemetry.retries += 1; } })?; continue; } Err(retry_error) => { this.update(cx, |this, _| { this.emit_compaction_telemetry_outcome( "failed", Some(error_message), ) })?; return Err(retry_error); } } } Err(error) => { this.update(cx, |this, _| { this.emit_compaction_telemetry_outcome( "failed", Some(error_message), ) })?; return Err(error); } } } } // 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. // If a refusal fallback is active, use that model instead. let (model, request) = this.update(cx, |this, cx| { let model = refusal_fallback_model .clone() .or_else(|| this.model().cloned()) .ok_or_else(|| anyhow!(NoModelConfiguredError))?; this.refresh_turn_tools(cx); let request = this.build_completion_request(intent, cx)?; this.current_request_token_usage = TokenUsage::default(); 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> = FuturesUnordered::new(); let mut early_tool_results: Vec<(usize, LanguageModelToolResult)> = Vec::new(); let mut cancelled = false; let mut had_refusal = 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 (owning_message_ix, tool_result) = tool_result; 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((owning_message_ix, 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 { let is_refusal = err .downcast_ref::() .is_some_and(|e| matches!(e, CompletionError::Refusal)); if is_refusal { log::info!("Model refused request; checking for fallback model"); had_refusal = true; break; } 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(); } })?; if had_refusal { let maybe_fallback = this.update(cx, |this, cx| -> Option> { let current_model = refusal_fallback_model.as_ref().or(this.model())?; let fallback_id = match current_model.refusal_fallback_model_id() { Some(id) => id, None => { log::info!( "Refusal fallback: no fallback configured for model {} (provider {})", current_model.id().0, current_model.provider_id() ); return None; } }; let provider_id = current_model.provider_id(); let found = LanguageModelRegistry::global(cx) .read(cx) .available_models(cx) .find(|m| { m.provider_id() == provider_id && m.id().0.as_ref() == fallback_id }); if found.is_none() { log::info!( "Refusal fallback: fallback model {}/{} not found in available models", provider_id, fallback_id ); } found })?; if let Some(fallback) = maybe_fallback { log::info!("Refusal fallback: retrying with {}", fallback.id().0); let fallback_name = fallback.name().0.clone(); this.update(cx, |this, cx| { this.pending_message = None; this.set_model(fallback.clone(), cx); })?; event_stream.send_retry(acp_thread::RetryStatus { last_error: "Safety filter triggered".into(), attempt: 1, max_attempts: 1, started_at: Instant::now(), duration: Duration::MAX, meta: Some(acp_thread::meta_with_refusal_fallback(&fallback_name)), }); refusal_fallback_model = Some(fallback); continue; } log::info!("Request refused with no fallback model available"); return Err(CompletionError::Refusal.into()); } let end_turn = tool_results.is_empty() && early_tool_results.is_empty(); for (owning_message_ix, tool_result) in early_tool_results { Self::process_tool_result(this, event_stream, cx, owning_message_ix, tool_result)?; } while let Some((owning_message_ix, tool_result)) = tool_results.next().await { Self::process_tool_result(this, event_stream, cx, owning_message_ix, tool_result)?; } this.update(cx, |this, cx| { this.flush_pending_message(cx); if this.title.is_none() { this.generate_title(cx); } })?; if cancelled { log::debug!("Turn cancelled by user, exiting"); return Ok(()); } if let Some(error) = error { attempt += 1; match Self::retry_completion_error( this, event_stream, &mut cancellation_rx, error, attempt, cx, ) .await? { ControlFlow::Break(_) => return Ok(()), ControlFlow::Continue(_) => {} } this.update(cx, |this, _cx| { if let Some(Message::Agent(message)) = this.last_message() { if message.tool_results.is_empty() { intent = CompletionIntent::UserPrompt; this.messages.push(Arc::new(Message::Resume)); } } })?; } else if end_turn { return Ok(()); } else { let end_at_boundary = this.update(cx, |this, _| this.end_turn_at_next_boundary())?; if end_at_boundary { log::debug!("Steering message queued, ending turn at message boundary"); return Ok(()); } intent = CompletionIntent::ToolResults; attempt = 0; } } } /// Computes the retry status for a failed completion, notifies listeners, /// and waits out the backoff delay (or returns early if the turn is /// cancelled while waiting). Returns an error if the completion is not /// retryable or retries are exhausted. async fn retry_completion_error( this: &WeakEntity, event_stream: &ThreadEventStream, cancellation_rx: &mut watch::Receiver, error: LanguageModelCompletionError, attempt: u8, cx: &mut AsyncApp, ) -> Result> { let retry = this.update(cx, |this, cx| { let user_store = this.user_store.read(cx); this.handle_completion_error(error, attempt, user_store.plan(), cx) })??; 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(ControlFlow::Break(())); } } } Ok(ControlFlow::Continue(())) } async fn perform_compaction_if_needed( this: &WeakEntity, event_stream: &ThreadEventStream, cancellation_rx: watch::Receiver, cx: &mut AsyncApp, ) -> Result> { let Some((model, request, insertion_ix)) = this.update(cx, |this, cx| { let insertion_ix = this.compaction_message_target_ix(cx)?; let model = this.compaction_model(cx)?; let request = this.build_compaction_request(insertion_ix, &model, cx); this.current_request_token_usage = TokenUsage::default(); // Preserve telemetry across retries so the retry count keeps // accumulating rather than resetting on each attempt. if this.pending_compaction_telemetry.is_none() { this.pending_compaction_telemetry = this.build_compaction_telemetry("auto", &model, cx); } Some((model, request, insertion_ix)) })? else { return Ok(ControlFlow::Continue(())); }; Self::stream_compaction( this, event_stream, cancellation_rx, model, request, CompactionInsertion::Auto { insertion_ix }, cx, ) .await } async fn stream_compaction( this: &WeakEntity, event_stream: &ThreadEventStream, mut cancellation_rx: watch::Receiver, model: Arc, request: LanguageModelRequest, insertion: CompactionInsertion, cx: &mut AsyncApp, ) -> Result> { log::debug!("Running compaction"); let compaction_id = acp_thread::ContextCompactionId(Uuid::new_v4().to_string().into()); event_stream.send_context_compaction( compaction_id.clone(), acp_thread::ContextCompactionStatus::InProgress, ); let stream = futures::select! { result = model.stream_completion(request, cx).fuse() => result, _ = cancellation_rx.changed().fuse() => { if *cancellation_rx.borrow() { log::debug!("Compaction cancelled before request started"); return Ok(ControlFlow::Break(())); } return Ok(ControlFlow::Continue(())); } }; let mut stream = stream?; let mut summary = String::new(); loop { let event = futures::select! { event = stream.next().fuse() => event, _ = cancellation_rx.changed().fuse() => { if *cancellation_rx.borrow() { log::debug!("Compaction cancelled while summarizing"); return Ok(ControlFlow::Break(())); } continue; } }; let Some(event) = event else { break; }; match event? { LanguageModelCompletionEvent::Text(text) => { summary.push_str(&text); event_stream.send_context_compaction_update(compaction_id.clone(), &text); } LanguageModelCompletionEvent::UsageUpdate(usage) => { this.update(cx, |this, _cx| { this.accumulate_token_usage(usage); })?; } LanguageModelCompletionEvent::Stop(_) | LanguageModelCompletionEvent::Started | LanguageModelCompletionEvent::Queued { .. } | LanguageModelCompletionEvent::Thinking { .. } | LanguageModelCompletionEvent::RedactedThinking { .. } | LanguageModelCompletionEvent::ReasoningDetails(_) | LanguageModelCompletionEvent::ToolUse(_) | LanguageModelCompletionEvent::ToolUseJsonParseError { .. } | LanguageModelCompletionEvent::StartMessage { .. } | LanguageModelCompletionEvent::Compaction(_) => {} } } if *cancellation_rx.borrow() { log::debug!("Compaction cancelled after summarizing"); return Ok(ControlFlow::Break(())); } let summary = summary.trim().to_string(); if summary.is_empty() { log::warn!("Compaction produced an empty summary"); return Err(anyhow::anyhow!("Compaction produced an empty summary")); } log::debug!("Compaction succeeded:\n{summary}"); event_stream.update_context_compaction_status( compaction_id, acp_thread::ContextCompactionStatus::Completed, ); this.update(cx, |this, cx| { let compaction = Arc::new(Message::Compaction(CompactionInfo::Summary(summary.into()))); match insertion { CompactionInsertion::Auto { insertion_ix } => { if insertion_ix <= this.messages.len() { this.messages.insert(insertion_ix, compaction); } else { this.messages.push(compaction); } } CompactionInsertion::Manual { marker_id } => { this.messages.push(Arc::new(Message::User(UserMessage { id: marker_id, content: Arc::from([]), }))); this.messages.push(compaction); } } cx.notify(); })?; Ok(ControlFlow::Continue(())) } fn process_tool_result( this: &WeakEntity, event_stream: &ThreadEventStream, cx: &mut AsyncApp, owning_message_ix: usize, tool_result: LanguageModelToolResult, ) -> Result<(), anyhow::Error> { log::debug!("Tool finished {:?}", tool_result); event_stream.update_tool_call_fields( &scoped_tool_call_id(owning_message_ix, &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, cx: &mut Context, ) -> Result { if let LanguageModelCompletionError::PromptTooLarge { tokens } = &error { self.mark_token_limit_exceeded(*tokens, cx); } let Some(model) = self.model() 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, meta: None, }) } /// 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, cx: &mut Context, ) -> Result>> { 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(arr) = &details { if !arr.is_empty() { last_message.reasoning_details = Some(Arc::new(details)); } } else { last_message.reasoning_details = Some(Arc::new(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().map(|m| m.telemetry_id()), model_provider = self.model().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, ); // A successful compaction defers its telemetry until the first // completion that follows it, so `tokens_after` reflects the // real post-compaction context size. if let Some(telemetry) = self.pending_compaction_telemetry.take() { telemetry.emit("succeeded", None, Some(total_input_tokens(usage))); } 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 { .. } | Compaction(_) => {} } 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, 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, cx: &mut Context, ) -> Option> { cx.notify(); let owning_message_ix = self.messages.len(); 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() { if let Ok(input) = tool_use.input.clone().into_json() { title = tool.initial_title(input, cx); } kind = tool.kind(); } self.send_or_update_tool_use(&tool_use, title, kind, owning_message_ix, event_stream); let Some(tool) = tool else { let content = format!("No tool named {} exists", tool_use.name); return Some(Task::ready(( owning_message_ix, LanguageModelToolResult { content: vec![LanguageModelToolResultContent::Text(Arc::from(content))], tool_use_id: tool_use.id, tool_name: tool_use.name, is_error: true, output: None, }, ))); }; // Agent tools are JSON-schema tools. Custom text-tool deltas are rejected // before considering partial-vs-complete input for these local tools. let input = match tool_use.input.clone().into_json() { Ok(input) => input, Err(error) => { return Some(Task::ready(( owning_message_ix, LanguageModelToolResult { content: vec![LanguageModelToolResultContent::Text(Arc::from( error.to_string(), ))], 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(input); return None; } let (mut sender, tool_input) = ToolInputSender::channel(); sender.send_partial(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, owning_message_ix, 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(input); return None; } log::debug!("Running tool {}", tool_use.name); let tool_input = ToolInput::ready(input); Some(self.run_tool( tool, tool_input, tool_use.id, tool_use.name, owning_message_ix, event_stream, cancellation_rx, cx, )) } fn run_tool( &self, tool: Arc, tool_input: ToolInput, tool_use_id: LanguageModelToolUseId, tool_name: Arc, owning_message_ix: usize, event_stream: &ThreadEventStream, cancellation_rx: watch::Receiver, cx: &mut Context, ) -> Task<(usize, LanguageModelToolResult)> { // A workspace can become restricted after a thread has already started. // Tools that aren't allowed in restricted workspaces must never run in // that state, even though they were exposed to the model earlier. if !tool.allow_in_restricted_mode() && TrustedWorktrees::has_restricted_worktrees( &self.project.read(cx).worktree_store(), cx, ) { return Task::ready(( owning_message_ix, LanguageModelToolResult { tool_use_id, tool_name, is_error: true, content: vec![LanguageModelToolResultContent::Text(Arc::from( "workspace has become restricted", ))], output: None, }, )); } let fs = self.project.read(cx).fs().clone(); let tool_call_id = scoped_tool_call_id(owning_message_ix, &tool_use_id); let tool_event_stream = ToolCallEventStream::new( tool_use_id.clone(), tool_call_id, event_stream.clone(), Some(fs), cancellation_rx, self.sandbox_grants.clone(), Some(cx.weak_entity()), ); 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), }; ( owning_message_ix, 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, raw_input: Arc, json_parse_error: String, event_stream: &ThreadEventStream, cancellation_rx: watch::Receiver, cx: &mut Context, ) -> Option> { let owning_message_ix = self.messages.len(); let tool_use = LanguageModelToolUse { id: tool_use_id, name: tool_name, raw_input: raw_input.to_string(), input: language_model::LanguageModelToolUseInput::Json(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, owning_message_ix, 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(( owning_message_ix, 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, owning_message_ix, event_stream, cancellation_rx, cx, )) } fn send_or_update_tool_use( &mut self, tool_use: &LanguageModelToolUse, title: SharedString, kind: acp::ToolKind, owning_message_ix: usize, event_stream: &ThreadEventStream, ) { let tool_call_id = scoped_tool_call_id(owning_message_ix, &tool_use.id); // 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_call_id, &tool_use.name, title, kind, tool_use.input.to_display_json(), ); last_message .content .push(AgentMessageContent::ToolUse(tool_use.clone())); } else { event_stream.update_tool_call_fields( &tool_call_id, acp::ToolCallUpdateFields::new() .title(title.as_str()) .kind(kind) .raw_input(tool_use.input.to_display_json()), None, ); } } pub fn title(&self) -> Option { 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_error.is_some() } pub fn title_generation_error(&self) -> Option { self.title_generation_error.clone() } pub fn can_generate_title(&self) -> bool { self.pending_title_generation.is_none() && self.summarization_model.is_some() } pub fn summary(&mut self, cx: &mut Context) -> Shared>> { 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() }; self.extend_request_history_until(&mut request.messages, self.messages.len()); 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) { if !self.can_generate_title() { return; } let Some(model) = self.summarization_model.clone() else { return; }; self.spawn_title_generation(model, None, cx); } pub fn regenerate_title(&mut self, cx: &mut Context) -> bool { self.regenerate_title_with_callback(cx, |_title, _cx| {}) } pub fn regenerate_title_with_callback( &mut self, cx: &mut Context, on_generated_title: impl FnOnce(SharedString, &mut Context) + 'static, ) -> bool { if self.pending_title_generation.is_some() { return false; } let Some(model) = self.summarization_model.clone() else { return false; }; self.spawn_title_generation(model, Some(Box::new(on_generated_title)), cx); true } fn spawn_title_generation( &mut self, model: Arc, on_generated_title: Option)>>, cx: &mut Context, ) { self.title_generation_error = None; log::debug!("Generating title with model: {:?}", model.name()); let temperature = AgentSettings::temperature_for_model(&model, cx); let request = build_thread_title_request(&self.messages, temperature); let title_generation = cx.spawn(async move |_this, cx| { stream_thread_title(model, request, cx) .await .context("failed to generate thread title") .map(SharedString::from) }); self.pending_title_generation = Some(cx.spawn(async move |this, cx| { let title = title_generation.await; _ = this.update(cx, |this, cx| { this.pending_title_generation = None; match title { Ok(title) => { this.set_title(title.clone(), cx); if let Some(on_generated_title) = on_generated_title { on_generated_title(title, cx); } } Err(error) => { let error = format!("{error:#}"); log::error!("{error}"); this.title_generation_error = Some(error.into()); cx.emit(TitleUpdated); cx.notify(); } } }); })); cx.notify(); } pub fn set_title(&mut self, title: SharedString, cx: &mut Context) { self.pending_title_generation = None; self.title_generation_error = None; 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(_) | Message::Resume | Message::Compaction(_) => None, }) } fn pending_message(&mut self) -> &mut AgentMessage { self.pending_message.get_or_insert_default() } fn flush_pending_message(&mut self, cx: &mut Context) { 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(Arc::new(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 { 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 sandboxing_enabled = crate::sandboxing::sandboxing_enabled(cx); 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); let mut description = tool.description().to_string(); let mut schema = tool.input_schema(model.tool_input_format()).log_err()?; // TEMPORARY (sandboxing feature flag): with the flag off, // the fetch and create_directory descriptions/schemas must // not advertise sandbox-dependent behavior (host grants, // out-of-project creation via the `reason` field), since // the corresponding runtime paths are disabled. Restore // the pre-sandboxing model-facing surface here rather than // forking the tools; delete this when the flag is removed // again. if !sandboxing_enabled { if tool_name.as_ref() == FetchTool::NAME { description = "Fetches a URL and returns the content as Markdown.".to_string(); } else if tool_name.as_ref() == CreateDirectoryTool::NAME { description = "Creates a new directory at the specified path within \ the project. Returns confirmation that the directory was \ created.\n\nThis tool creates a directory and all necessary \ parent directories. It should be used whenever you need to \ create new directories within the project.\nThe only supported \ path outside the project is `~/.agents/skills` or a descendant, \ for global agent skills." .to_string(); if let Some(properties) = schema .get_mut("properties") .and_then(|value| value.as_object_mut()) { properties.remove("reason"); } } } Some(LanguageModelRequestTool::function( tool_name.to_string(), description, schema, tool.supports_input_streaming(), )) }) .collect::>() } 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), // Models that can't run with thinking disabled ignore the // toggle state, which may be stale from a previously selected // model that could. thinking_allowed: self.thinking_enabled || !model.supports_disabling_thinking(), thinking_effort: self.thinking_effort.clone(), speed: self.speed(), compact_at_tokens: None, }; log::debug!("Completion request built successfully"); Ok(request) } fn enabled_tools(&self, cx: &App) -> BTreeMap> { let Some(model) = self.model() else { return BTreeMap::new(); }; let Some(profile) = AgentSettings::get_global(cx).profiles.get(&self.profile_id) else { return BTreeMap::new(); }; // Terminal variants are configured by users under the canonical // `terminal` name. Expose the one matching the current sandbox state // to the model under that name. let use_sandboxed_terminal = sandboxing_enabled_for_project(self.project.read(cx), cx); // Tools that aren't allowed in restricted workspaces must never be // provided to the model while the workspace is restricted, regardless // of what the active profile enables. let is_restricted = TrustedWorktrees::has_restricted_worktrees(&self.project.read(cx).worktree_store(), cx); let mut tools = self .tools .iter() .filter(|(_, tool)| !is_restricted || tool.allow_in_restricted_mode()) .filter_map(|(tool_name, tool)| { let terminal_variant = matches!( tool_name.as_ref(), TerminalTool::NAME | SandboxedTerminalTool::NAME ); let profile_tool_name = if terminal_variant { TerminalTool::NAME } else { tool_name.as_ref() }; if tool.supports_provider(&model.provider_id()) && profile.is_tool_enabled(profile_tool_name) { match (tool_name.as_ref(), use_sandboxed_terminal) { (TerminalTool::NAME, false) | (SandboxedTerminalTool::NAME, true) => { Some((SharedString::from(TerminalTool::NAME), tool.clone())) } (TerminalTool::NAME | SandboxedTerminalTool::NAME, _) => None, _ => Some(( provider_compatible_tool_name(tool_name.as_ref()).into(), tool.clone(), )), } } else { None } }) .filter(|(tool_name, _)| crate::tools::tool_feature_flag_enabled(tool_name, cx)) .collect::>(); let mut context_server_tools = Vec::new(); let mut seen_tools = tools.keys().cloned().collect::>(); 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: SharedString = provider_compatible_tool_name(tool_name.as_ref()).into(); 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 = provider_compatible_tool_name(&server_id.0.to_snake_case()).to_string(); 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> { 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) { 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 { 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 { 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, cx: &App, ) -> Vec { let mut messages = self.build_request_messages_until(available_tools, self.messages.len(), cx); if let Some(message) = self.pending_message.as_ref() { messages.extend(message.to_request()); } messages } fn build_request_messages_until( &self, available_tools: Vec, end_ix: usize, cx: &App, ) -> Vec { let end_ix = end_ix.min(self.messages.len()); log::trace!("Building request messages from {} thread messages", end_ix); let user_agents_md = UserAgentsMd::global(cx).and_then(|s| s.content().cloned()); let system_prompt = SystemPromptTemplate { project: self.project_context.read(cx), available_tools, model_name: self.model().map(|m| m.name().0.to_string()), date: Local::now().format("%Y-%m-%d").to_string(), user_agents_md, sandboxing: crate::sandboxing::sandboxing_enabled_for_project( self.project.read(cx), cx, ), is_linux: cfg!(target_os = "linux"), is_windows: cfg!(target_os = "windows"), } .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, }]; self.extend_request_history_until(&mut messages, end_ix); if let Some(last_message) = messages.last_mut() { last_message.cache = true; } messages } fn extend_request_history_until( &self, request_messages: &mut Vec, end_ix: usize, ) { extend_request_history_until(&self.messages, request_messages, end_ix); } fn build_compaction_telemetry( &self, trigger: &'static str, compaction_model: &Arc, cx: &App, ) -> Option { let model = self.model()?; let auto_compact = AgentSettings::get_global(cx).auto_compact; let max_tokens = model.max_token_count(); let max_input_tokens = max_tokens.saturating_sub(model.max_output_tokens().unwrap_or(0)); let tokens_before = self .latest_request_token_usage() .map(|usage| total_input_tokens(usage).saturating_add(usage.output_tokens)); Some(CompactionTelemetry { trigger, 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: model.telemetry_id(), compaction_model: compaction_model.telemetry_id(), thinking_effort: self.thinking_effort.clone(), max_tokens, tokens_before, auto_compact_enabled: auto_compact.enabled, auto_compact_threshold: auto_compact.threshold.to_string(), auto_compact_threshold_tokens: auto_compact_threshold_token_count( auto_compact.threshold, max_input_tokens, ), retries: 0, }) } /// Emits a pending compaction telemetry event for a non-success outcome /// (`"failed"` or `"canceled"`), with no post-compaction token count. A /// no-op if no compaction telemetry is pending. fn emit_compaction_telemetry_outcome(&mut self, status: &'static str, error: Option) { if let Some(telemetry) = self.pending_compaction_telemetry.take() { telemetry.emit(status, error, None); } } fn compaction_message_target_ix(&self, cx: &App) -> Option { let auto_compact = AgentSettings::get_global(cx).auto_compact; if !auto_compact.enabled { return None; } let model = self.model()?; let max_token_count = model.max_token_count(); let max_input_tokens = max_token_count.saturating_sub(model.max_output_tokens().unwrap_or(0)); // Models with a small context window don't leave enough headroom for a // compaction pass; the UI warns the user about the token limit instead. if max_input_tokens < MIN_COMPACTION_CONTEXT_WINDOW { return None; } let (usage_ix, usage) = { let this = &self; this.messages .iter() .enumerate() .rev() .find_map(|(ix, message)| { let Message::User(user_message) = &**message else { return None; }; this.request_token_usage .get(&user_message.id) .copied() .map(|usage| (ix, usage)) }) }?; if latest_compaction_message_ix_before(&self.messages, self.messages.len()) .is_some_and(|compaction_ix| compaction_ix > usage_ix) { return None; } let active_tokens = total_input_tokens(usage).saturating_add(usage.output_tokens); let compaction_threshold = auto_compact_threshold_token_count(auto_compact.threshold, max_input_tokens); if active_tokens < compaction_threshold { return None; } let insertion_ix = match self.messages.last() { Some(message) if matches!( &**message, Message::User(UserMessage { id, .. }) if !self.request_token_usage.contains_key(id) ) => { self.messages.len().saturating_sub(1) } _ => self.messages.len(), }; Some(insertion_ix) } /// Insertion point for a manually-triggered compaction. /// Returns `None` only when there is nothing to summarize (no messages, or the thread already ends in a compaction). fn forced_compaction_target_ix(&self) -> Option { if matches!( self.messages.last().map(|message| &**message), None | Some(Message::Compaction(_)) ) { return None; } Some(self.messages.len()) } fn compaction_model(&self, cx: &App) -> Option> { LanguageModelRegistry::read_global(cx) .compaction_model() .map(|m| m.model) .or_else(|| self.model().cloned()) } fn build_compaction_request( &self, insertion_ix: usize, model: &Arc, cx: &App, ) -> LanguageModelRequest { let mut request = LanguageModelRequest { thread_id: Some(self.id.to_string()), prompt_id: Some(self.prompt_id.to_string()), intent: Some(CompletionIntent::ThreadContextSummarization), temperature: AgentSettings::temperature_for_model(model, cx), messages: self.build_request_messages_until(Vec::new(), insertion_ix, cx), ..Default::default() }; request.messages.push(LanguageModelRequestMessage { role: Role::User, content: vec![COMPACTION_PROMPT.into()], cache: false, reasoning_details: None, }); request } pub fn to_markdown(&self) -> String { let mut markdown = messages_to_markdown(&self.messages); 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 { 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, }) } // Retrying won't help for Payment Required errors. PaymentRequired => None, // Retrying won't help until the user consents to data retention // or switches models. DataRetentionConsentRequired { .. } => None, // Conservatively assume that any other errors are non-retryable HttpResponseError { .. } | Other(..) => Some(RetryStrategy::Fixed { delay: BASE_RETRY_DELAY, max_attempts: 2, }), } } } fn total_input_tokens(usage: language_model::TokenUsage) -> u64 { usage .input_tokens .saturating_add(usage.cache_creation_input_tokens) .saturating_add(usage.cache_read_input_tokens) } fn auto_compact_threshold_token_count( threshold: AutoCompactThreshold, max_token_count: u64, ) -> u64 { match threshold { AutoCompactThreshold::Percentage(percent) => { ((max_token_count as f64) * percent).ceil() as u64 } AutoCompactThreshold::TokensUsed(tokens) => tokens, AutoCompactThreshold::TokensRemaining(tokens) => { max_token_count.saturating_sub(tokens).saturating_add(1) } } } /// Snapshot of the data needed to report an `"Agent Compaction Completed"` /// telemetry event, captured when a compaction starts. struct CompactionTelemetry { /// `"auto"` for threshold-triggered compaction, `"manual"` for `/compact`. trigger: &'static str, thread_id: String, parent_thread_id: Option, prompt_id: String, model: String, compaction_model: String, thinking_effort: Option, max_tokens: u64, /// Tokens in the context window immediately before compaction. tokens_before: Option, auto_compact_enabled: bool, auto_compact_threshold: String, auto_compact_threshold_tokens: u64, /// Number of times the compaction request was retried before the final /// outcome. retries: u32, } impl CompactionTelemetry { fn emit(self, status: &'static str, error: Option, tokens_after: Option) { telemetry::event!( "Agent Compaction Completed", trigger = self.trigger, status = status, error = error, thread_id = self.thread_id, parent_thread_id = self.parent_thread_id, prompt_id = self.prompt_id, model = self.model, compaction_model = self.compaction_model, thinking_effort = self.thinking_effort, max_tokens = self.max_tokens, tokens_before = self.tokens_before, tokens_after = tokens_after, auto_compact_enabled = self.auto_compact_enabled, auto_compact_threshold = self.auto_compact_threshold, auto_compact_threshold_tokens = self.auto_compact_threshold_tokens, retries = self.retries, ); } } fn user_message_byte_len(message: &LanguageModelRequestMessage) -> usize { message .content .iter() .map(|content| match content { MessageContent::Text(text) => text.len(), MessageContent::Image(image) => image.len(), // These can never occur in a user message MessageContent::Thinking { .. } | MessageContent::RedactedThinking(_) | MessageContent::ToolResult(_) | MessageContent::ToolUse(_) | MessageContent::Compaction(_) => 0, }) .sum() } fn truncate_user_message_to_byte_budget( mut message: LanguageModelRequestMessage, byte_budget: usize, ) -> Option { let mut remaining_bytes = byte_budget; let mut content = Vec::with_capacity(message.content.len()); for item in message.content { match item { MessageContent::Text(text) => { let fits = text.len() <= remaining_bytes; if let Some(text) = take_text_within_byte_budget(text, &mut remaining_bytes) { content.push(MessageContent::Text(text)); } if !fits { break; } } MessageContent::Image(image) => { let byte_len = image.len(); if let Some(bytes) = remaining_bytes.checked_sub(byte_len) { remaining_bytes = bytes; content.push(MessageContent::Image(image)); } else { break; } } // These can never occur in a user message MessageContent::Thinking { .. } | MessageContent::RedactedThinking(_) | MessageContent::ToolResult(_) | MessageContent::ToolUse(_) | MessageContent::Compaction(_) => {} } } if content.is_empty() { None } else { message.content = content; Some(message) } } fn take_text_within_byte_budget(text: String, remaining_bytes: &mut usize) -> Option { if text.is_empty() || *remaining_bytes == 0 { return None; } if let Some(bytes) = remaining_bytes.checked_sub(text.len()) { *remaining_bytes = bytes; return Some(text); } let end = text.floor_char_boundary((*remaining_bytes).min(text.len())); *remaining_bytes = 0; let text = text[..end].to_string(); if text.is_empty() { None } else { Some(text) } } /// Describes where a streamed compaction summary should land in the thread /// once it completes successfully. enum CompactionInsertion { /// Automatic compaction inserts the summary at an index computed up front /// (which may be before a trailing not-yet-answered user message). Auto { insertion_ix: usize }, /// Manual `/compact` appends a zero-content user message followed by the summary. Manual { marker_id: ClientUserMessageId }, } 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>, /// 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, /// Senders for tools that support input streaming and have already been /// started but are still receiving input from the LLM. streaming_tool_inputs: HashMap, } impl RunningTurn { fn new( event_stream: ThreadEventStream, tools: BTreeMap>, cancellation_tx: watch::Sender, task: Task<()>, ) -> Self { Self { _task: task, event_stream, tools, cancellation_tx, streaming_tool_inputs: HashMap::default(), } } 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(crate) fn messages_to_markdown(messages: &[Arc]) -> String { let mut markdown = String::new(); for (ix, message) in 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 | Message::Compaction(_) => {} } markdown.push_str(&message.to_markdown()); } markdown } fn extend_request_history_until( messages: &[Arc], request_messages: &mut Vec, end_ix: usize, ) { let end_ix = end_ix.min(messages.len()); let Some(compaction_ix) = latest_compaction_message_ix_before(messages, end_ix) else { for message in &messages[..end_ix] { request_messages.extend(message.to_request()); } return; }; if matches!( &*messages[compaction_ix], Message::Compaction(CompactionInfo::Summary(_)) ) { request_messages.extend(retained_user_request_messages_before( messages, compaction_ix, )); } for message in &messages[compaction_ix..end_ix] { request_messages.extend(message.to_request()); } } fn latest_compaction_message_ix_before(messages: &[Arc], end_ix: usize) -> Option { messages[..end_ix] .iter() .rposition(|message| matches!(&**message, Message::Compaction(_))) } fn retained_user_request_messages_before( messages: &[Arc], compaction_ix: usize, ) -> Vec { let mut remaining_bytes = COMPACTION_RETAINED_USER_MESSAGES_BYTE_BUDGET; let mut retained_messages = Vec::new(); for message in messages[..compaction_ix].iter().rev() { let Message::User(user_message) = &**message else { continue; }; if user_message.content.is_empty() { continue; } let request_message = user_message.to_request(); let byte_count = user_message_byte_len(&request_message); if let Some(bytes) = remaining_bytes.checked_sub(byte_count) { remaining_bytes = bytes; retained_messages.push(request_message); } else { if remaining_bytes > 0 && let Some(request_message) = truncate_user_message_to_byte_budget(request_message, remaining_bytes) { retained_messages.push(request_message); } break; } } retained_messages.reverse(); retained_messages } pub fn build_thread_title_request( messages: &[Arc], temperature: Option, ) -> LanguageModelRequest { let mut request = LanguageModelRequest { intent: Some(CompletionIntent::ThreadSummarization), temperature, ..Default::default() }; extend_request_history_until(messages, &mut request.messages, messages.len()); request.messages.push(LanguageModelRequestMessage { role: Role::User, content: vec![SUMMARIZE_THREAD_PROMPT.into()], cache: false, reasoning_details: None, }); request } pub async fn stream_thread_title( model: Arc, request: LanguageModelRequest, cx: &AsyncApp, ) -> Result { let mut title = String::new(); let mut events = model.stream_completion(request, cx).await?; while let Some(event) = events.next().await { let LanguageModelCompletionEvent::Text(text) = event? else { continue; }; if let Some(newline_ix) = text.find(|ch| ch == '\n' || ch == '\r') { title.push_str(&text[..newline_ix]); break; } title.push_str(&text); } Ok(title) } pub struct TokenUsageUpdated(pub Option); impl EventEmitter for Thread {} pub struct TitleUpdated; impl EventEmitter for Thread {} pub struct ModelChanged; impl EventEmitter 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 { rx: mpsc::UnboundedReceiver>, _phantom: PhantomData, } impl ToolInput { #[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 { 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> { 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(self) -> ToolInput { ToolInput { rx: self.rx, _phantom: PhantomData, } } } pub enum ToolInputPayload { Partial(serde_json::Value), Full(T), InvalidJson { error_message: String }, } pub struct ToolInputSender { has_received_final: bool, tx: mpsc::UnboundedSender>, } impl ToolInputSender { pub(crate) fn channel() -> (Self, ToolInput) { 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; 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, 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::(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 } /// Whether this tool may be provided to an agent in a restricted workspace. /// /// Tools that return `false` are never exposed to the model while the /// workspace is restricted, and will fail if invoked in that state. fn allow_in_restricted_mode() -> bool { true } /// Runs the tool with the provided input. /// /// Returns `Result` rather than `Result` /// 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, input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task>; /// 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 { Arc::new(Erased(Arc::new(self))) } } pub struct Erased(T); pub struct AgentToolOutput { pub llm_output: Vec, pub raw_output: serde_json::Value, } impl From 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; fn supports_input_streaming(&self) -> bool { false } fn supports_provider(&self, _provider: &LanguageModelProviderId) -> bool { true } fn allow_in_restricted_mode(&self) -> bool { true } /// See [`AgentTool::run`] for why this returns `Result`. fn run( self: Arc, input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task>; fn replay( &self, input: serde_json::Value, output: serde_json::Value, event_stream: ToolCallEventStream, cx: &mut App, ) -> Result<()>; } impl AnyAgentTool for Erased> 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 { 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 allow_in_restricted_mode(&self) -> bool { T::allow_in_restricted_mode() } fn run( self: Arc, input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { let tool_input: ToolInput = 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) } } /// Builds the ACP-facing tool call id for a tool use in the message at /// `message_ix`. /// /// Provider-issued `tool_use` ids aren't guaranteed unique across separate /// request/response cycles in the same turn -- some providers reset a /// per-request counter (e.g. `call_1`, `call_2`, ...), so the same raw id /// can recur. Scoping by message index keeps the id stable for one tool /// call's lifetime while preventing it from colliding with an unrelated one. pub(crate) fn scoped_tool_call_id( message_ix: usize, tool_use_id: &LanguageModelToolUseId, ) -> acp::ToolCallId { // `message_ix` is non-zero-padded decimal, so the `:` delimiter is always // unambiguous -- this would break if the index were zero-padded. acp::ToolCallId::new(format!("{message_ix}:{tool_use_id}")) } #[derive(Clone)] struct ThreadEventStream(mpsc::UnboundedSender>); 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: &acp::ToolCallId, 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: &acp::ToolCallId, tool_name: &str, title: String, kind: acp::ToolKind, input: serde_json::Value, ) -> acp::ToolCall { acp::ToolCall::new(id.clone(), title) .kind(kind) .raw_input(input) .meta(acp_thread::meta_with_tool_name(tool_name)) } fn update_tool_call_fields( &self, tool_call_id: &acp::ToolCallId, fields: acp::ToolCallUpdateFields, meta: Option, ) { self.0 .unbounded_send(Ok(ThreadEvent::ToolCallUpdate( acp::ToolCallUpdate::new(tool_call_id.clone(), fields) .meta(meta) .into(), ))) .ok(); } fn resolve_tool_call_authorization( &self, tool_call_id: &acp::ToolCallId, outcome: acp_thread::SelectedPermissionOutcome, ) { self.0 .unbounded_send(Ok(ThreadEvent::ToolCallAuthorizationResolved { tool_call_id: tool_call_id.clone(), outcome, })) .ok(); } fn send_retry(&self, status: acp_thread::RetryStatus) { self.0.unbounded_send(Ok(ThreadEvent::Retry(status))).ok(); } fn send_context_compaction( &self, id: acp_thread::ContextCompactionId, status: acp_thread::ContextCompactionStatus, ) { self.0 .unbounded_send(Ok(ThreadEvent::ContextCompaction( acp_thread::ContextCompaction { id, status, summary: None, }, ))) .ok(); } fn send_context_compaction_update( &self, id: acp_thread::ContextCompactionId, summary_delta: &str, ) { self.0 .unbounded_send(Ok(ThreadEvent::ContextCompactionUpdate( acp_thread::ContextCompactionUpdate { id, summary_delta: summary_delta.to_string(), status: None, }, ))) .ok(); } fn update_context_compaction_status( &self, id: acp_thread::ContextCompactionId, status: acp_thread::ContextCompactionStatus, ) { self.0 .unbounded_send(Ok(ThreadEvent::ContextCompactionUpdate( acp_thread::ContextCompactionUpdate { id, summary_delta: String::new(), status: Some(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) { self.0.unbounded_send(Err(error.into())).ok(); } } /// The user's choice when the OS sandbox could not be created for a command /// (see [`ToolCallEventStream::authorize_sandbox_fallback`]). Only the /// Bubblewrap sandboxes (Linux directly, Windows via WSL) can fail to create a /// sandbox, so this is gated to those platforms. #[cfg(any(target_os = "linux", target_os = "windows"))] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum SandboxFallbackDecision { /// Try creating the sandbox again (e.g. after the user installed `bwrap`). Retry, /// Run the command without a sandbox. RunUnsandboxed, /// Don't run the command at all. Deny, } #[derive(Clone)] pub struct ToolCallEventStream { tool_use_id: LanguageModelToolUseId, /// The ACP-facing id for this tool call (see [`scoped_tool_call_id`]). /// Distinct from `tool_use_id`, which is the raw, provider-issued id. tool_call_id: acp::ToolCallId, stream: ThreadEventStream, fs: Option>, cancellation_rx: watch::Receiver, /// Shared, thread-scoped sandbox grants (see [`Thread::sandbox_grants`]). sandbox_grants: Rc>, /// The owning thread, used to trigger a save when a "for this thread" /// sandbox grant is recorded so it survives reopening. `None` in tests and /// for streams not tied to a live thread. thread: Option>, } impl ToolCallEventStream { #[cfg(any(test, feature = "test-support"))] pub fn test() -> (Self, ToolCallEventStreamReceiver) { let (stream, receiver, _cancellation_tx) = Self::test_with_cancellation(); (stream, receiver) } /// Like [`Self::test`], but the returned stream shares the provided /// thread-scoped sandbox grants. This mirrors how a real [`Thread`] builds a /// distinct event stream per tool call while sharing one set of grants, so /// tests can exercise sequences of tool calls within the same conversation. #[cfg(test)] pub(crate) fn test_with_grants( sandbox_grants: Rc>, ) -> (Self, ToolCallEventStreamReceiver) { let (events_tx, events_rx) = mpsc::unbounded::>(); let (_cancellation_tx, cancellation_rx) = watch::channel(false); let stream = ToolCallEventStream::new( "test_id".into(), acp::ToolCallId::new("test_id"), ThreadEventStream(events_tx), None, cancellation_rx, sandbox_grants, None, ); (stream, ToolCallEventStreamReceiver(events_rx)) } #[cfg(any(test, feature = "test-support"))] pub fn test_with_cancellation() -> (Self, ToolCallEventStreamReceiver, watch::Sender) { let (events_tx, events_rx) = mpsc::unbounded::>(); let (cancellation_tx, cancellation_rx) = watch::channel(false); let stream = ToolCallEventStream::new( "test_id".into(), acp::ToolCallId::new("test_id"), ThreadEventStream(events_tx), None, cancellation_rx, Rc::new(RefCell::new(ThreadSandboxGrants::default())), None, ); ( 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) { cancellation_tx.send(true).ok(); } fn new( tool_use_id: LanguageModelToolUseId, tool_call_id: acp::ToolCallId, stream: ThreadEventStream, fs: Option>, cancellation_rx: watch::Receiver, sandbox_grants: Rc>, thread: Option>, ) -> Self { Self { tool_use_id, tool_call_id, stream, fs, cancellation_rx, sandbox_grants, thread, } } /// Whether the owning thread is a subagent, so prompts can say "for this /// subagent" instead of "for this thread". fn is_subagent(&self, cx: &App) -> bool { self.thread .as_ref() .and_then(|thread| thread.upgrade()) .is_some_and(|thread| thread.read(cx).is_subagent()) } /// Persist the thread so a freshly recorded "for this thread" sandbox grant /// survives a reopen. Saving is driven by the agent's `observe` on the /// thread entity, so a no-op `notify` is enough to schedule it. fn persist_thread_grants(thread: &Option>, cx: &AsyncApp) { let Some(thread) = thread else { return }; cx.update(|cx| { thread.update(cx, |_thread, cx| cx.notify()).ok(); }); } /// 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 + '_ { 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_call_id, fields, None); } pub fn update_fields_with_meta( &self, fields: acp::ToolCallUpdateFields, meta: Option, ) { self.stream .update_tool_call_fields(&self.tool_call_id, fields, meta); } pub fn resolve_authorization(&self, outcome: acp_thread::SelectedPermissionOutcome) { self.stream .resolve_tool_call_authorization(&self.tool_call_id, outcome); } pub fn update_diff(&self, diff: Entity) { self.stream .0 .unbounded_send(Ok(ThreadEvent::ToolCallUpdate( acp_thread::ToolCallUpdateDiff { id: self.tool_call_id.clone(), diff, } .into(), ))) .ok(); } pub fn subagent_spawned(&self, id: acp::SessionId) { self.stream .0 .unbounded_send(Ok(ThreadEvent::SubagentSpawned(id))) .ok(); } /// 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 MCP tool" → sets `tools..default = "allow"` or "deny" /// - "Only this time" → allow/deny once pub fn authorize_third_party_tool( &self, title: impl Into, tool_id: String, display_name: String, cx: &mut App, ) -> Task> { 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 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, context: ToolPermissionContext, cx: &mut App, ) -> Task> { 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 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, context: ToolPermissionContext, cx: &mut App, ) -> Task> { let title = title.into(); let options = context.build_permission_options(); self.run_authorization_loop(title, options, Some(context), None, cx) } /// Gate a sandbox *escalation* (network access, per-path writes, or full /// filesystem write access) on user approval. /// /// Offers the user three grant lifetimes — "once", "for the rest of this /// thread", and "always". Thread grants live in the shared, in-memory /// [`ThreadSandboxGrants`]. Always grants are persisted in agent settings /// and are also observed while a prompt is pending, matching the /// settings-driven authorization flow for regular tools. pub(crate) fn authorize_sandbox( &self, request: SandboxRequest, reason: String, cx: &mut App, ) -> Task> { if Self::sandbox_request_covered_by_grants(&request, &self.sandbox_grants, cx) { return Task::ready(Ok(())); } let (network_hosts, network_all_hosts) = match &request.network { crate::sandboxing::NetworkRequest::None => (Vec::new(), false), crate::sandboxing::NetworkRequest::AnyHost => (Vec::new(), true), crate::sandboxing::NetworkRequest::Hosts(hosts) => { (hosts.iter().map(|host| host.to_string()).collect(), false) } }; let sandbox_authorization_details = acp_thread::SandboxAuthorizationDetails { // The command stays in the tool-call title (set by the terminal // tool), so the approval card keeps showing it; the details only // describe the requested access and the agent's reason. command: None, network_hosts, network_all_hosts, allow_fs_write_all: request.allow_fs_write_all, unsandboxed: request.unsandboxed, write_paths: request.write_paths.clone(), // The Windows-drive warning is a separate pre-prompt // (`authorize_windows_fs_warning`), never part of the escalation // prompt, so this stays off here. warn_windows_fs: false, reason, }; let allow_thread_label = if self.is_subagent(cx) { "Allow for this subagent" } else { "Allow for this thread" }; let options = acp_thread::PermissionOptions::Flat(vec![ acp::PermissionOption::new( acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowOnce.as_id()), "Allow once", acp::PermissionOptionKind::AllowOnce, ), acp::PermissionOption::new( acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowThread.as_id()), allow_thread_label, acp::PermissionOptionKind::AllowAlways, ), acp::PermissionOption::new( acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowAlways.as_id()), "Allow always", acp::PermissionOptionKind::AllowAlways, ), acp::PermissionOption::new( acp::PermissionOptionId::new(acp_thread::SandboxPermission::Deny.as_id()), "Deny", acp::PermissionOptionKind::RejectOnce, ), ]); let fs = self.fs.clone(); let stream = self.stream.clone(); let tool_call_id = self.tool_call_id.clone(); let sandbox_grants = self.sandbox_grants.clone(); let thread = self.thread.clone(); let auto_allow_outcome = match auto_resolve_permission_outcome(&options, true) { Ok(outcome) => outcome, Err(error) => return Task::ready(Err(error)), }; 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_call_id.clone(), // Leave the title untouched so the card keeps // showing the command (matching the fallback flow). acp::ToolCallUpdateFields::new(), ) .meta(acp_thread::meta_with_sandbox_authorization( sandbox_authorization_details, )), options, response: response_tx, context: None, kind: acp_thread::AuthorizationKind::PermissionGrant, }, ))) { log::error!("Failed to send sandbox authorization: {error}"); return Err(anyhow!("Failed to send sandbox authorization: {error}")); } let (mut settings_tx, mut settings_rx) = watch::channel(()); let _settings_subscription = cx.update(|cx| { cx.observe_global::(move |_cx| { settings_tx.send(()).ok(); }) }); 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"))?; ensure_tool_call_authorization_not_interrupted(&outcome)?; return Self::handle_sandbox_permission_outcome( &outcome, &request, sandbox_grants.clone(), thread.clone(), fs.clone(), cx, ); } _ = settings_changed.fuse() => { if cx.update(|cx| Self::sandbox_request_covered_by_grants( &request, &sandbox_grants, cx, )) { drop(response_rx); stream.resolve_tool_call_authorization( &tool_call_id, auto_allow_outcome.clone(), ); return Ok(()); } } } } }) } /// Confirm, before running a command whose sandbox will contain a Windows /// drive (DrvFs) path, that the user accepts the weaker integrity /// guarantees. This is a transient gate *in front of* the normal sandbox /// flow — it is never persisted or recorded as a grant; the only way to /// stop it recurring is to disable `warn_ntfs_grants` in settings (offered /// via the banner's gear). Returns `Ok(())` on "Continue" (after which the /// caller proceeds to any escalation prompt) and `Err` on "Abort". pub(crate) fn authorize_windows_fs_warning(&self, cx: &mut App) -> Task> { // If the warning is already disabled, don't prompt. if !AgentSettings::get_global(cx) .sandbox_permissions .warn_ntfs_grants { return Task::ready(Ok(())); } let details = acp_thread::SandboxAuthorizationDetails { command: None, network_hosts: Vec::new(), network_all_hosts: false, allow_fs_write_all: false, unsandboxed: false, write_paths: Vec::new(), warn_windows_fs: true, reason: String::new(), }; let options = acp_thread::PermissionOptions::Flat(vec![ acp::PermissionOption::new( acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowOnce.as_id()), "Continue", acp::PermissionOptionKind::AllowOnce, ), acp::PermissionOption::new( acp::PermissionOptionId::new(acp_thread::SandboxPermission::Deny.as_id()), "Abort", acp::PermissionOptionKind::RejectOnce, ), ]); let stream = self.stream.clone(); let tool_use_id = self.tool_use_id.clone(); cx.spawn(async move |_cx| { let (response_tx, 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(), ) .meta(acp_thread::meta_with_sandbox_authorization(details)), options, response: response_tx, context: None, kind: acp_thread::AuthorizationKind::PermissionGrant, }, ))) { log::error!("Failed to send Windows-drive sandbox warning: {error}"); return Err(anyhow!( "Failed to send Windows-drive sandbox warning: {error}" )); } let outcome = response_rx .await .map_err(|_| anyhow!("authorization channel closed"))?; match acp_thread::SandboxPermission::from_id(outcome.option_id.0.as_ref()) { Some(acp_thread::SandboxPermission::AllowOnce) => Ok(()), _ => Err(anyhow!("Windows-drive write aborted by user")), } }) } fn sandbox_request_covered_by_grants( request: &SandboxRequest, sandbox_grants: &Rc>, cx: &App, ) -> bool { let settings = AgentSettings::get_global(cx); sandbox_grants .borrow() .covers_with_persistent(request, &settings.sandbox_permissions) } fn handle_sandbox_permission_outcome( outcome: &acp_thread::SelectedPermissionOutcome, request: &SandboxRequest, sandbox_grants: Rc>, thread: Option>, fs: Option>, cx: &AsyncApp, ) -> Result<()> { debug_assert!( outcome.params.is_none(), "unexpected params for sandbox permission" ); match acp_thread::SandboxPermission::from_id(outcome.option_id.0.as_ref()) { Some(acp_thread::SandboxPermission::AllowOnce) => Ok(()), Some(acp_thread::SandboxPermission::AllowThread) => { sandbox_grants.borrow_mut().record(request); Self::persist_thread_grants(&thread, cx); Ok(()) } Some(acp_thread::SandboxPermission::AllowAlways) => { Self::persist_sandbox_always_permission(request, fs, cx); Ok(()) } Some(acp_thread::SandboxPermission::Deny) => { Err(anyhow!("Permission to run tool denied by user")) } None => { let other = outcome.option_id.0.as_ref(); debug_assert!(false, "unexpected sandbox permission option_id: {other}"); Err(anyhow!("Permission to run tool denied by user")) } } } fn persist_sandbox_always_permission( request: &SandboxRequest, fs: Option>, cx: &AsyncApp, ) { let Some(fs) = fs else { log::error!( "Cannot persist \"allow always\" sandbox permission: no filesystem available" ); return; }; let request = request.clone(); cx.update(|cx| { update_settings_file(fs, cx, move |settings, _| { let agent = settings.agent.get_or_insert_default(); match &request.network { crate::sandboxing::NetworkRequest::None => {} crate::sandboxing::NetworkRequest::AnyHost => { agent.allow_sandbox_all_hosts(); } crate::sandboxing::NetworkRequest::Hosts(hosts) => { // Rebuild the persisted list with subsumption pruning // so granting `*.github.com` retires a previously // persisted `api.github.com` instead of accumulating // redundant entries. Unparsable hand-edited entries // are preserved untouched. let mut patterns = Vec::new(); let mut unparsable = Vec::new(); for raw in agent.sandbox_network_hosts() { match http_proxy::HostPattern::parse(raw) { Ok(pattern) => { crate::sandboxing::insert_host_pattern(&mut patterns, pattern) } Err(_) => unparsable.push(raw.clone()), } } for host in hosts { crate::sandboxing::insert_host_pattern(&mut patterns, host.clone()); } let mut host_strings = unparsable; host_strings.extend(patterns.iter().map(|pattern| pattern.to_string())); agent.set_sandbox_network_hosts(host_strings); } } if request.allow_fs_write_all { agent.allow_sandbox_fs_write_all(); } if request.unsandboxed { agent.allow_sandbox_unsandboxed(); } for granted in request.write_paths { // Persist the full (requested, resolved-canonical) pair so a // persistent grant is rebuilt from the vetted canonical // across restarts, not re-resolved by path string. agent.add_sandbox_write_path(settings::GrantedWritePathContent { requested: granted.requested, resolved: granted.resolved, on_windows_fs: granted.on_windows_fs, }); } }); }); } /// The sandbox permissions to actually enforce for a command: the union /// of this command's `request`, everything granted "for the rest of the /// conversation", and persistent "allow always" sandbox grants. /// /// Callers must apply this to the enforced sandbox policy (rather than /// the raw `request`) so standing grants keep working for later commands /// that write to a previously approved path without re-requesting it. pub(crate) fn effective_sandbox_request( &self, request: &SandboxRequest, persistent: &agent_settings::SandboxPermissions, ) -> SandboxRequest { self.sandbox_grants .borrow() .effective_with_persistent(request, persistent) } /// Whether the user allowed running commands unsandboxed for the rest of /// the thread (distinct from the persistent `allow_unsandboxed` setting). pub(crate) fn sandbox_fallback_granted_for_thread(&self) -> bool { self.sandbox_grants.borrow().fallback_granted_for_thread() } /// Whether the user approved a model-requested `unsandboxed: true` escape /// for the rest of this thread. Like the fallback grant, this makes every /// command in the thread run without a sandbox. pub(crate) fn unsandboxed_granted_for_thread(&self) -> bool { self.sandbox_grants.borrow().unsandboxed_granted() } /// Whether unsandboxed access is currently in effect: granted for this /// thread (a model-requested `unsandboxed` escape or a sandbox-creation /// fallback) or configured persistently via `allow_unsandboxed`. When true, /// commands already run without any OS sandbox, so per-host network grants /// no longer provide isolation and callers may skip host authorization /// entirely. pub(crate) fn unsandboxed_access_granted(&self, cx: &App) -> bool { self.unsandboxed_granted_for_thread() || self.sandbox_fallback_granted_for_thread() || AgentSettings::get_global(cx) .sandbox_permissions .allow_unsandboxed } /// Ask the user how to proceed when the OS sandbox could not be created /// for a command (for example, `bwrap` is missing or user namespaces are /// disabled). /// /// Unlike [`Self::authorize_sandbox`] — which gates a model-requested /// *escalation* — this surfaces a *system limitation*: the sandbox failed, /// so the prompt explains why (`reason`) and lets the user retry, run the /// command unsandboxed (once / for this thread / always), or deny it. The /// "for this thread" choice is recorded in the in-memory thread grants and /// "always" is persisted as the `allow_unsandboxed` setting. Only the /// Bubblewrap sandboxes (Linux directly, Windows via WSL) can fail to /// create a sandbox, so this is gated to those platforms. /// /// `retries` is how many times the user has already pressed Retry for this /// command; it's shown on the button so repeated presses visibly advance /// ("Retry", then "Retry (attempt 1)", "Retry (attempt 2)", …). #[cfg(any(target_os = "linux", target_os = "windows"))] pub(crate) fn authorize_sandbox_fallback( &self, command: Option, reason: String, docs_section: Option, retries: usize, cx: &mut App, ) -> Task> { let details = acp_thread::SandboxFallbackAuthorizationDetails { command, reason, docs_section, }; let retry_label = if retries == 0 { "Retry".to_string() } else { format!("Retry (attempt {retries})") }; let allow_thread_label = if self.is_subagent(cx) { "Run without sandbox for this subagent" } else { "Run without sandbox for this thread" }; let options = acp_thread::PermissionOptions::Flat(vec![ // Retry isn't an allow/deny choice; the UI renders it with its own // icon and we dispatch on the option id, so the kind here only // governs keybindings. Use `RejectAlways` (which has none) so the // "allow once" shortcut maps to "Run without sandbox once" rather // than to Retry. acp::PermissionOption::new( acp::PermissionOptionId::new(acp_thread::SANDBOX_FALLBACK_RETRY_OPTION_ID), retry_label, acp::PermissionOptionKind::RejectAlways, ), acp::PermissionOption::new( acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowOnce.as_id()), "Run without sandbox once", acp::PermissionOptionKind::AllowOnce, ), acp::PermissionOption::new( acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowThread.as_id()), allow_thread_label, acp::PermissionOptionKind::AllowAlways, ), acp::PermissionOption::new( acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowAlways.as_id()), "Always run without sandbox", acp::PermissionOptionKind::AllowAlways, ), acp::PermissionOption::new( acp::PermissionOptionId::new(acp_thread::SandboxPermission::Deny.as_id()), "Deny", acp::PermissionOptionKind::RejectOnce, ), ]); let fs = self.fs.clone(); let stream = self.stream.clone(); let tool_call_id = self.tool_call_id.clone(); let sandbox_grants = self.sandbox_grants.clone(); let thread = self.thread.clone(); cx.spawn(async move |cx| { let (response_tx, response_rx) = oneshot::channel(); if let Err(error) = stream .0 .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( ToolCallAuthorization { // Deliberately leave the tool-call title untouched so // the card keeps showing the *command* (not the // failure reason): it's critical the user can see what // they're approving to run unsandboxed. The reason is // surfaced separately by the fallback details / warning. tool_call: acp::ToolCallUpdate::new( tool_call_id.clone(), acp::ToolCallUpdateFields::new(), ) .meta( acp_thread::meta_with_sandbox_fallback_authorization(details), ), options, response: response_tx, context: None, kind: acp_thread::AuthorizationKind::ActionChoice, }, ))) { log::error!("Failed to send sandbox fallback authorization: {error}"); return Err(anyhow!( "Failed to send sandbox fallback authorization: {error}" )); } let outcome = response_rx .await .map_err(|_| anyhow!("authorization channel closed"))?; ensure_tool_call_authorization_not_interrupted(&outcome)?; let option_id = outcome.option_id.0.as_ref(); if option_id == acp_thread::SANDBOX_FALLBACK_RETRY_OPTION_ID { return Ok(SandboxFallbackDecision::Retry); } match acp_thread::SandboxPermission::from_id(option_id) { Some(acp_thread::SandboxPermission::AllowOnce) => { Ok(SandboxFallbackDecision::RunUnsandboxed) } Some(acp_thread::SandboxPermission::AllowThread) => { sandbox_grants.borrow_mut().record_fallback(); Self::persist_thread_grants(&thread, cx); Ok(SandboxFallbackDecision::RunUnsandboxed) } Some(acp_thread::SandboxPermission::AllowAlways) => { sandbox_grants.borrow_mut().record_fallback(); Self::persist_thread_grants(&thread, cx); Self::persist_sandbox_unsandboxed_permission(fs, cx); Ok(SandboxFallbackDecision::RunUnsandboxed) } Some(acp_thread::SandboxPermission::Deny) => Ok(SandboxFallbackDecision::Deny), None => { let other = option_id; debug_assert!(false, "unexpected sandbox fallback option_id: {other}"); Ok(SandboxFallbackDecision::Deny) } } }) } /// Persist the `allow_unsandboxed` setting. Going forward this turns /// sandboxing off for the model-facing surface: later turns expose the /// plain `terminal` tool (with no sandbox prompt section) and commands run /// without an OS sandbox. On Windows, WSL sandbox setup is skipped. #[cfg(any(target_os = "linux", target_os = "windows"))] fn persist_sandbox_unsandboxed_permission(fs: Option>, cx: &AsyncApp) { let Some(fs) = fs else { log::error!( "Cannot persist \"allow always\" unsandboxed permission: no filesystem available" ); return; }; cx.update(|cx| { update_settings_file(fs, cx, move |settings, _| { settings .agent .get_or_insert_default() .allow_sandbox_unsandboxed(); }); }); } /// Prompts the user to choose between an explicit set of actions and /// returns the chosen `option_id`. /// /// Unlike [`Self::authorize`] / [`Self::authorize_always_prompt`], this /// does not interpret the user's choice as a permission grant — callers /// are responsible for handling each `option_id` explicitly. Use this /// when a tool needs the user to pick between several side-effecting /// actions (for example, "Save" vs "Discard" for a dirty buffer). pub fn prompt_for_decision( &self, title: Option, message: Option, options: Vec, cx: &mut App, ) -> Task> { let options = acp_thread::PermissionOptions::Flat(options); let stream = self.stream.clone(); let tool_call_id = self.tool_call_id.clone(); cx.spawn(async move |_cx| { let mut fields = acp::ToolCallUpdateFields::new(); if let Some(title) = title { fields = fields.title(title); } if let Some(message) = message { fields = fields.content(vec![acp::ToolCallContent::from(message)]); } let (response_tx, response_rx) = oneshot::channel(); if let Err(error) = stream .0 .unbounded_send(Ok(ThreadEvent::ToolCallAuthorization( ToolCallAuthorization { tool_call: acp::ToolCallUpdate::new(tool_call_id.clone(), fields), options, response: response_tx, context: None, kind: acp_thread::AuthorizationKind::ActionChoice, }, ))) { log::error!("Failed to send tool call decision prompt: {error}"); return Err(anyhow!("Failed to send tool call decision prompt: {error}")); } let outcome = response_rx .await .map_err(|_| anyhow!("authorization channel closed"))?; ensure_tool_call_authorization_not_interrupted(&outcome)?; Ok(outcome.option_id) }) } /// 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, check_settings: Option ToolPermissionDecision>>, cx: &mut App, ) -> Task> { // 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_call_id = self.tool_call_id.clone(); let auto_resolution_outcomes = if check_settings.is_some() { match ( auto_resolve_permission_outcome(&options, true), auto_resolve_permission_outcome(&options, false), ) { (Ok(allow), Ok(deny)) => Some((allow, deny)), (Err(error), _) | (_, Err(error)) => return Task::ready(Err(error)), } } else { None }; 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_call_id.clone(), acp::ToolCallUpdateFields::new().title(title), ), options, response: response_tx, context, kind: acp_thread::AuthorizationKind::PermissionGrant, }, ))) { 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"))?; ensure_tool_call_authorization_not_interrupted(&outcome)?; return Self::persist_permission_outcome(&outcome, fs, cx); }; let Some((auto_allow_outcome, auto_deny_outcome)) = auto_resolution_outcomes else { return Err(anyhow!("missing auto-resolution outcomes")); }; let (mut settings_tx, mut settings_rx) = watch::channel(()); let _settings_subscription = cx.update(|cx| { cx.observe_global::(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"))?; ensure_tool_call_authorization_not_interrupted(&outcome)?; return Self::persist_permission_outcome(&outcome, fs.clone(), cx); } _ = settings_changed.fuse() => { // On auto-resolve, we dismiss the prompt UI by // resolving the tool call's `WaitingForConfirmation` // status with an internal selected outcome. Dropping // `response_rx` prevents the synthetic response from // being delivered back into this loop. match cx.update(|cx| check_settings(cx)) { ToolPermissionDecision::Allow => { drop(response_rx); stream.resolve_tool_call_authorization( &tool_call_id, auto_allow_outcome.clone(), ); return Ok(()); } ToolPermissionDecision::Deny(reason) => { drop(response_rx); stream.resolve_tool_call_authorization( &tool_call_id, auto_deny_outcome.clone(), ); 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>, 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>, 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>); #[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_authorization_resolved( &mut self, ) -> (acp::ToolCallId, acp_thread::SelectedPermissionOutcome) { let event = self.0.next().await; if let Some(Ok(ThreadEvent::ToolCallAuthorizationResolved { tool_call_id, outcome, })) = event { (tool_call_id, outcome) } else { panic!("Expected authorization resolved but got: {:?}", event); } } pub async fn expect_diff(&mut self) -> Entity { 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 { 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); } } } #[cfg(any(test, feature = "test-support"))] impl std::ops::Deref for ToolCallEventStreamReceiver { type Target = mpsc::UnboundedReceiver>; 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 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: SharedString::default(), }, 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.into(), }, 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 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(), } } #[cfg(test)] mod tests { use super::*; use gpui::TestAppContext; use language_model::LanguageModelToolUseId; use language_model::fake_provider::FakeLanguageModel; use serde_json::json; use settings::LanguageModelProviderSetting; use std::sync::Arc; async fn setup_thread_for_test(cx: &mut TestAppContext) -> (Entity, ThreadEventStream) { cx.update(|cx| { let settings_store = settings::SettingsStore::test(cx); cx.set_global(settings_store); LanguageModelRegistry::test(cx); }); 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 set_auto_compact_settings(cx: &mut App, auto_compact: agent_settings::AutoCompactSettings) { let mut settings = AgentSettings::get_global(cx).clone(); settings.auto_compact = auto_compact; AgentSettings::override_global(settings, cx); } fn set_registry_compaction_model(cx: &mut App, model: Option>) { use language_model::fake_provider::FakeLanguageModelProvider; use language_model::{ConfiguredModel, LanguageModelProvider}; LanguageModelRegistry::global(cx).update(cx, |registry, cx| { let configured = model.map(|m| ConfiguredModel { provider: Arc::new(FakeLanguageModelProvider::default()) as Arc, model: m, }); registry.set_compaction_model(configured, cx); }); } #[test] fn test_summary_compaction_renders_for_request_and_markdown() { let message = Message::Compaction(CompactionInfo::Summary("Older context".into())); assert_eq!(message.role(), Role::User); assert_eq!(message.to_markdown(), "--- Context Compacted ---\n"); let request_messages = message.to_request(); assert_eq!(request_messages.len(), 1); assert_eq!(request_messages[0].role, Role::User); assert!(!request_messages[0].cache); assert_eq!(request_messages[0].reasoning_details, None); assert_eq!(request_messages[0].content.len(), 1); let language_model::MessageContent::Text(text) = &request_messages[0].content[0] else { panic!("expected text summary context"); }; assert_eq!( text.as_str(), "The previous conversation was compacted. Use this summary as context:\n\nOlder context" ); } fn user_text_message(id: ClientUserMessageId, text: &str) -> Arc { Arc::new(Message::User(UserMessage { id, content: vec![UserMessageContent::Text(text.to_string())].into(), })) } fn agent_text_message(text: &str) -> Arc { Arc::new(Message::Agent(AgentMessage { content: vec![AgentMessageContent::Text(text.to_string())], ..Default::default() })) } fn summary_compaction(summary: &str) -> Arc { Arc::new(Message::Compaction(CompactionInfo::Summary(summary.into()))) } fn summary_request_text(summary: &str) -> String { format!( "The previous conversation was compacted. Use this summary as context:\n\n{summary}" ) } fn request_texts_after_system(messages: &[LanguageModelRequestMessage]) -> Vec { messages .iter() .skip(1) .map(LanguageModelRequestMessage::string_contents) .collect() } fn request_texts(messages: &[LanguageModelRequestMessage]) -> Vec { messages .iter() .map(LanguageModelRequestMessage::string_contents) .collect() } #[gpui::test] async fn test_thread_summary_request_uses_compacted_history(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let summary_model = Arc::new(FakeLanguageModel::default()); let summary_task = cx.update(|cx| { thread.update(cx, |thread, cx| { thread.set_summarization_model(Some(summary_model.clone()), cx); thread .messages .push(user_text_message(ClientUserMessageId::new(), "old user")); thread.messages.push(agent_text_message("old assistant")); thread.messages.push(summary_compaction("first summary")); thread.messages.push(user_text_message( ClientUserMessageId::new(), "between user", )); thread .messages .push(agent_text_message("between assistant")); thread.messages.push(summary_compaction("latest summary")); thread .messages .push(user_text_message(ClientUserMessageId::new(), "after user")); thread.messages.push(agent_text_message("after assistant")); thread.summary(cx) }) }); cx.run_until_parked(); let summary_request = summary_model.pending_completions().pop().unwrap(); assert_eq!( summary_request.intent, Some(CompletionIntent::ThreadContextSummarization) ); assert_eq!( request_texts(&summary_request.messages), vec![ "old user".to_string(), "between user".to_string(), summary_request_text("latest summary"), "after user".to_string(), "after assistant".to_string(), SUMMARIZE_THREAD_DETAILED_PROMPT.to_string(), ] ); summary_model.send_completion_stream_text_chunk(&summary_request, "thread summary"); summary_model.end_completion_stream(&summary_request); assert_eq!(summary_task.await.as_deref(), Some("thread summary")); } #[test] fn test_thread_title_request_uses_compacted_history() { let messages = vec![ user_text_message(ClientUserMessageId::new(), "old user"), agent_text_message("old assistant"), summary_compaction("first summary"), user_text_message(ClientUserMessageId::new(), "between user"), agent_text_message("between assistant"), summary_compaction("latest summary"), user_text_message(ClientUserMessageId::new(), "after user"), agent_text_message("after assistant"), ]; let request = build_thread_title_request(&messages, Some(0.2)); assert_eq!(request.intent, Some(CompletionIntent::ThreadSummarization)); assert_eq!(request.temperature, Some(0.2)); assert_eq!( request_texts(&request.messages), vec![ "old user".to_string(), "between user".to_string(), summary_request_text("latest summary"), "after user".to_string(), "after assistant".to_string(), SUMMARIZE_THREAD_PROMPT.to_string(), ] ); } #[gpui::test] async fn test_compaction_threshold_uses_percentage_setting(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let model = Arc::new(FakeLanguageModel::default()); let user_message_id = ClientUserMessageId::new(); cx.update(|cx| { thread.update(cx, |thread, cx| { thread.set_model(model, cx); thread .messages .push(user_text_message(user_message_id.clone(), "below limit")); thread.request_token_usage.insert( user_message_id.clone(), language_model::TokenUsage { input_tokens: 899_999, ..Default::default() }, ); assert_eq!(thread.compaction_message_target_ix(cx), None); thread.request_token_usage.insert( user_message_id.clone(), language_model::TokenUsage { input_tokens: 900_000, ..Default::default() }, ); assert_eq!(thread.compaction_message_target_ix(cx), Some(1)); }); }); } #[gpui::test] async fn test_compaction_threshold_accounts_for_max_output_tokens(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let model = Arc::new(FakeLanguageModel::default()); model.set_max_output_tokens(Some(32_000)); let user_message_id = ClientUserMessageId::new(); cx.update(|cx| { thread.update(cx, |thread, cx| { thread.set_model(model, cx); thread.messages.push(user_text_message( user_message_id.clone(), "near input limit", )); thread.request_token_usage.insert( user_message_id.clone(), language_model::TokenUsage { input_tokens: 871_199, ..Default::default() }, ); assert_eq!(thread.compaction_message_target_ix(cx), None); thread.request_token_usage.insert( user_message_id.clone(), language_model::TokenUsage { input_tokens: 871_200, ..Default::default() }, ); assert_eq!(thread.compaction_message_target_ix(cx), Some(1)); set_auto_compact_settings( cx, agent_settings::AutoCompactSettings { enabled: true, threshold: AutoCompactThreshold::TokensRemaining(20_000), }, ); thread.request_token_usage.insert( user_message_id.clone(), language_model::TokenUsage { input_tokens: 948_000, ..Default::default() }, ); assert_eq!(thread.compaction_message_target_ix(cx), None); thread.request_token_usage.insert( user_message_id.clone(), language_model::TokenUsage { input_tokens: 948_001, ..Default::default() }, ); assert_eq!(thread.compaction_message_target_ix(cx), Some(1)); }); }); } #[gpui::test] async fn test_compaction_threshold_respects_enabled_setting(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let model = Arc::new(FakeLanguageModel::default()); let user_message_id = ClientUserMessageId::new(); cx.update(|cx| { set_auto_compact_settings( cx, agent_settings::AutoCompactSettings { enabled: false, threshold: AutoCompactThreshold::Percentage(0.9), }, ); thread.update(cx, |thread, cx| { thread.set_model(model, cx); thread .messages .push(user_text_message(user_message_id.clone(), "near limit")); thread.request_token_usage.insert( user_message_id.clone(), language_model::TokenUsage { input_tokens: 960_000, ..Default::default() }, ); assert_eq!(thread.compaction_message_target_ix(cx), None); }); }); } #[gpui::test] async fn test_compaction_threshold_respects_token_settings(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let model = Arc::new(FakeLanguageModel::default()); let user_message_id = ClientUserMessageId::new(); cx.update(|cx| { set_auto_compact_settings( cx, agent_settings::AutoCompactSettings { enabled: true, threshold: AutoCompactThreshold::TokensUsed(100_000), }, ); thread.update(cx, |thread, cx| { thread.set_model(model, cx); thread.messages.push(user_text_message( user_message_id.clone(), "fixed token limit", )); thread.request_token_usage.insert( user_message_id.clone(), language_model::TokenUsage { input_tokens: 99_999, ..Default::default() }, ); assert_eq!(thread.compaction_message_target_ix(cx), None); thread.request_token_usage.insert( user_message_id.clone(), language_model::TokenUsage { input_tokens: 100_000, ..Default::default() }, ); assert_eq!(thread.compaction_message_target_ix(cx), Some(1)); set_auto_compact_settings( cx, agent_settings::AutoCompactSettings { enabled: true, threshold: AutoCompactThreshold::TokensRemaining(20_000), }, ); thread.request_token_usage.insert( user_message_id.clone(), language_model::TokenUsage { input_tokens: 980_000, ..Default::default() }, ); assert_eq!(thread.compaction_message_target_ix(cx), None); thread.request_token_usage.insert( user_message_id.clone(), language_model::TokenUsage { input_tokens: 980_001, ..Default::default() }, ); assert_eq!(thread.compaction_message_target_ix(cx), Some(1)); }); }); } #[gpui::test] async fn test_compaction_unavailable_for_small_context_window(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let model = Arc::new(FakeLanguageModel::default()); // A context window below the minimum disables auto-compaction. model.set_max_token_count(MIN_COMPACTION_CONTEXT_WINDOW - 1); let user_message_id = ClientUserMessageId::new(); cx.update(|cx| { thread.update(cx, |thread, cx| { thread.set_model(model, cx); thread .messages .push(user_text_message(user_message_id.clone(), "near limit")); thread.request_token_usage.insert( user_message_id.clone(), language_model::TokenUsage { input_tokens: u64::MAX, ..Default::default() }, ); assert_eq!(thread.compaction_message_target_ix(cx), None); }); }); } #[gpui::test] async fn test_compaction_inserts_before_new_user_and_requests_compacted_window( cx: &mut TestAppContext, ) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let model = Arc::new(FakeLanguageModel::default()); let old_user_message_id = ClientUserMessageId::new(); let new_user_message_id = ClientUserMessageId::new(); cx.update(|cx| { thread.update(cx, |thread, cx| { thread.set_model(model.clone(), cx); thread .messages .push(user_text_message(old_user_message_id.clone(), "old user")); thread.messages.push(agent_text_message("old assistant")); thread.request_token_usage.insert( old_user_message_id.clone(), language_model::TokenUsage { input_tokens: 960_000, ..Default::default() }, ); }); }); let _events = cx .update(|cx| { thread.update(cx, |thread, cx| { thread.send(new_user_message_id, vec!["new prompt"], cx) }) }) .unwrap(); cx.run_until_parked(); let compaction_request = model.pending_completions().pop().unwrap(); assert_eq!( compaction_request.intent, Some(CompletionIntent::ThreadContextSummarization) ); let compaction_texts = request_texts_after_system(&compaction_request.messages); assert_eq!(compaction_texts.len(), 3); assert_eq!(compaction_texts[0], "old user"); assert_eq!(compaction_texts[1], "old assistant"); assert_eq!(compaction_texts[2], COMPACTION_PROMPT); model.send_completion_stream_text_chunk(&compaction_request, "compacted old context"); model.end_completion_stream(&compaction_request); cx.run_until_parked(); let final_request = model.pending_completions().pop().unwrap(); assert_eq!(final_request.intent, Some(CompletionIntent::UserPrompt)); assert_eq!( request_texts_after_system(&final_request.messages), vec![ "old user".to_string(), summary_request_text("compacted old context"), "new prompt".to_string(), ] ); model.send_completion_stream_text_chunk(&final_request, "answer"); model.end_completion_stream(&final_request); cx.run_until_parked(); cx.update(|cx| { thread.read_with(cx, |thread, _cx| { assert!(matches!(&*thread.messages[0], Message::User(_))); assert!(matches!(&*thread.messages[1], Message::Agent(_))); assert!(matches!( &*thread.messages[2], Message::Compaction(CompactionInfo::Summary(summary)) if summary.as_ref() == "compacted old context" )); assert!(matches!(&*thread.messages[3], Message::User(_))); }); }); } #[gpui::test] async fn test_manual_compact_forces_summary(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let model = Arc::new(FakeLanguageModel::default()); // A context window below the minimum and no recorded token usage would // both disable *automatic* compaction. Manual compaction forces it anyway. model.set_max_token_count(MIN_COMPACTION_CONTEXT_WINDOW - 1); let user_message_id = ClientUserMessageId::new(); let compact_message_id = ClientUserMessageId::new(); cx.update(|cx| { thread.update(cx, |thread, cx| { thread.set_model(model.clone(), cx); thread .messages .push(user_text_message(user_message_id.clone(), "old user")); thread.messages.push(agent_text_message("old assistant")); // Auto-compaction would be a no-op here. assert_eq!(thread.compaction_message_target_ix(cx), None); }); }); let _events = cx .update(|cx| { thread.update(cx, |thread, cx| { thread.compact(compact_message_id.clone(), cx) }) }) .unwrap(); cx.run_until_parked(); let compaction_request = model.pending_completions().pop().unwrap(); assert_eq!( compaction_request.intent, Some(CompletionIntent::ThreadContextSummarization) ); let compaction_texts = request_texts_after_system(&compaction_request.messages); assert_eq!(compaction_texts.len(), 3); assert_eq!(compaction_texts[0], "old user"); assert_eq!(compaction_texts[1], "old assistant"); assert_eq!(compaction_texts[2], COMPACTION_PROMPT); model.send_completion_stream_text_chunk(&compaction_request, "summary of old context"); model.end_completion_stream(&compaction_request); cx.run_until_parked(); // The compaction summary is appended after a zero-content user message // marker, and no follow-up model turn is requested — `/compact` only // compacts. assert!(model.pending_completions().is_empty()); cx.update(|cx| { thread.read_with(cx, |thread, _cx| { assert!(matches!(&*thread.messages[0], Message::User(_))); assert!(matches!(&*thread.messages[1], Message::Agent(_))); assert!(matches!( &*thread.messages[2], Message::User(UserMessage { id, content }) if id == &compact_message_id && content.is_empty() )); assert!(matches!( &*thread.messages[3], Message::Compaction(CompactionInfo::Summary(summary)) if summary.as_ref() == "summary of old context" )); // Re-running `/compact` with nothing new to summarize is a // no-op: the thread already ends in a compaction. assert_eq!(thread.forced_compaction_target_ix(), None); }); thread .update(cx, |thread, cx| thread.truncate(compact_message_id.clone(), cx)) .unwrap(); thread.read_with(cx, |thread, _cx| { assert_eq!(thread.messages.len(), 2); assert!(matches!(&*thread.messages[0], Message::User(_))); assert!(matches!(&*thread.messages[1], Message::Agent(_))); }); }); } /// Cancelling an in-flight manual compaction must not leave the zero-content /// rewind marker (or a partial summary) dangling at the end of the thread. #[gpui::test] async fn test_manual_compact_cancelled_leaves_no_marker(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let model = Arc::new(FakeLanguageModel::default()); cx.update(|cx| { thread.update(cx, |thread, cx| { thread.set_model(model.clone(), cx); thread .messages .push(user_text_message(ClientUserMessageId::new(), "old user")); thread.messages.push(agent_text_message("old assistant")); }); }); let _events = cx .update(|cx| { thread.update(cx, |thread, cx| { thread.compact(ClientUserMessageId::new(), cx) }) }) .unwrap(); cx.run_until_parked(); // The compaction request is in flight but hasn't streamed a summary. assert_eq!(model.pending_completions().len(), 1); cx.update(|cx| thread.update(cx, |thread, cx| thread.cancel(cx))) .await; cx.run_until_parked(); thread.read_with(cx, |thread, _cx| { assert_eq!(thread.messages.len(), 2); assert!(matches!(&*thread.messages[0], Message::User(_))); assert!(matches!(&*thread.messages[1], Message::Agent(_))); }); } /// A failed compaction (here, an empty summary) reports an error and leaves /// the thread untouched — no marker, no compaction. #[gpui::test] async fn test_manual_compact_empty_summary_leaves_no_marker(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let model = Arc::new(FakeLanguageModel::default()); cx.update(|cx| { thread.update(cx, |thread, cx| { thread.set_model(model.clone(), cx); thread .messages .push(user_text_message(ClientUserMessageId::new(), "old user")); thread.messages.push(agent_text_message("old assistant")); }); }); let mut events = cx .update(|cx| { thread.update(cx, |thread, cx| { thread.compact(ClientUserMessageId::new(), cx) }) }) .unwrap(); cx.run_until_parked(); let request = model.pending_completions().pop().unwrap(); // End the stream without emitting any summary text. model.end_completion_stream(&request); cx.run_until_parked(); // An error is surfaced, and the thread is left exactly as it was. The // compaction task drops the event stream after failing, so the channel // closes and this drain terminates. let mut saw_error = false; while let Some(event) = events.next().await { if event.is_err() { saw_error = true; } } assert!(saw_error, "expected an error event for the empty summary"); thread.read_with(cx, |thread, _cx| { assert_eq!(thread.messages.len(), 2); assert!(matches!(&*thread.messages[0], Message::User(_))); assert!(matches!(&*thread.messages[1], Message::Agent(_))); }); } /// `/compact` on an empty thread (nothing to summarize) is a no-op: it /// issues no model request and adds no marker. #[gpui::test] async fn test_manual_compact_noop_on_empty_thread(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let model = Arc::new(FakeLanguageModel::default()); cx.update(|cx| thread.update(cx, |thread, cx| thread.set_model(model.clone(), cx))); let _events = cx .update(|cx| { thread.update(cx, |thread, cx| { thread.compact(ClientUserMessageId::new(), cx) }) }) .unwrap(); cx.run_until_parked(); assert!(model.pending_completions().is_empty()); thread.read_with(cx, |thread, _cx| { assert!(thread.messages.is_empty()); }); } /// The zero-content marker replays as an empty user message, which the UI /// drops (it renders content blocks, of which there are none), so reloading /// a compacted thread doesn't surface an empty `/compact` bubble. #[gpui::test] async fn test_manual_compact_marker_replays_as_empty_user_message(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let marker_id = ClientUserMessageId::new(); let mut replay_events = cx.update(|cx| { thread.update(cx, |thread, cx| { thread .messages .push(user_text_message(ClientUserMessageId::new(), "before")); thread.messages.push(agent_text_message("answer")); thread.messages.push(Arc::new(Message::User(UserMessage { id: marker_id.clone(), content: Arc::from([]), }))); thread.messages.push(summary_compaction("summary")); thread.replay(cx) }) }); // Skip the leading "before"/"answer" replay events. let _ = replay_events.next().await; let _ = replay_events.next().await; let event = replay_events.next().await; match event { Some(Ok(ThreadEvent::UserMessage(message))) => { assert_eq!(message.id, marker_id); assert!( message.content.is_empty(), "marker should replay with no content so the UI renders nothing" ); } _ => panic!("expected the marker to replay as a user message, got {event:?}"), } let event = replay_events.next().await; assert!( matches!(&event, Some(Ok(ThreadEvent::ContextCompaction(_)))), "expected the compaction to replay after the marker, got {event:?}" ); } /// When `agent.compaction_model` is configured, manual `/compact` streams /// to the configured model rather than the thread's primary model. #[gpui::test] async fn test_compaction_uses_configured_compaction_model(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let thread_model = Arc::new(FakeLanguageModel::default()); let compaction_model = Arc::new(FakeLanguageModel::default()); cx.update(|cx| { thread.update(cx, |thread, cx| { thread.set_model(thread_model.clone(), cx); thread .messages .push(user_text_message(ClientUserMessageId::new(), "old user")); thread.messages.push(agent_text_message("old assistant")); }); set_registry_compaction_model(cx, Some(compaction_model.clone())); }); let _events = cx .update(|cx| { thread.update(cx, |thread, cx| { thread.compact(ClientUserMessageId::new(), cx) }) }) .unwrap(); cx.run_until_parked(); assert_eq!( thread_model.pending_completions().len(), 0, "thread's primary model should not have been used for compaction" ); let request = compaction_model .pending_completions() .pop() .expect("compaction model should have received the request"); assert_eq!( request.intent, Some(CompletionIntent::ThreadContextSummarization) ); thread.read_with(cx, |thread, _cx| { let telemetry = thread .pending_compaction_telemetry .as_ref() .expect("pending telemetry"); assert_eq!(telemetry.model, compaction_model.telemetry_id()); }); compaction_model.send_completion_stream_text_chunk(&request, "summary"); compaction_model.end_completion_stream(&request); cx.run_until_parked(); } /// When `agent.compaction_model` is configured but doesn't resolve (e.g. /// the provider isn't registered), manual `/compact` falls back to the /// thread's primary model and the telemetry reflects the actual stream /// model — not the one the user tried to configure. #[gpui::test] async fn test_compaction_falls_back_when_compaction_model_unavailable(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let thread_model = Arc::new(FakeLanguageModel::default()); cx.update(|cx| { thread.update(cx, |thread, cx| { thread.set_model(thread_model.clone(), cx); thread .messages .push(user_text_message(ClientUserMessageId::new(), "old user")); thread.messages.push(agent_text_message("old assistant")); }); // Settings say "configured"; registry says "couldn't resolve". let mut settings = AgentSettings::get_global(cx).clone(); settings.compaction_model = Some(LanguageModelSelection { provider: LanguageModelProviderSetting("missing".into()), model: "missing-model".into(), enable_thinking: false, effort: None, speed: None, }); AgentSettings::override_global(settings, cx); set_registry_compaction_model(cx, None); }); let _events = cx .update(|cx| { thread.update(cx, |thread, cx| { thread.compact(ClientUserMessageId::new(), cx) }) }) .unwrap(); cx.run_until_parked(); let request = thread_model .pending_completions() .pop() .expect("thread model should have received the fallback request"); assert_eq!( request.intent, Some(CompletionIntent::ThreadContextSummarization) ); thread.read_with(cx, |thread, _cx| { let telemetry = thread .pending_compaction_telemetry .as_ref() .expect("pending telemetry"); assert_eq!(telemetry.model, thread_model.telemetry_id()); }); thread_model.send_completion_stream_text_chunk(&request, "summary"); thread_model.end_completion_stream(&request); cx.run_until_parked(); } /// Auto-compaction triggered by the threshold also honors /// `agent.compaction_model`. #[gpui::test] async fn test_auto_compaction_uses_compaction_model(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let thread_model = Arc::new(FakeLanguageModel::default()); let compaction_model = Arc::new(FakeLanguageModel::default()); let old_user_message_id = ClientUserMessageId::new(); cx.update(|cx| { thread.update(cx, |thread, cx| { thread.set_model(thread_model.clone(), cx); thread .messages .push(user_text_message(old_user_message_id.clone(), "old user")); thread.messages.push(agent_text_message("old assistant")); thread.request_token_usage.insert( old_user_message_id.clone(), TokenUsage { input_tokens: u64::MAX, ..Default::default() }, ); }); set_auto_compact_settings( cx, agent_settings::AutoCompactSettings { enabled: true, threshold: agent_settings::AutoCompactThreshold::Percentage(0.5), }, ); set_registry_compaction_model(cx, Some(compaction_model.clone())); }); // The auto-compact gate fires inside `run_turn` when we kick off a // new user message. Drive a `send` so `perform_compaction_if_needed` // runs. let _events = cx .update(|cx| { thread.update(cx, |thread, cx| { thread.send(ClientUserMessageId::new(), vec!["new prompt"], cx) }) }) .unwrap(); cx.run_until_parked(); assert_eq!(thread_model.pending_completions().len(), 0); let request = compaction_model .pending_completions() .pop() .expect("compaction model should have received the auto-compaction request"); assert_eq!( request.intent, Some(CompletionIntent::ThreadContextSummarization) ); compaction_model.send_completion_stream_text_chunk(&request, "summary"); compaction_model.end_completion_stream(&request); cx.run_until_parked(); } #[gpui::test] async fn test_compaction_usage_counts_toward_cumulative_usage(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let model = Arc::new(FakeLanguageModel::default()); let old_user_message_id = ClientUserMessageId::new(); let new_user_message_id = ClientUserMessageId::new(); let prior_usage = TokenUsage { input_tokens: 960_000, output_tokens: 25, ..Default::default() }; let compaction_usage = TokenUsage { input_tokens: 40, output_tokens: 9, cache_creation_input_tokens: 2, cache_read_input_tokens: 3, }; let final_usage = TokenUsage { input_tokens: 500, output_tokens: 50, ..Default::default() }; cx.update(|cx| { thread.update(cx, |thread, cx| { thread.set_model(model.clone(), cx); thread .messages .push(user_text_message(old_user_message_id.clone(), "old user")); thread.messages.push(agent_text_message("old assistant")); thread .request_token_usage .insert(old_user_message_id.clone(), prior_usage); thread.cumulative_token_usage = prior_usage; thread.current_request_token_usage = prior_usage; }); }); let _events = cx .update(|cx| { thread.update(cx, |thread, cx| { thread.send(new_user_message_id.clone(), vec!["new prompt"], cx) }) }) .unwrap(); cx.run_until_parked(); let compaction_request = model.pending_completions().pop().unwrap(); assert_eq!( compaction_request.intent, Some(CompletionIntent::ThreadContextSummarization) ); model.send_completion_stream_event( &compaction_request, LanguageModelCompletionEvent::UsageUpdate(TokenUsage { input_tokens: 40, output_tokens: 4, ..Default::default() }), ); model.send_completion_stream_event( &compaction_request, LanguageModelCompletionEvent::UsageUpdate(compaction_usage), ); model.send_completion_stream_text_chunk(&compaction_request, "compacted old context"); model.end_completion_stream(&compaction_request); cx.run_until_parked(); let expected_after_compaction = prior_usage + compaction_usage; thread.read_with(cx, |thread, _cx| { assert_eq!(thread.cumulative_token_usage(), expected_after_compaction); assert!( !thread .request_token_usage .contains_key(&new_user_message_id) ); }); let final_request = model.pending_completions().pop().unwrap(); assert_eq!(final_request.intent, Some(CompletionIntent::UserPrompt)); model.send_completion_stream_event( &final_request, LanguageModelCompletionEvent::UsageUpdate(final_usage), ); model.end_completion_stream(&final_request); cx.run_until_parked(); thread.read_with(cx, |thread, _cx| { assert_eq!( thread.cumulative_token_usage(), expected_after_compaction + final_usage ); assert_eq!( thread.request_token_usage.get(&new_user_message_id), Some(&final_usage) ); }); } #[gpui::test] async fn test_replay_emits_context_compaction(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let user_message_id = ClientUserMessageId::new(); let mut replay_events = cx.update(|cx| { thread.update(cx, |thread, cx| { thread .messages .push(user_text_message(user_message_id.clone(), "before")); thread.messages.push(summary_compaction("summary")); thread.messages.push(agent_text_message("after")); thread.replay(cx) }) }); let event = replay_events.next().await; assert!( matches!( &event, Some(Ok(ThreadEvent::UserMessage(UserMessage { id, .. }))) if id == &user_message_id ), "expected replayed user message, got {event:?}" ); let event = replay_events.next().await; let compaction_id = match &event { Some(Ok(ThreadEvent::ContextCompaction(compaction))) => compaction.id.clone(), _ => panic!("expected context compaction event, got {event:?}"), }; let event = replay_events.next().await; assert!( matches!( &event, Some(Ok(ThreadEvent::ContextCompactionUpdate(update))) if update.id == compaction_id && update.summary_delta == "summary" ), "expected context compaction summary event, got {event:?}" ); let event = replay_events.next().await; assert!( matches!(&event, Some(Ok(ThreadEvent::AgentText(text))) if text == "after"), "expected replayed agent text, got {event:?}" ); } #[gpui::test] async fn test_native_compaction_boundary(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let request_messages = cx.update(|cx| { thread.update(cx, |thread, cx| { thread.messages.push(user_text_message( ClientUserMessageId::new(), "before native", )); thread.messages.push(Arc::new(Message::Compaction( CompactionInfo::ProviderNative { provider: LanguageModelProviderId::from("openai".to_string()), items: vec![json!({"type": "compaction"})], }, ))); thread.messages.push(user_text_message( ClientUserMessageId::new(), "after native", )); thread.build_request_messages(Vec::new(), cx) }) }); assert_eq!( request_texts_after_system(&request_messages), vec!["after native".to_string()] ); } #[gpui::test] async fn test_retained_users_truncate_oldest(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let mut long_text = "START".to_string(); long_text.push_str(&"x".repeat(COMPACTION_RETAINED_USER_MESSAGES_BYTE_BUDGET)); long_text.push_str("END"); let request_messages = cx.update(|cx| { thread.update(cx, |thread, cx| { thread.messages.push(user_text_message( ClientUserMessageId::new(), "dropped older user", )); thread .messages .push(agent_text_message("dropped assistant")); thread .messages .push(user_text_message(ClientUserMessageId::new(), &long_text)); thread .messages .push(user_text_message(ClientUserMessageId::new(), "new")); thread.messages.push(summary_compaction("summary context")); thread.messages.push(agent_text_message("after assistant")); thread .messages .push(user_text_message(ClientUserMessageId::new(), "after user")); thread.build_request_messages(Vec::new(), cx) }) }); let request_texts = request_texts_after_system(&request_messages); assert_eq!(request_texts.len(), 5); assert_eq!( request_texts[0], format!( "START{}", "x".repeat( COMPACTION_RETAINED_USER_MESSAGES_BYTE_BUDGET - "START".len() - "new".len() ) ) ); assert_eq!(request_texts[1], "new"); assert_eq!(request_texts[2], summary_request_text("summary context")); assert_eq!(request_texts[3], "after assistant"); assert_eq!(request_texts[4], "after user"); assert!(request_texts.iter().all( |text| !text.contains("dropped older user") && !text.contains("dropped assistant") )); } #[test] fn test_truncate_text_utf8_boundary() { let message = LanguageModelRequestMessage { role: Role::User, content: vec![MessageContent::Text("hello 👋 world".to_string())], cache: false, reasoning_details: None, }; let truncated = truncate_user_message_to_byte_budget(message, 8).unwrap(); assert_eq!( truncated.content, vec![MessageContent::Text("hello ".to_string())] ); } #[test] fn test_truncate_keeps_fitting_images() { let image = LanguageModelImage { source: "image".into(), }; let message = LanguageModelRequestMessage { role: Role::User, content: vec![ MessageContent::Text("abc".to_string()), MessageContent::Image(image.clone()), ], cache: false, reasoning_details: None, }; let truncated = truncate_user_message_to_byte_budget(message, 8).unwrap(); assert_eq!( truncated.content, vec![ MessageContent::Text("abc".to_string()), MessageContent::Image(image), ] ); } fn setup_parent_with_subagents( cx: &mut TestAppContext, parent: &Entity, count: usize, ) -> Vec> { 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 }) } struct ReplayImageTool; impl AgentTool for ReplayImageTool { type Input = (); type Output = String; const NAME: &'static str = "registered_image_tool"; fn kind() -> acp::ToolKind { acp::ToolKind::Other } fn initial_title( &self, _input: Result, _cx: &mut App, ) -> SharedString { "Registered Image Tool".into() } fn run( self: Arc, _input: ToolInput, _event_stream: ToolCallEventStream, _cx: &mut App, ) -> Task> { Task::ready(Ok(String::new())) } } #[gpui::test] async fn test_authorize_sandbox_allow_always_does_not_cache_thread_grant( cx: &mut TestAppContext, ) { crate::tests::init_test(cx); let (event_stream, mut receiver) = ToolCallEventStream::test(); let request = SandboxRequest { network: crate::sandboxing::NetworkRequest::None, allow_fs_write_all: false, unsandboxed: false, write_paths: vec![ settings::GrantedWritePath::from_requested(PathBuf::from("/tmp/build")), settings::GrantedWritePath::from_requested(PathBuf::from("/tmp/cache")), settings::GrantedWritePath::from_requested(PathBuf::from("/tmp/logs")), settings::GrantedWritePath::from_requested(PathBuf::from("/tmp/secret")), ], }; let authorize = cx.update(|cx| { event_stream.authorize_sandbox( request.clone(), "needs to write build artifacts".to_string(), cx, ) }); let authorization = receiver.expect_authorization().await; let details = acp_thread::sandbox_authorization_details_from_meta(&authorization.tool_call.meta) .expect("sandbox authorization should include request details"); assert!(details.network_hosts.is_empty()); assert!(!details.network_all_hosts); assert_eq!(details.allow_fs_write_all, request.allow_fs_write_all); assert_eq!(details.unsandboxed, request.unsandboxed); assert_eq!(details.write_paths, request.write_paths); assert!(authorization.tool_call.fields.content.is_none()); let acp_thread::PermissionOptions::Flat(options) = &authorization.options else { panic!("expected flat sandbox permission options"); }; let options = options .iter() .map(|option| { ( option.option_id.0.as_ref(), option.name.as_ref(), option.kind, ) }) .collect::>(); assert_eq!( options, vec![ ("allow", "Allow once", acp::PermissionOptionKind::AllowOnce), ( "allow_thread", "Allow for this thread", acp::PermissionOptionKind::AllowAlways, ), ( "allow_always", "Allow always", acp::PermissionOptionKind::AllowAlways, ), ("deny", "Deny", acp::PermissionOptionKind::RejectOnce), ] ); let send_result = authorization .response .send(acp_thread::SelectedPermissionOutcome::new( acp::PermissionOptionId::new("allow_always"), acp::PermissionOptionKind::AllowAlways, )); assert!(send_result.is_ok()); authorize.await.unwrap(); // "Allow always" persists to settings only let effective = event_stream.effective_sandbox_request( &SandboxRequest::default(), &agent_settings::SandboxPermissions::default(), ); assert!( effective.write_paths.is_empty(), "allow always should not record an in-memory thread grant: {:?}", effective.write_paths ); } #[cfg(target_os = "linux")] #[gpui::test] async fn test_authorize_sandbox_fallback_options_and_details(cx: &mut TestAppContext) { crate::tests::init_test(cx); let (event_stream, mut receiver) = ToolCallEventStream::test(); let authorize = cx.update(|cx| { event_stream.authorize_sandbox_fallback( Some("cargo build".to_string()), "bwrap not found on PATH".to_string(), Some("installing-bubblewrap".to_string()), 0, cx, ) }); let authorization = receiver.expect_authorization().await; let details = acp_thread::sandbox_fallback_authorization_details_from_meta( &authorization.tool_call.meta, ) .expect("fallback authorization should include details"); assert_eq!(details.command.as_deref(), Some("cargo build")); assert_eq!(details.reason, "bwrap not found on PATH"); assert_eq!( details.docs_section.as_deref(), Some("installing-bubblewrap") ); let acp_thread::PermissionOptions::Flat(options) = &authorization.options else { panic!("expected flat fallback permission options"); }; let options = options .iter() .map(|option| (option.option_id.0.as_ref(), option.name.as_ref())) .collect::>(); assert_eq!( options, vec![ ("retry", "Retry"), ("allow", "Run without sandbox once"), ("allow_thread", "Run without sandbox for this thread"), ("allow_always", "Always run without sandbox"), ("deny", "Deny"), ] ); authorization .response .send(acp_thread::SelectedPermissionOutcome::new( acp::PermissionOptionId::new(acp_thread::SANDBOX_FALLBACK_RETRY_OPTION_ID), acp::PermissionOptionKind::RejectAlways, )) .unwrap(); assert_eq!(authorize.await.unwrap(), SandboxFallbackDecision::Retry); } #[cfg(target_os = "linux")] #[gpui::test] async fn test_authorize_sandbox_fallback_retry_label_counts_attempts(cx: &mut TestAppContext) { crate::tests::init_test(cx); async fn retry_label(cx: &mut TestAppContext, retries: usize) -> String { let (event_stream, mut receiver) = ToolCallEventStream::test(); let authorize = cx.update(|cx| { event_stream.authorize_sandbox_fallback( None, "probe failed".to_string(), None, retries, cx, ) }); let authorization = receiver.expect_authorization().await; let acp_thread::PermissionOptions::Flat(options) = &authorization.options else { panic!("expected flat fallback permission options"); }; let label = options .iter() .find(|option| { option.option_id.0.as_ref() == acp_thread::SANDBOX_FALLBACK_RETRY_OPTION_ID }) .expect("retry option present") .name .to_string(); authorization .response .send(acp_thread::SelectedPermissionOutcome::new( acp::PermissionOptionId::new(acp_thread::SANDBOX_FALLBACK_RETRY_OPTION_ID), acp::PermissionOptionKind::RejectAlways, )) .unwrap(); authorize.await.unwrap(); label } assert_eq!(retry_label(cx, 0).await, "Retry"); assert_eq!(retry_label(cx, 1).await, "Retry (attempt 1)"); assert_eq!(retry_label(cx, 2).await, "Retry (attempt 2)"); } #[cfg(target_os = "linux")] #[gpui::test] async fn test_authorize_sandbox_fallback_allow_thread_records_grant(cx: &mut TestAppContext) { crate::tests::init_test(cx); let (event_stream, mut receiver) = ToolCallEventStream::test(); assert!(!event_stream.sandbox_fallback_granted_for_thread()); let authorize = cx.update(|cx| { event_stream.authorize_sandbox_fallback( Some("cargo build".to_string()), "user namespaces are disabled".to_string(), None, 0, cx, ) }); let authorization = receiver.expect_authorization().await; authorization .response .send(acp_thread::SelectedPermissionOutcome::new( acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowThread.as_id()), acp::PermissionOptionKind::AllowAlways, )) .unwrap(); assert_eq!( authorize.await.unwrap(), SandboxFallbackDecision::RunUnsandboxed ); // The thread-scoped grant now lets later commands skip the sandbox // without prompting again. assert!(event_stream.sandbox_fallback_granted_for_thread()); } #[cfg(target_os = "linux")] #[gpui::test] async fn test_authorize_sandbox_fallback_deny(cx: &mut TestAppContext) { crate::tests::init_test(cx); let (event_stream, mut receiver) = ToolCallEventStream::test(); let authorize = cx.update(|cx| { event_stream.authorize_sandbox_fallback( None, "bwrap probe failed".to_string(), None, 0, cx, ) }); let authorization = receiver.expect_authorization().await; authorization .response .send(acp_thread::SelectedPermissionOutcome::new( acp::PermissionOptionId::new(acp_thread::SandboxPermission::Deny.as_id()), acp::PermissionOptionKind::RejectOnce, )) .unwrap(); assert_eq!(authorize.await.unwrap(), SandboxFallbackDecision::Deny); assert!(!event_stream.sandbox_fallback_granted_for_thread()); } #[test] fn test_auto_resolve_permission_outcome_uses_once_only_options() { let options = acp_thread::PermissionOptions::Dropdown(vec![ acp_thread::PermissionOptionChoice { allow: acp::PermissionOption::new( acp::PermissionOptionId::new("always_allow:test_tool"), "Always allow", acp::PermissionOptionKind::AllowAlways, ), deny: acp::PermissionOption::new( acp::PermissionOptionId::new("always_deny:test_tool"), "Always deny", acp::PermissionOptionKind::RejectAlways, ), sub_patterns: vec![], }, acp_thread::PermissionOptionChoice { allow: acp::PermissionOption::new( acp::PermissionOptionId::new("allow"), "Allow once", acp::PermissionOptionKind::AllowOnce, ), deny: acp::PermissionOption::new( acp::PermissionOptionId::new("deny"), "Deny once", acp::PermissionOptionKind::RejectOnce, ), sub_patterns: vec![], }, ]); let allow = auto_resolve_permission_outcome(&options, true) .expect("allow auto-resolve should use once-only option"); assert_eq!(allow.option_id, acp::PermissionOptionId::new("allow")); assert_eq!(allow.option_kind, acp::PermissionOptionKind::AllowOnce); let deny = auto_resolve_permission_outcome(&options, false) .expect("deny auto-resolve should use once-only option"); assert_eq!(deny.option_id, acp::PermissionOptionId::new("deny")); assert_eq!(deny.option_kind, acp::PermissionOptionKind::RejectOnce); } #[gpui::test] async fn test_replay_tool_call_replays_image_content(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; let registered_tool_use_id = LanguageModelToolUseId::from("registered_tool_id"); let missing_tool_use_id = LanguageModelToolUseId::from("missing_tool_id"); let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; let image = LanguageModelImage { source: image_data.into(), }; let mut replay_events = cx.update(|cx| { thread.update(cx, |thread, cx| { thread.add_tool(ReplayImageTool); let registered_tool_use = LanguageModelToolUse { id: registered_tool_use_id.clone(), name: ReplayImageTool::NAME.into(), raw_input: "null".to_string(), input: language_model::LanguageModelToolUseInput::Json(json!(null)), is_input_complete: true, thought_signature: None, }; let missing_tool_use = LanguageModelToolUse { id: missing_tool_use_id.clone(), name: "missing_image_tool".into(), raw_input: "{}".to_string(), input: language_model::LanguageModelToolUseInput::Json(json!({})), is_input_complete: true, thought_signature: None, }; let mut tool_results = IndexMap::default(); tool_results.insert( registered_tool_use_id.clone(), LanguageModelToolResult { tool_use_id: registered_tool_use_id.clone(), tool_name: ReplayImageTool::NAME.into(), is_error: false, content: vec![ LanguageModelToolResultContent::Text("before".into()), LanguageModelToolResultContent::Image(image.clone()), LanguageModelToolResultContent::Text("after".into()), ], output: Some(json!("raw output")), }, ); tool_results.insert( missing_tool_use_id.clone(), LanguageModelToolResult { tool_use_id: missing_tool_use_id.clone(), tool_name: "missing_image_tool".into(), is_error: false, content: vec![LanguageModelToolResultContent::Image(image.clone())], output: Some(json!("raw output")), }, ); thread.messages.push(Arc::new(Message::Agent(AgentMessage { content: vec![ AgentMessageContent::ToolUse(registered_tool_use), AgentMessageContent::ToolUse(missing_tool_use), ], tool_results, reasoning_details: None, }))); thread.replay(cx) }) }); let mut tool_use_ids_with_image_content = HashSet::default(); while let Some(event) = replay_events.next().await { let event = event.unwrap(); if let ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) = event && let Some(content) = &update.fields.content && content.iter().any(|content| { matches!( content, acp::ToolCallContent::Content(acp::Content { content: acp::ContentBlock::Image(_), .. }) ) }) { tool_use_ids_with_image_content.insert(update.tool_call_id.to_string()); } } // Both tool uses live in the message pushed above, at index 0 (see // `scoped_tool_call_id`). assert!( tool_use_ids_with_image_content .contains(&scoped_tool_call_id(0, ®istered_tool_use_id).to_string()) ); assert!( tool_use_ids_with_image_content .contains(&scoped_tool_call_id(0, &missing_tool_use_id).to_string()) ); } #[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 = 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 = 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 = Arc::from("test_tool"); let raw_input: Arc = 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 (_owning_message_ix, 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, language_model::LanguageModelToolUseInput::Json(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)); }) } }