## Objective Related conversations can’t share an OpenAI cache key without also sharing a thread ID. We currently derive `prompt_cache_key` directly from `thread_id`, tying cache reuse to conversation identity. ## Solution Add an optional `prompt_cache_key` to `LanguageModelRequest` and use it in both OpenAI Chat Completions and Responses conversions. We still fall back to `thread_id` when no key is supplied, so existing callers keep their current behavior. This lets callers share a cache key across parent and child conversations while keeping their conversation and transport identities separate. ## Testing Added regression coverage for explicit keys, legacy fallback, provider capability gating, and serialization compatibility. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - N/A
9233 lines
350 KiB
Rust
9233 lines
350 KiB
Rust
use crate::{
|
|
ApplyCodeActionTool, AskUserTool, 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, LanguageModelToolUse,
|
|
LanguageModelToolUseId, MessageContent, ProviderErrorCategory, 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<PathBuf>,
|
|
pub git_paths: Vec<PathBuf>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct VerifiedSandboxStatus {
|
|
pub settings_sandbox: ThreadSandbox,
|
|
pub thread_sandbox: ThreadSandbox,
|
|
pub baseline_writable_paths: Vec<PathBuf>,
|
|
}
|
|
|
|
pub enum SandboxStatusRefresh {
|
|
Ready(VerifiedSandboxStatus),
|
|
Pending(Task<VerifiedSandboxStatus>),
|
|
}
|
|
|
|
/// 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<str>);
|
|
|
|
impl PromptId {
|
|
pub fn new() -> Self {
|
|
Self(Uuid::new_v4().to_string().into())
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for PromptId {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}", self.0)
|
|
}
|
|
}
|
|
|
|
pub(crate) const MAX_RETRY_ATTEMPTS: u8 = 4;
|
|
pub(crate) const BASE_RETRY_DELAY: Duration = Duration::from_secs(5);
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
enum RetryStrategy {
|
|
ExponentialBackoff,
|
|
FixedDelay { delay: Duration, max_attempts: u8 },
|
|
}
|
|
|
|
impl RetryStrategy {
|
|
fn delay_after(&self, error: &LanguageModelCompletionError, attempt: u8) -> Option<Duration> {
|
|
if attempt == 0 {
|
|
return None;
|
|
}
|
|
match self {
|
|
RetryStrategy::ExponentialBackoff => (attempt <= MAX_RETRY_ATTEMPTS)
|
|
.then(|| error.retry_delay(attempt as usize))
|
|
.flatten(),
|
|
RetryStrategy::FixedDelay {
|
|
delay,
|
|
max_attempts,
|
|
} => (attempt <= *max_attempts).then_some(*delay),
|
|
}
|
|
}
|
|
|
|
fn max_attempts(&self) -> u8 {
|
|
match self {
|
|
RetryStrategy::ExponentialBackoff => MAX_RETRY_ATTEMPTS,
|
|
RetryStrategy::FixedDelay { max_attempts, .. } => *max_attempts,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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<serde_json::Value>,
|
|
},
|
|
}
|
|
|
|
impl CompactionInfo {
|
|
fn to_request(&self) -> Vec<LanguageModelRequestMessage> {
|
|
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<LanguageModelRequestMessage> {
|
|
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(CompactionInfo::Summary(summary)) => {
|
|
format!("## Context Compaction (Completed)\n\n{summary}\n\n")
|
|
}
|
|
Message::Compaction(CompactionInfo::ProviderNative { .. }) => {
|
|
"## Context Compaction (Completed)\n\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("<image />\n");
|
|
}
|
|
UserMessageContent::Mention { uri, content } => {
|
|
if !content.is_empty() {
|
|
let _ = writeln!(&mut markdown, "{}\n\n{}", uri.as_link(), content);
|
|
} else {
|
|
let _ = writeln!(&mut markdown, "{}", uri.as_link());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
markdown
|
|
}
|
|
|
|
fn to_request(&self) -> LanguageModelRequestMessage {
|
|
let mut message = LanguageModelRequestMessage {
|
|
role: Role::User,
|
|
content: Vec::with_capacity(self.content.len()),
|
|
cache: false,
|
|
reasoning_details: None,
|
|
};
|
|
|
|
const OPEN_CONTEXT: &str = "<context>\n\
|
|
The following items were attached by the user. \
|
|
They are up-to-date and don't need to be re-read.\n\n";
|
|
|
|
const OPEN_FILES_TAG: &str = "<files>";
|
|
const OPEN_DIRECTORIES_TAG: &str = "<directories>";
|
|
const OPEN_SYMBOLS_TAG: &str = "<symbols>";
|
|
const OPEN_SELECTIONS_TAG: &str = "<selections>";
|
|
const OPEN_THREADS_TAG: &str = "<threads>";
|
|
const OPEN_FETCH_TAG: &str = "<fetched_urls>";
|
|
const OPEN_RULES_TAG: &str =
|
|
"<rules>\nThe user has specified the following rules that should be applied:\n";
|
|
const OPEN_DIAGNOSTICS_TAG: &str = "<diagnostics>";
|
|
const OPEN_DIFFS_TAG: &str = "<diffs>";
|
|
const MERGE_CONFLICT_TAG: &str = "<merge_conflicts>";
|
|
const OPEN_SKILLS_TAG: &str =
|
|
"<skills>\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("</files>\n");
|
|
message
|
|
.content
|
|
.push(language_model::MessageContent::Text(file_context));
|
|
}
|
|
|
|
if directory_context.len() > OPEN_DIRECTORIES_TAG.len() {
|
|
directory_context.push_str("</directories>\n");
|
|
message
|
|
.content
|
|
.push(language_model::MessageContent::Text(directory_context));
|
|
}
|
|
|
|
if symbol_context.len() > OPEN_SYMBOLS_TAG.len() {
|
|
symbol_context.push_str("</symbols>\n");
|
|
message
|
|
.content
|
|
.push(language_model::MessageContent::Text(symbol_context));
|
|
}
|
|
|
|
if selection_context.len() > OPEN_SELECTIONS_TAG.len() {
|
|
selection_context.push_str("</selections>\n");
|
|
message
|
|
.content
|
|
.push(language_model::MessageContent::Text(selection_context));
|
|
}
|
|
|
|
if diffs_context.len() > OPEN_DIFFS_TAG.len() {
|
|
diffs_context.push_str("</diffs>\n");
|
|
message
|
|
.content
|
|
.push(language_model::MessageContent::Text(diffs_context));
|
|
}
|
|
|
|
if thread_context.len() > OPEN_THREADS_TAG.len() {
|
|
thread_context.push_str("</threads>\n");
|
|
message
|
|
.content
|
|
.push(language_model::MessageContent::Text(thread_context));
|
|
}
|
|
|
|
if fetch_context.len() > OPEN_FETCH_TAG.len() {
|
|
fetch_context.push_str("</fetched_urls>\n");
|
|
message
|
|
.content
|
|
.push(language_model::MessageContent::Text(fetch_context));
|
|
}
|
|
|
|
if rules_context.len() > OPEN_RULES_TAG.len() {
|
|
rules_context.push_str("</user_rules>\n");
|
|
message
|
|
.content
|
|
.push(language_model::MessageContent::Text(rules_context));
|
|
}
|
|
|
|
if diagnostics_context.len() > OPEN_DIAGNOSTICS_TAG.len() {
|
|
diagnostics_context.push_str("</diagnostics>\n");
|
|
message
|
|
.content
|
|
.push(language_model::MessageContent::Text(diagnostics_context));
|
|
}
|
|
|
|
if skills_context.len() > OPEN_SKILLS_TAG.len() {
|
|
skills_context.push_str("</skills>\n");
|
|
message
|
|
.content
|
|
.push(language_model::MessageContent::Text(skills_context));
|
|
}
|
|
|
|
if merge_conflict_context.len() > MERGE_CONFLICT_TAG.len() {
|
|
merge_conflict_context.push_str("</merge_conflicts>\n");
|
|
message
|
|
.content
|
|
.push(language_model::MessageContent::Text(merge_conflict_context));
|
|
}
|
|
|
|
if message.content.len() > len_before_context {
|
|
message.content.insert(
|
|
len_before_context,
|
|
language_model::MessageContent::Text(OPEN_CONTEXT.into()),
|
|
);
|
|
message
|
|
.content
|
|
.push(language_model::MessageContent::Text("</context>".into()));
|
|
}
|
|
|
|
message
|
|
}
|
|
}
|
|
|
|
fn codeblock_tag(full_path: &Path, line_range: Option<&RangeInclusive<u32>>) -> String {
|
|
let mut result = String::new();
|
|
|
|
if let Some(extension) = full_path.extension().and_then(|ext| ext.to_str()) {
|
|
let _ = write!(result, "{} ", extension);
|
|
}
|
|
|
|
let _ = write!(result, "{}", full_path.display());
|
|
|
|
if let Some(range) = line_range {
|
|
if range.start() == range.end() {
|
|
let _ = write!(result, ":{}", range.start() + 1);
|
|
} else {
|
|
let _ = write!(result, ":{}-{}", range.start() + 1, range.end() + 1);
|
|
}
|
|
}
|
|
|
|
result
|
|
}
|
|
|
|
impl AgentMessage {
|
|
pub fn to_markdown(&self) -> String {
|
|
let mut markdown = String::new();
|
|
|
|
for content in &self.content {
|
|
match content {
|
|
AgentMessageContent::Text(text) => {
|
|
markdown.push_str(text);
|
|
markdown.push('\n');
|
|
}
|
|
AgentMessageContent::Thinking { text, .. } => {
|
|
markdown.push_str("<think>");
|
|
markdown.push_str(text);
|
|
markdown.push_str("</think>\n");
|
|
}
|
|
AgentMessageContent::RedactedThinking(_) => {
|
|
markdown.push_str("<redacted_thinking />\n")
|
|
}
|
|
AgentMessageContent::ToolUse(tool_use) => {
|
|
markdown.push_str(&format!(
|
|
"**Tool Use**: {} (ID: {})\n",
|
|
tool_use.name, tool_use.id
|
|
));
|
|
markdown.push_str(&format!(
|
|
"{}\n",
|
|
MarkdownCodeBlock {
|
|
tag: "json",
|
|
text: &format!("{:#}", tool_use.input.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, "<image />\n").ok();
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(output) = tool_result.output.as_ref() {
|
|
writeln!(
|
|
markdown,
|
|
"**Debug Output**:\n\n```json\n{}\n```\n",
|
|
serde_json::to_string_pretty(output).unwrap()
|
|
)
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
markdown
|
|
}
|
|
|
|
pub fn to_request(&self) -> Vec<LanguageModelRequestMessage> {
|
|
let mut assistant_message = LanguageModelRequestMessage {
|
|
role: Role::Assistant,
|
|
content: Vec::with_capacity(self.content.len()),
|
|
cache: false,
|
|
reasoning_details: self.reasoning_details.clone(),
|
|
};
|
|
for chunk in &self.content {
|
|
match chunk {
|
|
AgentMessageContent::Text(text) => {
|
|
assistant_message
|
|
.content
|
|
.push(language_model::MessageContent::Text(text.clone()));
|
|
}
|
|
AgentMessageContent::Thinking { text, signature } => {
|
|
assistant_message
|
|
.content
|
|
.push(language_model::MessageContent::Thinking {
|
|
text: text.clone(),
|
|
signature: signature.clone(),
|
|
});
|
|
}
|
|
AgentMessageContent::RedactedThinking(value) => {
|
|
assistant_message.content.push(
|
|
language_model::MessageContent::RedactedThinking(value.clone()),
|
|
);
|
|
}
|
|
AgentMessageContent::ToolUse(tool_use) => {
|
|
if self.tool_results.contains_key(&tool_use.id) {
|
|
assistant_message
|
|
.content
|
|
.push(language_model::MessageContent::ToolUse(tool_use.clone()));
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
let mut user_message = LanguageModelRequestMessage {
|
|
role: Role::User,
|
|
content: Vec::new(),
|
|
cache: false,
|
|
reasoning_details: None,
|
|
};
|
|
|
|
for tool_result in self.tool_results.values() {
|
|
let mut tool_result = tool_result.clone();
|
|
// Surprisingly, the API fails if we return an empty string here.
|
|
// It thinks we are sending a tool use without a tool result.
|
|
if tool_result.is_content_empty() {
|
|
tool_result.content = vec!["<Tool returned an empty string>".into()];
|
|
}
|
|
user_message
|
|
.content
|
|
.push(language_model::MessageContent::ToolResult(tool_result));
|
|
}
|
|
|
|
let mut messages = Vec::new();
|
|
if !assistant_message.content.is_empty() {
|
|
messages.push(assistant_message);
|
|
}
|
|
if !user_message.content.is_empty() {
|
|
messages.push(user_message);
|
|
}
|
|
messages
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct AgentMessage {
|
|
pub(crate) content: Vec<AgentMessageContent>,
|
|
pub(crate) tool_results: IndexMap<LanguageModelToolUseId, LanguageModelToolResult>,
|
|
pub(crate) reasoning_details: Option<Arc<serde_json::Value>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum AgentMessageContent {
|
|
Text(String),
|
|
Thinking {
|
|
text: String,
|
|
signature: Option<String>,
|
|
},
|
|
RedactedThinking(String),
|
|
ToolUse(LanguageModelToolUse),
|
|
}
|
|
|
|
pub trait TerminalHandle {
|
|
fn id(&self, cx: &AsyncApp) -> Result<acp::TerminalId>;
|
|
fn current_output(&self, cx: &AsyncApp) -> Result<acp::TerminalOutputResponse>;
|
|
fn wait_for_exit(&self, cx: &AsyncApp) -> Result<Shared<Task<acp::TerminalExitStatus>>>;
|
|
fn kill(&self, cx: &AsyncApp) -> Result<()>;
|
|
fn was_stopped_by_user(&self, cx: &AsyncApp) -> Result<bool>;
|
|
}
|
|
|
|
pub trait SubagentHandle {
|
|
/// The session ID of this subagent thread
|
|
fn id(&self) -> acp::SessionId;
|
|
/// The current number of entries in the thread.
|
|
/// Useful for knowing where the next turn will begin
|
|
fn num_entries(&self, cx: &App) -> usize;
|
|
/// Runs a turn for a given message and returns both the response and the index of that output message.
|
|
fn send(&self, message: String, cx: &AsyncApp) -> Task<Result<String>>;
|
|
}
|
|
|
|
pub trait ThreadEnvironment {
|
|
fn create_terminal(
|
|
&self,
|
|
command: String,
|
|
extra_env: Vec<acp::EnvVariable>,
|
|
cwd: Option<PathBuf>,
|
|
output_byte_limit: Option<u64>,
|
|
sandbox_wrap: Option<acp_thread::SandboxWrap>,
|
|
cx: &mut AsyncApp,
|
|
) -> Task<Result<Rc<dyn TerminalHandle>>>;
|
|
|
|
fn create_subagent(&self, label: String, cx: &mut App) -> Result<Rc<dyn SubagentHandle>>;
|
|
|
|
fn resume_subagent(
|
|
&self,
|
|
_session_id: acp::SessionId,
|
|
_cx: &mut App,
|
|
) -> Result<Rc<dyn SubagentHandle>> {
|
|
Err(anyhow::anyhow!(
|
|
"Resuming subagent sessions is not supported"
|
|
))
|
|
}
|
|
|
|
/// 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<Result<SiblingThreadInfo>> {
|
|
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<AvailableAgents> {
|
|
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<String>,
|
|
/// Optional model override, as `provider/model-id`.
|
|
/// Defaults to the user's configured default model for the agent.
|
|
pub model: Option<String>,
|
|
/// 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<String>,
|
|
/// Git ref (branch, tag, or commit) to base the new worktree on.
|
|
/// Only relevant when `use_new_worktree` is true.
|
|
pub base_ref: Option<String>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// A list of agents and, for each, the models available for use.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct AvailableAgents {
|
|
pub agents: Vec<AvailableAgent>,
|
|
}
|
|
|
|
#[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<AvailableModel>,
|
|
}
|
|
|
|
#[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,
|
|
},
|
|
Elicitation(ElicitationRequest),
|
|
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<u64>,
|
|
pub cwd: Option<PathBuf>,
|
|
pub response: oneshot::Sender<Result<Entity<acp_thread::Terminal>>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ToolPermissionContext {
|
|
pub tool_name: String,
|
|
pub input_values: Vec<String>,
|
|
pub scope: ToolPermissionScope,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ToolPermissionScope {
|
|
ToolInput,
|
|
SymlinkTarget,
|
|
AgentSkills,
|
|
}
|
|
|
|
impl ToolPermissionContext {
|
|
pub fn new(tool_name: impl Into<String>, input_values: Vec<String>) -> Self {
|
|
Self {
|
|
tool_name: tool_name.into(),
|
|
input_values,
|
|
scope: ToolPermissionScope::ToolInput,
|
|
}
|
|
}
|
|
|
|
pub fn symlink_target(tool_name: impl Into<String>, target_paths: Vec<String>) -> Self {
|
|
Self {
|
|
tool_name: tool_name.into(),
|
|
input_values: target_paths,
|
|
scope: ToolPermissionScope::SymlinkTarget,
|
|
}
|
|
}
|
|
|
|
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<String>, Option<String>) {
|
|
if tool_name == TerminalTool::NAME {
|
|
(
|
|
extract_terminal_pattern(value),
|
|
extract_terminal_pattern_display(value),
|
|
)
|
|
} else if tool_name == CopyPathTool::NAME
|
|
|| tool_name == MovePathTool::NAME
|
|
|| tool_name == EditFileTool::NAME
|
|
|| tool_name == WriteFileTool::NAME
|
|
|| tool_name == DeletePathTool::NAME
|
|
|| tool_name == CreateDirectoryTool::NAME
|
|
{
|
|
(
|
|
extract_path_pattern(value),
|
|
extract_path_pattern_display(value),
|
|
)
|
|
} else if tool_name == FetchTool::NAME {
|
|
(
|
|
extract_url_pattern(value),
|
|
extract_url_pattern_display(value),
|
|
)
|
|
} else {
|
|
(None, None)
|
|
}
|
|
};
|
|
|
|
// Extract patterns from all input values. Only offer a pattern-specific
|
|
// "always allow/deny" button when every value produces the same pattern.
|
|
let (pattern, pattern_display) = match input_values.as_slice() {
|
|
[single] => extract_for_value(single),
|
|
_ => {
|
|
let mut iter = input_values.iter().map(|v| extract_for_value(v));
|
|
match iter.next() {
|
|
Some(first) => {
|
|
if iter.all(|pair| pair.0 == first.0) {
|
|
first
|
|
} else {
|
|
(None, None)
|
|
}
|
|
}
|
|
None => (None, None),
|
|
}
|
|
}
|
|
};
|
|
|
|
let mut choices = Vec::new();
|
|
|
|
let mut push_choice =
|
|
|label: String, allow_id, deny_id, allow_kind, deny_kind, sub_patterns: Vec<String>| {
|
|
choices.push(acp_thread::PermissionOptionChoice {
|
|
allow: acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new(allow_id),
|
|
label.clone(),
|
|
allow_kind,
|
|
),
|
|
deny: acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new(deny_id),
|
|
label,
|
|
deny_kind,
|
|
),
|
|
sub_patterns,
|
|
});
|
|
};
|
|
|
|
if shell_supports_always_allow {
|
|
push_choice(
|
|
format!("Always for {}", tool_name.replace('_', " ")),
|
|
format!("always_allow:{}", tool_name),
|
|
format!("always_deny:{}", tool_name),
|
|
acp::PermissionOptionKind::AllowAlways,
|
|
acp::PermissionOptionKind::RejectAlways,
|
|
vec![],
|
|
);
|
|
|
|
if let (Some(pattern), Some(display)) = (pattern, pattern_display) {
|
|
let button_text = if tool_name == TerminalTool::NAME {
|
|
format!("Always for `{}` commands", display)
|
|
} else {
|
|
format!("Always for `{}`", display)
|
|
};
|
|
push_choice(
|
|
button_text,
|
|
format!("always_allow:{}", tool_name),
|
|
format!("always_deny:{}", tool_name),
|
|
acp::PermissionOptionKind::AllowAlways,
|
|
acp::PermissionOptionKind::RejectAlways,
|
|
vec![pattern],
|
|
);
|
|
}
|
|
}
|
|
|
|
push_choice(
|
|
"Only this time".to_string(),
|
|
"allow".to_string(),
|
|
"deny".to_string(),
|
|
acp::PermissionOptionKind::AllowOnce,
|
|
acp::PermissionOptionKind::RejectOnce,
|
|
vec![],
|
|
);
|
|
|
|
acp_thread::PermissionOptions::Dropdown(choices)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct ToolCallAuthorization {
|
|
pub tool_call: acp::ToolCallUpdate,
|
|
pub options: acp_thread::PermissionOptions,
|
|
pub response: oneshot::Sender<acp_thread::SelectedPermissionOutcome>,
|
|
pub context: Option<ToolPermissionContext>,
|
|
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(())
|
|
}
|
|
}
|
|
|
|
/// A request from a tool to elicit structured input from the user via a form.
|
|
///
|
|
/// The scope (session id) is filled in by the connection layer when the request
|
|
/// is handed to the [`AcpThread`], so tools only supply the form schema and the
|
|
/// message to display.
|
|
#[derive(Debug)]
|
|
pub struct ElicitationRequest {
|
|
pub tool_call_id: acp::ToolCallId,
|
|
pub message: String,
|
|
pub schema: acp::ElicitationSchema,
|
|
pub response: oneshot::Sender<acp::CreateElicitationResponse>,
|
|
}
|
|
|
|
fn auto_resolve_permission_outcome(
|
|
options: &acp_thread::PermissionOptions,
|
|
is_allow: bool,
|
|
) -> Result<acp_thread::SelectedPermissionOutcome> {
|
|
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<dyn LanguageModel>),
|
|
Unresolved(SelectedModel),
|
|
Unset,
|
|
}
|
|
|
|
impl ThreadModel {
|
|
fn as_model(&self) -> Option<&Arc<dyn LanguageModel>> {
|
|
match self {
|
|
Self::Ready(model) => Some(model),
|
|
Self::Unresolved(_) | Self::Unset => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&ThreadModel> for Option<DbLanguageModel> {
|
|
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<Utc>,
|
|
title: Option<SharedString>,
|
|
pending_title_generation: Option<Task<()>>,
|
|
title_generation_error: Option<SharedString>,
|
|
pending_summary_generation: Option<Shared<Task<Option<SharedString>>>>,
|
|
summary: Option<SharedString>,
|
|
messages: Vec<Arc<Message>>,
|
|
user_store: Entity<UserStore>,
|
|
/// Holds the task that handles agent interaction until the end of the turn.
|
|
/// Survives across multiple requests as the model performs tool calls and
|
|
/// we run tools, report their results.
|
|
running_turn: Option<RunningTurn>,
|
|
/// 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<AgentMessage>,
|
|
pub(crate) tools: BTreeMap<SharedString, Arc<dyn AnyAgentTool>>,
|
|
request_token_usage: HashMap<ClientUserMessageId, language_model::TokenUsage>,
|
|
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<CompactionTelemetry>,
|
|
#[allow(unused)]
|
|
initial_project_snapshot: Shared<Task<Option<Arc<ProjectSnapshot>>>>,
|
|
pub(crate) context_server_registry: Entity<ContextServerRegistry>,
|
|
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<ProjectContext>,
|
|
pub(crate) templates: Arc<Templates>,
|
|
model: ThreadModel,
|
|
summarization_model: Option<Arc<dyn LanguageModel>>,
|
|
thinking_enabled: bool,
|
|
thinking_effort: Option<String>,
|
|
speed: Option<Speed>,
|
|
prompt_capabilities_tx: watch::Sender<acp::PromptCapabilities>,
|
|
pub(crate) prompt_capabilities_rx: watch::Receiver<acp::PromptCapabilities>,
|
|
pub(crate) project: Entity<Project>,
|
|
pub(crate) action_log: Entity<ActionLog>,
|
|
/// If this is a subagent thread, contains context about the parent
|
|
subagent_context: Option<SubagentContext>,
|
|
/// The user's unsent prompt text, persisted so it can be restored when reloading the thread.
|
|
draft_prompt: Option<Vec<acp::ContentBlock>>,
|
|
ui_scroll_position: Option<gpui::ListOffset>,
|
|
/// Weak references to running subagent threads for cancellation propagation
|
|
running_subagents: Vec<WeakEntity<Thread>>,
|
|
inherits_parent_model_settings: bool,
|
|
sandboxed_terminal_temp_dir: Option<PathBuf>,
|
|
/// 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<RefCell<ThreadSandboxGrants>>,
|
|
}
|
|
|
|
impl Thread {
|
|
fn prompt_capabilities(model: Option<&dyn LanguageModel>) -> acp::PromptCapabilities {
|
|
let image = model.map_or(true, |model| model.supports_images());
|
|
acp::PromptCapabilities::new()
|
|
.image(image)
|
|
.embedded_context(true)
|
|
}
|
|
|
|
pub fn new_subagent(parent_thread: &Entity<Thread>, cx: &mut Context<Self>) -> Self {
|
|
let project = parent_thread.read(cx).project.clone();
|
|
let project_context = parent_thread.read(cx).project_context.clone();
|
|
let context_server_registry = parent_thread.read(cx).context_server_registry.clone();
|
|
let templates = parent_thread.read(cx).templates.clone();
|
|
let model = parent_thread.read(cx).model().cloned();
|
|
let parent_action_log = parent_thread.read(cx).action_log().clone();
|
|
let action_log =
|
|
cx.new(|_cx| ActionLog::new(project.clone()).with_linked_action_log(parent_action_log));
|
|
let mut thread = Self::new_internal(
|
|
project,
|
|
project_context,
|
|
context_server_registry,
|
|
templates,
|
|
model,
|
|
action_log,
|
|
cx,
|
|
);
|
|
thread.subagent_context = Some(SubagentContext {
|
|
parent_thread_id: parent_thread.read(cx).id().clone(),
|
|
depth: parent_thread.read(cx).depth() + 1,
|
|
});
|
|
thread.inherit_parent_settings(parent_thread, cx);
|
|
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>,
|
|
project_context: Entity<ProjectContext>,
|
|
context_server_registry: Entity<ContextServerRegistry>,
|
|
templates: Arc<Templates>,
|
|
model: Option<Arc<dyn LanguageModel>>,
|
|
cx: &mut Context<Self>,
|
|
) -> Self {
|
|
Self::new_internal(
|
|
project.clone(),
|
|
project_context,
|
|
context_server_registry,
|
|
templates,
|
|
model,
|
|
cx.new(|_cx| ActionLog::new(project)),
|
|
cx,
|
|
)
|
|
}
|
|
|
|
fn new_internal(
|
|
project: Entity<Project>,
|
|
project_context: Entity<ProjectContext>,
|
|
context_server_registry: Entity<ContextServerRegistry>,
|
|
templates: Arc<Templates>,
|
|
model: Option<Arc<dyn LanguageModel>>,
|
|
action_log: Entity<ActionLog>,
|
|
cx: &mut Context<Self>,
|
|
) -> Self {
|
|
let settings = AgentSettings::get_global(cx);
|
|
let (profile_id, 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<Thread>, cx: &mut Context<Self>) {
|
|
let parent = parent_thread.read(cx);
|
|
self.speed = parent.speed;
|
|
self.thinking_enabled = parent.thinking_enabled;
|
|
self.thinking_effort = parent.thinking_effort.clone();
|
|
self.summarization_model = parent.summarization_model.clone();
|
|
self.profile_id = parent.profile_id.clone();
|
|
self.profile_downgraded_for_restricted_workspace =
|
|
parent.profile_downgraded_for_restricted_workspace;
|
|
}
|
|
|
|
fn apply_model_selection(
|
|
&mut self,
|
|
selection: &LanguageModelSelection,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
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<Self>,
|
|
) -> Result<PathBuf> {
|
|
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<Self>,
|
|
) -> mpsc::UnboundedReceiver<Result<ThreadEvent>> {
|
|
let (tx, rx) = mpsc::unbounded();
|
|
let stream = ThreadEventStream::new(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<Self>,
|
|
) {
|
|
// 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
|
|
.sender
|
|
.unbounded_send(Ok(ThreadEvent::ToolCall(
|
|
acp::ToolCall::new(tool_call_id.clone(), tool_use.name.to_string())
|
|
.name(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<Vec<acp::ToolCallContent>> {
|
|
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::<Vec<_>>();
|
|
|
|
if content.is_empty() {
|
|
None
|
|
} else {
|
|
Some(content)
|
|
}
|
|
}
|
|
|
|
pub fn from_db(
|
|
id: acp::SessionId,
|
|
db_thread: DbThread,
|
|
project: Entity<Project>,
|
|
project_context: Entity<ProjectContext>,
|
|
context_server_registry: Entity<ContextServerRegistry>,
|
|
templates: Arc<Templates>,
|
|
cx: &mut Context<Self>,
|
|
) -> Self {
|
|
let settings = AgentSettings::get_global(cx);
|
|
let profile_id = db_thread
|
|
.profile
|
|
.unwrap_or_else(|| settings.default_profile.clone());
|
|
|
|
let 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<Self>,
|
|
) -> 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<PathBuf> {
|
|
crate::sandboxing::sandbox_worktree_writable_paths(self.project.read(cx), cx)
|
|
}
|
|
|
|
pub fn to_db(&self, cx: &App) -> Task<DbThread> {
|
|
let initial_project_snapshot = self.initial_project_snapshot.clone();
|
|
let mut thread = DbThread {
|
|
title: self.title().unwrap_or_default(),
|
|
messages: self.messages.clone(),
|
|
updated_at: self.updated_at,
|
|
detailed_summary: self.summary.clone(),
|
|
initial_project_snapshot: None,
|
|
cumulative_token_usage: self.cumulative_token_usage,
|
|
request_token_usage: self.request_token_usage.clone(),
|
|
model: (&self.model).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<Project>,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Arc<ProjectSnapshot>> {
|
|
let task = project::telemetry_snapshot::TelemetrySnapshot::new(&project, cx);
|
|
cx.spawn(async move |_, _| {
|
|
let snapshot = task.await;
|
|
|
|
Arc::new(ProjectSnapshot {
|
|
worktree_snapshots: snapshot.worktree_snapshots,
|
|
timestamp: Utc::now(),
|
|
})
|
|
})
|
|
}
|
|
|
|
pub fn project_context(&self) -> &Entity<ProjectContext> {
|
|
&self.project_context
|
|
}
|
|
|
|
pub fn project(&self) -> &Entity<Project> {
|
|
&self.project
|
|
}
|
|
|
|
pub fn action_log(&self) -> &Entity<ActionLog> {
|
|
&self.action_log
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.messages.is_empty() && self.title.is_none()
|
|
}
|
|
|
|
pub fn draft_prompt(&self) -> Option<&[acp::ContentBlock]> {
|
|
self.draft_prompt.as_deref()
|
|
}
|
|
|
|
pub fn set_draft_prompt(&mut self, prompt: Option<Vec<acp::ContentBlock>>) {
|
|
self.draft_prompt = prompt;
|
|
}
|
|
|
|
pub fn ui_scroll_position(&self) -> Option<gpui::ListOffset> {
|
|
self.ui_scroll_position
|
|
}
|
|
|
|
pub fn set_ui_scroll_position(&mut self, position: Option<gpui::ListOffset>) {
|
|
self.ui_scroll_position = position;
|
|
}
|
|
|
|
pub fn model(&self) -> Option<&Arc<dyn LanguageModel>> {
|
|
self.model.as_model()
|
|
}
|
|
|
|
pub fn thread_model(&self) -> &ThreadModel {
|
|
&self.model
|
|
}
|
|
|
|
pub(crate) fn ensure_model(
|
|
&mut self,
|
|
default_model: Option<&Arc<dyn LanguageModel>>,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
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<dyn LanguageModel>, cx: &mut Context<Self>) {
|
|
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<dyn LanguageModel>> {
|
|
self.summarization_model.as_ref()
|
|
}
|
|
|
|
pub fn set_summarization_model(
|
|
&mut self,
|
|
model: Option<Arc<dyn LanguageModel>>,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.summarization_model = model.clone();
|
|
|
|
for subagent in &self.running_subagents {
|
|
subagent
|
|
.update(cx, |thread, cx| {
|
|
thread.set_summarization_model(model.clone(), cx)
|
|
})
|
|
.ok();
|
|
}
|
|
cx.notify()
|
|
}
|
|
|
|
pub fn thinking_enabled(&self) -> bool {
|
|
self.thinking_enabled
|
|
}
|
|
|
|
pub fn set_thinking_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
|
|
self.thinking_enabled = enabled;
|
|
|
|
for subagent in &self.running_subagents {
|
|
subagent
|
|
.update(cx, |thread, cx| {
|
|
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<String>, cx: &mut Context<Self>) {
|
|
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<Speed> {
|
|
self.speed
|
|
}
|
|
|
|
pub fn set_speed(&mut self, speed: Speed, cx: &mut Context<Self>) {
|
|
self.speed = Some(speed);
|
|
|
|
for subagent in &self.running_subagents {
|
|
subagent
|
|
.update(cx, |thread, cx| {
|
|
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<Arc<Message>> {
|
|
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<dyn ThreadEnvironment>,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
// Only update the agent location for the root thread, not for subagents.
|
|
let update_agent_location = self.parent_thread_id().is_none();
|
|
|
|
let language_registry = self.project.read(cx).languages().clone();
|
|
self.add_tool(CopyPathTool::new(self.project.clone()));
|
|
self.add_tool(CreateDirectoryTool::new(self.project.clone()));
|
|
self.add_tool(DeletePathTool::new(
|
|
self.project.clone(),
|
|
self.action_log.clone(),
|
|
));
|
|
self.add_tool(EditFileTool::new(
|
|
self.project.clone(),
|
|
cx.weak_entity(),
|
|
self.action_log.clone(),
|
|
language_registry.clone(),
|
|
));
|
|
self.add_tool(WriteFileTool::new(
|
|
self.project.clone(),
|
|
cx.weak_entity(),
|
|
self.action_log.clone(),
|
|
language_registry,
|
|
));
|
|
self.add_tool(FetchTool::new(self.project.read(cx).client().http_client()));
|
|
self.add_tool(FindPathTool::new(self.project.clone()));
|
|
self.add_tool(GrepTool::new(self.project.clone()));
|
|
self.add_tool(ListDirectoryTool::new(self.project.clone()));
|
|
self.add_tool(MovePathTool::new(self.project.clone()));
|
|
self.add_tool(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(AskUserTool);
|
|
|
|
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<T: AgentTool>(&mut self, tool: T) {
|
|
debug_assert!(
|
|
!self.tools.contains_key(T::NAME),
|
|
"Duplicate tool name: {}",
|
|
T::NAME,
|
|
);
|
|
self.tools.insert(T::NAME.into(), tool.erase());
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub fn remove_tool(&mut self, name: &str) -> bool {
|
|
self.tools.remove(name).is_some()
|
|
}
|
|
|
|
pub fn profile(&self) -> &AgentProfileId {
|
|
&self.profile_id
|
|
}
|
|
|
|
/// 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<Project>,
|
|
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<Self>) {
|
|
// 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<Self>) -> Task<()> {
|
|
for subagent in self.running_subagents.drain(..) {
|
|
if let Some(subagent) = subagent.upgrade() {
|
|
subagent.update(cx, |thread, cx| thread.cancel(cx)).detach();
|
|
}
|
|
}
|
|
|
|
let Some(running_turn) = self.running_turn.take() else {
|
|
self.flush_pending_message(cx);
|
|
return Task::ready(());
|
|
};
|
|
|
|
let turn_task = running_turn.cancel();
|
|
|
|
cx.spawn(async move |this, cx| {
|
|
turn_task.await;
|
|
this.update(cx, |this, cx| {
|
|
this.flush_pending_message(cx);
|
|
})
|
|
.ok();
|
|
})
|
|
}
|
|
|
|
pub fn set_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>) {
|
|
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<u64>, cx: &mut Context<Self>) {
|
|
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<Self>,
|
|
) -> Result<()> {
|
|
self.cancel(cx).detach();
|
|
// Clear pending message since cancel will try to flush it asynchronously,
|
|
// and we don't want that content to be added after we truncate
|
|
self.pending_message.take();
|
|
let Some(position) = self.messages.iter().position(|msg| {
|
|
matches!(&**msg, Message::User(UserMessage { id, .. }) if id == &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<language_model::TokenUsage> {
|
|
let last_user_message = self.last_user_message()?;
|
|
let tokens = self.request_token_usage.get(&last_user_message.id)?;
|
|
Some(*tokens)
|
|
}
|
|
|
|
pub fn cumulative_token_usage(&self) -> language_model::TokenUsage {
|
|
self.cumulative_token_usage
|
|
}
|
|
|
|
/// Maximum input after reserving the selected model's output allowance.
|
|
pub fn input_token_capacity(&self) -> Option<u64> {
|
|
let model = self.model()?;
|
|
Some(compaction_input_capacity(
|
|
model.max_input_tokens(),
|
|
model.max_total_tokens(),
|
|
model.max_output_tokens(),
|
|
))
|
|
}
|
|
|
|
pub fn latest_token_usage(&self) -> Option<acp_thread::TokenUsage> {
|
|
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<u64> {
|
|
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<Self>,
|
|
) -> Option<Arc<dyn LanguageModel>> {
|
|
let selection = AgentSettings::get_global(cx)
|
|
.profiles
|
|
.get(profile_id)?
|
|
.default_model
|
|
.clone()?;
|
|
Self::resolve_model_from_selection(&selection, cx)
|
|
}
|
|
|
|
fn user_configured_model_selection(cx: &App) -> Option<SelectedModel> {
|
|
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<Self>,
|
|
) -> Option<Arc<dyn LanguageModel>> {
|
|
let selected = SelectedModel {
|
|
provider: LanguageModelProviderId::from(selection.provider.0.clone()),
|
|
model: LanguageModelId::from(selection.model.clone()),
|
|
};
|
|
LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
|
|
registry
|
|
.select_model(&selected, cx)
|
|
.map(|configured| configured.model)
|
|
})
|
|
}
|
|
|
|
pub fn resume(
|
|
&mut self,
|
|
cx: &mut Context<Self>,
|
|
) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>> {
|
|
self.messages.push(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<T>(
|
|
&mut self,
|
|
id: ClientUserMessageId,
|
|
content: impl IntoIterator<Item = T>,
|
|
cx: &mut Context<Self>,
|
|
) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>>
|
|
where
|
|
T: Into<UserMessageContent>,
|
|
{
|
|
let content = content.into_iter().map(Into::into).collect::<Arc<_>>();
|
|
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<Self>,
|
|
) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>> {
|
|
let model = self
|
|
.model()
|
|
.ok_or_else(|| anyhow!(NoModelConfiguredError))?;
|
|
|
|
log::info!("Thread::send called with model: {}", model.name().0);
|
|
self.advance_prompt_id();
|
|
|
|
log::debug!("Total messages in thread: {}", self.messages.len());
|
|
self.run_turn(cx)
|
|
}
|
|
|
|
/// 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<Self>,
|
|
) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>> {
|
|
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::<Result<ThreadEvent>>();
|
|
let event_stream = ThreadEventStream::new(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<Item = acp::ContentBlock>,
|
|
path_style: PathStyle,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let content = blocks
|
|
.into_iter()
|
|
.map(|block| UserMessageContent::from_content_block(block, path_style))
|
|
.collect::<Arc<_>>();
|
|
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<Self>) {
|
|
let text = match block {
|
|
acp::ContentBlock::Text(text_content) => text_content.text,
|
|
acp::ContentBlock::Image(_) => "[image]".to_string(),
|
|
acp::ContentBlock::Audio(_) => "[audio]".to_string(),
|
|
acp::ContentBlock::ResourceLink(resource_link) => resource_link.uri,
|
|
acp::ContentBlock::Resource(resource) => match resource.resource {
|
|
acp::EmbeddedResourceResource::TextResourceContents(resource) => resource.uri,
|
|
acp::EmbeddedResourceResource::BlobResourceContents(resource) => resource.uri,
|
|
_ => "[resource]".to_string(),
|
|
},
|
|
_ => "[unknown]".to_string(),
|
|
};
|
|
|
|
self.messages.push(Arc::new(Message::Agent(AgentMessage {
|
|
content: vec![AgentMessageContent::Text(text)],
|
|
..Default::default()
|
|
})));
|
|
cx.notify();
|
|
}
|
|
|
|
fn run_turn(
|
|
&mut self,
|
|
cx: &mut Context<Self>,
|
|
) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>> {
|
|
// Flush the old pending message synchronously before cancelling,
|
|
// to avoid a race where the detached cancel task might flush the NEW
|
|
// turn's pending message instead of the old one.
|
|
self.flush_pending_message(cx);
|
|
self.cancel(cx).detach();
|
|
|
|
let (events_tx, events_rx) = mpsc::unbounded::<Result<ThreadEvent>>();
|
|
let event_stream = ThreadEventStream::new(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::<CompletionError>() {
|
|
Ok(CompletionError::Refusal) => {
|
|
event_stream.send_stop(acp::StopReason::Refusal);
|
|
_ = this.update(cx, |this, _| this.messages.truncate(message_ix));
|
|
}
|
|
Ok(CompletionError::MaxTokens) => {
|
|
event_stream.send_stop(acp::StopReason::MaxTokens);
|
|
}
|
|
Ok(CompletionError::Other(error)) | Err(error) => {
|
|
event_stream.send_error(error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
_ = this.update(cx, |this, _| this.running_turn.take());
|
|
}
|
|
});
|
|
self.running_turn = Some(RunningTurn::new(event_stream, tools, cancellation_tx, task));
|
|
Ok(events_rx)
|
|
}
|
|
|
|
async fn run_turn_internal(
|
|
this: &WeakEntity<Self>,
|
|
event_stream: &ThreadEventStream,
|
|
mut cancellation_rx: watch::Receiver<bool>,
|
|
cx: &mut AsyncApp,
|
|
) -> Result<()> {
|
|
let mut attempt = 0;
|
|
let mut intent = CompletionIntent::UserPrompt;
|
|
// Set when a refusal fallback occurs so subsequent iterations use the fallback model.
|
|
let mut refusal_fallback_model: Option<Arc<dyn LanguageModel>> = 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::<LanguageModelCompletionError>() {
|
|
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.context("Automatic context compaction failed")
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Err(error) => {
|
|
this.update(cx, |this, _| {
|
|
this.emit_compaction_telemetry_outcome(
|
|
"failed",
|
|
Some(error_message),
|
|
)
|
|
})?;
|
|
return Err(error.context("Automatic context compaction failed"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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<Task<(usize, LanguageModelToolResult)>> =
|
|
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::<CompletionError>()
|
|
.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<Arc<dyn LanguageModel>> {
|
|
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<Self>,
|
|
event_stream: &ThreadEventStream,
|
|
cancellation_rx: &mut watch::Receiver<bool>,
|
|
error: LanguageModelCompletionError,
|
|
attempt: u8,
|
|
cx: &mut AsyncApp,
|
|
) -> Result<ControlFlow<()>> {
|
|
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<Self>,
|
|
event_stream: &ThreadEventStream,
|
|
cancellation_rx: watch::Receiver<bool>,
|
|
cx: &mut AsyncApp,
|
|
) -> Result<ControlFlow<()>> {
|
|
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<Self>,
|
|
event_stream: &ThreadEventStream,
|
|
mut cancellation_rx: watch::Receiver<bool>,
|
|
model: Arc<dyn LanguageModel>,
|
|
request: LanguageModelRequest,
|
|
insertion: CompactionInsertion,
|
|
cx: &mut AsyncApp,
|
|
) -> Result<ControlFlow<()>> {
|
|
if *cancellation_rx.borrow() {
|
|
return Ok(ControlFlow::Break(()));
|
|
}
|
|
|
|
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 result: Result<ControlFlow<()>> = async {
|
|
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.clone(),
|
|
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(()))
|
|
}
|
|
.await;
|
|
|
|
if result.is_err() {
|
|
event_stream.update_context_compaction_status(
|
|
compaction_id,
|
|
acp_thread::ContextCompactionStatus::Failed,
|
|
);
|
|
}
|
|
result
|
|
}
|
|
|
|
fn process_tool_result(
|
|
this: &WeakEntity<Thread>,
|
|
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<Plan>,
|
|
cx: &mut Context<Self>,
|
|
) -> Result<acp_thread::RetryStatus> {
|
|
if let LanguageModelCompletionError::ProviderRejection {
|
|
category: ProviderErrorCategory::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 Some(delay) = strategy.delay_after(&error, attempt) else {
|
|
return Err(anyhow!(error));
|
|
};
|
|
let delay = crate::jitter_retry_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: strategy.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<bool>,
|
|
cx: &mut Context<Self>,
|
|
) -> Result<Option<Task<(usize, LanguageModelToolResult)>>> {
|
|
log::trace!("Handling streamed completion event: {:?}", event);
|
|
use LanguageModelCompletionEvent::*;
|
|
|
|
match event {
|
|
StartMessage { .. } => {
|
|
self.flush_pending_message(cx);
|
|
self.pending_message = Some(AgentMessage::default());
|
|
}
|
|
Text(new_text) => self.handle_text_event(new_text, event_stream),
|
|
Thinking { text, signature } => {
|
|
self.handle_thinking_event(text, signature, event_stream)
|
|
}
|
|
RedactedThinking { data } => self.handle_redacted_thinking_event(data),
|
|
ReasoningDetails(details) => {
|
|
let last_message = self.pending_message();
|
|
// Store the last non-empty reasoning_details (overwrites earlier ones)
|
|
// This ensures we keep the encrypted reasoning with signatures, not the early text reasoning
|
|
if let serde_json::Value::Array(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<String>,
|
|
event_stream: &ThreadEventStream,
|
|
) {
|
|
event_stream.send_thinking(&new_text);
|
|
|
|
let last_message = self.pending_message();
|
|
if let Some(AgentMessageContent::Thinking { text, signature }) =
|
|
last_message.content.last_mut()
|
|
{
|
|
text.push_str(&new_text);
|
|
*signature = new_signature.or(signature.take());
|
|
} else {
|
|
last_message.content.push(AgentMessageContent::Thinking {
|
|
text: new_text,
|
|
signature: new_signature,
|
|
});
|
|
}
|
|
}
|
|
|
|
fn handle_redacted_thinking_event(&mut self, data: String) {
|
|
let last_message = self.pending_message();
|
|
last_message
|
|
.content
|
|
.push(AgentMessageContent::RedactedThinking(data));
|
|
}
|
|
|
|
fn handle_tool_use_event(
|
|
&mut self,
|
|
tool_use: LanguageModelToolUse,
|
|
event_stream: &ThreadEventStream,
|
|
cancellation_rx: watch::Receiver<bool>,
|
|
cx: &mut Context<Self>,
|
|
) -> Option<Task<(usize, LanguageModelToolResult)>> {
|
|
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<dyn AnyAgentTool>,
|
|
tool_input: ToolInput<serde_json::Value>,
|
|
tool_use_id: LanguageModelToolUseId,
|
|
tool_name: Arc<str>,
|
|
owning_message_ix: usize,
|
|
event_stream: &ThreadEventStream,
|
|
cancellation_rx: watch::Receiver<bool>,
|
|
cx: &mut Context<Self>,
|
|
) -> 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<str>,
|
|
raw_input: Arc<str>,
|
|
json_parse_error: String,
|
|
event_stream: &ThreadEventStream,
|
|
cancellation_rx: watch::Receiver<bool>,
|
|
cx: &mut Context<Self>,
|
|
) -> Option<Task<(usize, LanguageModelToolResult)>> {
|
|
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<SharedString> {
|
|
self.title.clone()
|
|
}
|
|
|
|
pub fn is_generating_summary(&self) -> bool {
|
|
self.pending_summary_generation.is_some()
|
|
}
|
|
|
|
pub fn is_generating_title(&self) -> bool {
|
|
self.pending_title_generation.is_some()
|
|
}
|
|
|
|
pub fn has_failed_title_generation(&self) -> bool {
|
|
self.title_generation_error.is_some()
|
|
}
|
|
|
|
pub fn title_generation_error(&self) -> Option<SharedString> {
|
|
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<Self>) -> Shared<Task<Option<SharedString>>> {
|
|
if let Some(summary) = self.summary.as_ref() {
|
|
return Task::ready(Some(summary.clone())).shared();
|
|
}
|
|
if let Some(task) = self.pending_summary_generation.clone() {
|
|
return task;
|
|
}
|
|
let Some(model) = self.summarization_model.clone() else {
|
|
log::error!("No summarization model available");
|
|
return Task::ready(None).shared();
|
|
};
|
|
let mut request = LanguageModelRequest {
|
|
thread_id: Some(self.id.to_string()),
|
|
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<Self>) {
|
|
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<Self>) -> bool {
|
|
self.regenerate_title_with_callback(cx, |_title, _cx| {})
|
|
}
|
|
|
|
pub fn regenerate_title_with_callback(
|
|
&mut self,
|
|
cx: &mut Context<Self>,
|
|
on_generated_title: impl FnOnce(SharedString, &mut Context<Self>) + '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<dyn LanguageModel>,
|
|
on_generated_title: Option<Box<dyn FnOnce(SharedString, &mut Context<Self>)>>,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
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.id, &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>) {
|
|
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<Self>) {
|
|
let Some(mut message) = self.pending_message.take() else {
|
|
return;
|
|
};
|
|
|
|
if message.content.is_empty() {
|
|
return;
|
|
}
|
|
|
|
for content in &message.content {
|
|
let AgentMessageContent::ToolUse(tool_use) = content else {
|
|
continue;
|
|
};
|
|
|
|
if !message.tool_results.contains_key(&tool_use.id) {
|
|
message.tool_results.insert(
|
|
tool_use.id.clone(),
|
|
LanguageModelToolResult {
|
|
tool_use_id: tool_use.id.clone(),
|
|
tool_name: tool_use.name.clone(),
|
|
is_error: true,
|
|
content: vec![LanguageModelToolResultContent::Text(
|
|
TOOL_CANCELED_MESSAGE.into(),
|
|
)],
|
|
output: None,
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
self.messages.push(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<LanguageModelRequest> {
|
|
let completion_intent =
|
|
if self.is_subagent() && completion_intent == CompletionIntent::UserPrompt {
|
|
CompletionIntent::Subagent
|
|
} else {
|
|
completion_intent
|
|
};
|
|
|
|
let model = self
|
|
.model()
|
|
.ok_or_else(|| anyhow!(NoModelConfiguredError))?;
|
|
let sandboxing_enabled = crate::sandboxing::sandboxing_enabled(cx);
|
|
let tools = if let Some(turn) = self.running_turn.as_ref() {
|
|
turn.tools
|
|
.iter()
|
|
.map(|(tool_name, tool)| {
|
|
log::trace!("Including tool: {}", tool_name);
|
|
let mut description = tool.description().to_string();
|
|
let mut schema = tool.input_schema();
|
|
// 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");
|
|
}
|
|
}
|
|
}
|
|
LanguageModelRequestTool::function(
|
|
tool_name.to_string(),
|
|
description,
|
|
schema,
|
|
tool.supports_input_streaming(),
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
log::debug!("Building completion request");
|
|
log::debug!("Completion intent: {:?}", completion_intent);
|
|
|
|
let available_tools: Vec<_> = self
|
|
.running_turn
|
|
.as_ref()
|
|
.map(|turn| turn.tools.keys().cloned().collect())
|
|
.unwrap_or_default();
|
|
|
|
log::debug!("Request includes {} tools", available_tools.len());
|
|
let messages = self.build_request_messages(available_tools, cx);
|
|
log::debug!("Request will include {} messages", messages.len());
|
|
|
|
let request = LanguageModelRequest {
|
|
thread_id: Some(self.id.to_string()),
|
|
prompt_cache_key: None,
|
|
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,
|
|
max_output_tokens: None,
|
|
};
|
|
|
|
log::debug!("Completion request built successfully");
|
|
Ok(request)
|
|
}
|
|
|
|
fn enabled_tools(&self, cx: &App) -> BTreeMap<SharedString, Arc<dyn AnyAgentTool>> {
|
|
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::<BTreeMap<_, _>>();
|
|
|
|
let mut context_server_tools = Vec::new();
|
|
let mut seen_tools = tools.keys().cloned().collect::<HashSet<_>>();
|
|
let mut duplicate_tool_names = HashSet::default();
|
|
for (server_id, server_tools) in self.context_server_registry.read(cx).servers() {
|
|
for (tool_name, tool) in server_tools {
|
|
if profile.is_context_server_tool_enabled(&server_id.0, &tool_name) {
|
|
let tool_name: 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<Arc<dyn AnyAgentTool>> {
|
|
self.running_turn.as_ref()?.tools.get(name).cloned()
|
|
}
|
|
|
|
pub fn has_tool(&self, name: &str) -> bool {
|
|
self.running_turn
|
|
.as_ref()
|
|
.is_some_and(|turn| turn.tools.contains_key(name))
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub fn has_registered_tool(&self, name: &str) -> bool {
|
|
self.tools.contains_key(name)
|
|
}
|
|
|
|
pub(crate) fn register_running_subagent(&mut self, subagent: WeakEntity<Thread>) {
|
|
self.running_subagents.push(subagent);
|
|
}
|
|
|
|
pub(crate) fn unregister_running_subagent(
|
|
&mut self,
|
|
subagent_session_id: &acp::SessionId,
|
|
cx: &App,
|
|
) {
|
|
self.running_subagents.retain(|s| {
|
|
s.upgrade()
|
|
.map_or(false, |s| s.read(cx).id() != subagent_session_id)
|
|
});
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub fn running_subagent_ids(&self, cx: &App) -> Vec<acp::SessionId> {
|
|
self.running_subagents
|
|
.iter()
|
|
.filter_map(|s| s.upgrade().map(|s| s.read(cx).id().clone()))
|
|
.collect()
|
|
}
|
|
|
|
pub fn is_subagent(&self) -> bool {
|
|
self.subagent_context.is_some()
|
|
}
|
|
|
|
pub fn parent_thread_id(&self) -> Option<acp::SessionId> {
|
|
self.subagent_context
|
|
.as_ref()
|
|
.map(|c| c.parent_thread_id.clone())
|
|
}
|
|
|
|
pub fn depth(&self) -> u8 {
|
|
self.subagent_context.as_ref().map(|c| c.depth).unwrap_or(0)
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub fn set_subagent_context(&mut self, context: SubagentContext) {
|
|
self.subagent_context = Some(context);
|
|
}
|
|
|
|
pub fn is_turn_complete(&self) -> bool {
|
|
self.running_turn.is_none()
|
|
}
|
|
|
|
pub(crate) fn auto_compaction_enabled(&self, cx: &App) -> bool {
|
|
AgentSettings::get_global(cx).auto_compact.enabled
|
|
&& self.input_token_capacity().is_some_and(|max_input_tokens| {
|
|
// 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.
|
|
max_input_tokens >= MIN_COMPACTION_CONTEXT_WINDOW
|
|
})
|
|
}
|
|
|
|
pub(crate) fn subagent_partial_output(&self) -> String {
|
|
subagent_partial_output_from_messages(&self.messages, self.pending_message.as_ref())
|
|
}
|
|
|
|
fn build_request_messages(
|
|
&self,
|
|
available_tools: Vec<SharedString>,
|
|
cx: &App,
|
|
) -> Vec<LanguageModelRequestMessage> {
|
|
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<SharedString>,
|
|
end_ix: usize,
|
|
cx: &App,
|
|
) -> Vec<LanguageModelRequestMessage> {
|
|
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<LanguageModelRequestMessage>,
|
|
end_ix: usize,
|
|
) {
|
|
extend_request_history_until(&self.messages, request_messages, end_ix);
|
|
}
|
|
|
|
fn build_compaction_telemetry(
|
|
&self,
|
|
trigger: &'static str,
|
|
compaction_model: &Arc<dyn LanguageModel>,
|
|
cx: &App,
|
|
) -> Option<CompactionTelemetry> {
|
|
let model = self.model()?;
|
|
let auto_compact = AgentSettings::get_global(cx).auto_compact;
|
|
let max_tokens = model.max_token_count();
|
|
let max_input_tokens = self.input_token_capacity()?;
|
|
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<String>) {
|
|
if let Some(telemetry) = self.pending_compaction_telemetry.take() {
|
|
telemetry.emit(status, error, None);
|
|
}
|
|
}
|
|
|
|
fn compaction_message_target_ix(&self, cx: &App) -> Option<usize> {
|
|
if !self.auto_compaction_enabled(cx) {
|
|
return None;
|
|
}
|
|
|
|
let auto_compact = AgentSettings::get_global(cx).auto_compact;
|
|
let max_input_tokens = self.input_token_capacity()?;
|
|
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<usize> {
|
|
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<Arc<dyn LanguageModel>> {
|
|
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<dyn LanguageModel>,
|
|
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<RetryStrategy> {
|
|
use LanguageModelCompletionError::*;
|
|
|
|
match error {
|
|
// A rejection with no status (e.g. a content-policy rejection
|
|
// like `cyber_policy`) is permanent: retrying sends the same
|
|
// request and gets the same answer. A rejection with a
|
|
// non-retryable status (auth, payload too large, ...) is
|
|
// permanent for the same reason. Otherwise, honor the
|
|
// provider's requested delay when it gave one, and fall back to
|
|
// exponential backoff when it didn't.
|
|
ProviderRejection { retry_after, .. } => {
|
|
if !error.is_transient() {
|
|
return None;
|
|
}
|
|
Some(match retry_after {
|
|
Some(delay) => RetryStrategy::FixedDelay {
|
|
delay: *delay,
|
|
max_attempts: MAX_RETRY_ATTEMPTS,
|
|
},
|
|
None => RetryStrategy::ExponentialBackoff,
|
|
})
|
|
}
|
|
ApiReadResponseError { .. } | HttpSend { .. } | DeserializeResponse { .. } => {
|
|
Some(RetryStrategy::FixedDelay {
|
|
delay: BASE_RETRY_DELAY,
|
|
max_attempts: 3,
|
|
})
|
|
}
|
|
// Retrying these errors definitely shouldn't help.
|
|
NoApiKey { .. } => None,
|
|
// These errors might be transient, so retry them
|
|
SerializeRequest { .. } | BuildRequestBody { .. } | StreamEndedUnexpectedly { .. } => {
|
|
Some(RetryStrategy::FixedDelay {
|
|
delay: BASE_RETRY_DELAY,
|
|
max_attempts: 1,
|
|
})
|
|
}
|
|
// Retrying won't help until the user consents to data retention
|
|
// or switches models.
|
|
DataRetentionConsentRequired { .. } => None,
|
|
// `Other` includes mid-stream mapping failures that can be caused by
|
|
// a transient malformed or interrupted provider event.
|
|
Other(..) => Some(RetryStrategy::FixedDelay {
|
|
delay: BASE_RETRY_DELAY,
|
|
max_attempts: 2,
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn subagent_partial_output_from_messages(
|
|
messages: &[Arc<Message>],
|
|
pending_message: Option<&AgentMessage>,
|
|
) -> String {
|
|
let Some(user_message_ix) = messages
|
|
.iter()
|
|
.rposition(|message| matches!(&**message, Message::User(_)))
|
|
else {
|
|
return String::new();
|
|
};
|
|
|
|
let mut text_messages = pending_message
|
|
.into_iter()
|
|
.chain(
|
|
messages
|
|
.iter()
|
|
.skip(user_message_ix + 1)
|
|
.rev()
|
|
.filter_map(|message| message.as_agent_message()),
|
|
)
|
|
.filter_map(|message| {
|
|
let characters = message
|
|
.content
|
|
.iter()
|
|
.rev()
|
|
.filter_map(|content| match content {
|
|
AgentMessageContent::Text(text) => Some(text),
|
|
_ => None,
|
|
})
|
|
.flat_map(|text| text.chars().rev())
|
|
.take(4096)
|
|
.collect::<Vec<_>>();
|
|
if characters.is_empty() {
|
|
None
|
|
} else {
|
|
Some(characters.into_iter().rev().collect::<String>())
|
|
}
|
|
})
|
|
.take(3)
|
|
.collect::<Vec<_>>();
|
|
text_messages.reverse();
|
|
text_messages.join("\n\n")
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
/// Reserves output without subtracting it from an independent input ceiling.
|
|
fn compaction_input_capacity(
|
|
input_limit: u64,
|
|
combined_limit: Option<u64>,
|
|
output_limit: Option<u64>,
|
|
) -> u64 {
|
|
combined_limit.map_or(input_limit, |combined| {
|
|
input_limit.min(combined.saturating_sub(output_limit.unwrap_or(0)))
|
|
})
|
|
}
|
|
|
|
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<String>,
|
|
prompt_id: String,
|
|
model: String,
|
|
compaction_model: String,
|
|
thinking_effort: Option<String>,
|
|
max_tokens: u64,
|
|
/// Tokens in the context window immediately before compaction.
|
|
tokens_before: Option<u64>,
|
|
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<String>, tokens_after: Option<u64>) {
|
|
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<LanguageModelRequestMessage> {
|
|
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<String> {
|
|
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<SharedString, Arc<dyn AnyAgentTool>>,
|
|
/// Sender to signal tool cancellation. When cancel is called, this is
|
|
/// set to true so all tools can detect user-initiated cancellation.
|
|
cancellation_tx: watch::Sender<bool>,
|
|
/// Senders for tools that support input streaming and have already been
|
|
/// started but are still receiving input from the LLM.
|
|
streaming_tool_inputs: HashMap<LanguageModelToolUseId, ToolInputSender>,
|
|
}
|
|
|
|
impl RunningTurn {
|
|
fn new(
|
|
event_stream: ThreadEventStream,
|
|
tools: BTreeMap<SharedString, Arc<dyn AnyAgentTool>>,
|
|
cancellation_tx: watch::Sender<bool>,
|
|
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<Message>]) -> 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<Message>],
|
|
request_messages: &mut Vec<LanguageModelRequestMessage>,
|
|
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<Message>], end_ix: usize) -> Option<usize> {
|
|
messages[..end_ix]
|
|
.iter()
|
|
.rposition(|message| matches!(&**message, Message::Compaction(_)))
|
|
}
|
|
|
|
fn retained_user_request_messages_before(
|
|
messages: &[Arc<Message>],
|
|
compaction_ix: usize,
|
|
) -> Vec<LanguageModelRequestMessage> {
|
|
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(
|
|
thread_id: &acp::SessionId,
|
|
messages: &[Arc<Message>],
|
|
temperature: Option<f32>,
|
|
) -> LanguageModelRequest {
|
|
let mut request = LanguageModelRequest {
|
|
thread_id: Some(thread_id.to_string()),
|
|
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<dyn LanguageModel>,
|
|
request: LanguageModelRequest,
|
|
cx: &AsyncApp,
|
|
) -> Result<String> {
|
|
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<acp_thread::TokenUsage>);
|
|
|
|
impl EventEmitter<TokenUsageUpdated> for Thread {}
|
|
|
|
pub struct TitleUpdated;
|
|
|
|
impl EventEmitter<TitleUpdated> for Thread {}
|
|
|
|
pub struct ModelChanged;
|
|
|
|
impl EventEmitter<ModelChanged> for Thread {}
|
|
|
|
/// A channel-based wrapper that delivers tool input to a running tool.
|
|
///
|
|
/// For non-streaming tools, created via `ToolInput::ready()` so `.recv()` resolves immediately.
|
|
/// For streaming tools, partial JSON snapshots arrive via `.recv_partial()` as the LLM streams
|
|
/// them, followed by the final complete input available through `.recv()`.
|
|
pub struct ToolInput<T> {
|
|
rx: mpsc::UnboundedReceiver<ToolInputPayload<serde_json::Value>>,
|
|
_phantom: PhantomData<T>,
|
|
}
|
|
|
|
impl<T: DeserializeOwned> ToolInput<T> {
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub fn resolved(input: impl Serialize) -> Self {
|
|
let value = serde_json::to_value(input).expect("failed to serialize tool input");
|
|
Self::ready(value)
|
|
}
|
|
|
|
pub fn ready(value: serde_json::Value) -> Self {
|
|
let (tx, rx) = mpsc::unbounded();
|
|
tx.unbounded_send(ToolInputPayload::Full(value)).ok();
|
|
Self {
|
|
rx,
|
|
_phantom: PhantomData,
|
|
}
|
|
}
|
|
|
|
pub fn invalid_json(error_message: String) -> Self {
|
|
let (tx, rx) = mpsc::unbounded();
|
|
tx.unbounded_send(ToolInputPayload::InvalidJson { error_message })
|
|
.ok();
|
|
Self {
|
|
rx,
|
|
_phantom: PhantomData,
|
|
}
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub fn test() -> (ToolInputSender, Self) {
|
|
let (sender, input) = ToolInputSender::channel();
|
|
(sender, input.cast())
|
|
}
|
|
|
|
/// Wait for the final deserialized input, ignoring all partial updates.
|
|
/// Non-streaming tools can use this to wait until the whole input is available.
|
|
pub async fn recv(mut self) -> Result<T> {
|
|
while let Ok(value) = self.next().await {
|
|
match value {
|
|
ToolInputPayload::Full(value) => return Ok(value),
|
|
ToolInputPayload::Partial(_) => {}
|
|
ToolInputPayload::InvalidJson { error_message } => {
|
|
return Err(anyhow!(error_message));
|
|
}
|
|
}
|
|
}
|
|
Err(anyhow!("tool input was not fully received"))
|
|
}
|
|
|
|
pub async fn next(&mut self) -> Result<ToolInputPayload<T>> {
|
|
let value = self
|
|
.rx
|
|
.next()
|
|
.await
|
|
.ok_or_else(|| anyhow!("tool input was not fully received"))?;
|
|
|
|
Ok(match value {
|
|
ToolInputPayload::Partial(payload) => ToolInputPayload::Partial(payload),
|
|
ToolInputPayload::Full(payload) => {
|
|
ToolInputPayload::Full(serde_json::from_value(payload)?)
|
|
}
|
|
ToolInputPayload::InvalidJson { error_message } => {
|
|
ToolInputPayload::InvalidJson { error_message }
|
|
}
|
|
})
|
|
}
|
|
|
|
fn cast<U: DeserializeOwned>(self) -> ToolInput<U> {
|
|
ToolInput {
|
|
rx: self.rx,
|
|
_phantom: PhantomData,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub enum ToolInputPayload<T> {
|
|
Partial(serde_json::Value),
|
|
Full(T),
|
|
InvalidJson { error_message: String },
|
|
}
|
|
|
|
pub struct ToolInputSender {
|
|
has_received_final: bool,
|
|
tx: mpsc::UnboundedSender<ToolInputPayload<serde_json::Value>>,
|
|
}
|
|
|
|
impl ToolInputSender {
|
|
pub(crate) fn channel() -> (Self, ToolInput<serde_json::Value>) {
|
|
let (tx, rx) = mpsc::unbounded();
|
|
let sender = Self {
|
|
tx,
|
|
has_received_final: false,
|
|
};
|
|
let input = ToolInput {
|
|
rx,
|
|
_phantom: PhantomData,
|
|
};
|
|
(sender, input)
|
|
}
|
|
|
|
pub(crate) fn has_received_final(&self) -> bool {
|
|
self.has_received_final
|
|
}
|
|
|
|
pub fn send_partial(&mut self, payload: serde_json::Value) {
|
|
self.tx
|
|
.unbounded_send(ToolInputPayload::Partial(payload))
|
|
.ok();
|
|
}
|
|
|
|
pub fn send_full(&mut self, payload: serde_json::Value) {
|
|
self.has_received_final = true;
|
|
self.tx.unbounded_send(ToolInputPayload::Full(payload)).ok();
|
|
}
|
|
|
|
pub fn send_invalid_json(&mut self, error_message: String) {
|
|
self.has_received_final = true;
|
|
self.tx
|
|
.unbounded_send(ToolInputPayload::InvalidJson { error_message })
|
|
.ok();
|
|
}
|
|
}
|
|
|
|
pub trait AgentTool
|
|
where
|
|
Self: 'static + Sized,
|
|
{
|
|
type Input: for<'de> Deserialize<'de> + Serialize + JsonSchema;
|
|
type Output: for<'de> Deserialize<'de> + Serialize + Into<LanguageModelToolResultContent>;
|
|
|
|
const NAME: &'static str;
|
|
|
|
fn description() -> SharedString {
|
|
let schema = schemars::schema_for!(Self::Input);
|
|
SharedString::new(
|
|
schema
|
|
.get("description")
|
|
.and_then(|description| description.as_str())
|
|
.unwrap_or_default(),
|
|
)
|
|
}
|
|
|
|
fn kind() -> acp::ToolKind;
|
|
|
|
/// The initial tool title to display. Can be updated during the tool run.
|
|
fn initial_title(
|
|
&self,
|
|
input: Result<Self::Input, serde_json::Value>,
|
|
cx: &mut App,
|
|
) -> SharedString;
|
|
|
|
/// Returns the JSON schema that describes the tool's input.
|
|
fn input_schema() -> Schema {
|
|
language_model::tool_schema::root_schema_for::<Self::Input>()
|
|
}
|
|
|
|
/// 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<Self::Output, Self::Output>` rather than `Result<Self::Output, anyhow::Error>`
|
|
/// because tool errors are sent back to the model as tool results. This means error output must
|
|
/// be structured and readable by the agent — not an arbitrary `anyhow::Error`. Returning the
|
|
/// same `Output` type for both success and failure lets tools provide structured data while
|
|
/// still signaling whether the invocation succeeded or failed.
|
|
fn run(
|
|
self: Arc<Self>,
|
|
input: ToolInput<Self::Input>,
|
|
event_stream: ToolCallEventStream,
|
|
cx: &mut App,
|
|
) -> Task<Result<Self::Output, Self::Output>>;
|
|
|
|
/// Emits events for a previous execution of the tool.
|
|
fn replay(
|
|
&self,
|
|
_input: Self::Input,
|
|
_output: Self::Output,
|
|
_event_stream: ToolCallEventStream,
|
|
_cx: &mut App,
|
|
) -> Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
fn erase(self) -> Arc<dyn AnyAgentTool> {
|
|
Arc::new(Erased(Arc::new(self)))
|
|
}
|
|
}
|
|
|
|
pub struct Erased<T>(T);
|
|
|
|
pub struct AgentToolOutput {
|
|
pub llm_output: Vec<LanguageModelToolResultContent>,
|
|
pub raw_output: serde_json::Value,
|
|
}
|
|
|
|
impl From<anyhow::Error> for AgentToolOutput {
|
|
fn from(error: anyhow::Error) -> Self {
|
|
let llm_output = vec![error.into()];
|
|
let raw_output = serde_json::to_value(&llm_output).unwrap_or_else(|e| {
|
|
log::error!("Failed to serialize tool output: {e}");
|
|
serde_json::Value::Null
|
|
});
|
|
Self {
|
|
raw_output,
|
|
llm_output,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub trait AnyAgentTool {
|
|
fn name(&self) -> SharedString;
|
|
fn description(&self) -> SharedString;
|
|
fn kind(&self) -> acp::ToolKind;
|
|
fn initial_title(&self, input: serde_json::Value, _cx: &mut App) -> SharedString;
|
|
fn input_schema(&self) -> serde_json::Value;
|
|
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<AgentToolOutput, AgentToolOutput>`.
|
|
fn run(
|
|
self: Arc<Self>,
|
|
input: ToolInput<serde_json::Value>,
|
|
event_stream: ToolCallEventStream,
|
|
cx: &mut App,
|
|
) -> Task<Result<AgentToolOutput, AgentToolOutput>>;
|
|
fn replay(
|
|
&self,
|
|
input: serde_json::Value,
|
|
output: serde_json::Value,
|
|
event_stream: ToolCallEventStream,
|
|
cx: &mut App,
|
|
) -> Result<()>;
|
|
}
|
|
|
|
impl<T> AnyAgentTool for Erased<Arc<T>>
|
|
where
|
|
T: AgentTool,
|
|
{
|
|
fn name(&self) -> SharedString {
|
|
T::NAME.into()
|
|
}
|
|
|
|
fn description(&self) -> SharedString {
|
|
T::description()
|
|
}
|
|
|
|
fn kind(&self) -> acp::ToolKind {
|
|
T::kind()
|
|
}
|
|
|
|
fn supports_input_streaming(&self) -> bool {
|
|
T::supports_input_streaming()
|
|
}
|
|
|
|
fn initial_title(&self, input: serde_json::Value, _cx: &mut App) -> SharedString {
|
|
let parsed_input = serde_json::from_value(input.clone()).map_err(|_| input);
|
|
self.0.initial_title(parsed_input, _cx)
|
|
}
|
|
|
|
fn input_schema(&self) -> serde_json::Value {
|
|
let mut schema = T::input_schema().to_value();
|
|
language_model::tool_schema::normalize_tool_schema(&mut schema);
|
|
schema
|
|
}
|
|
|
|
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<Self>,
|
|
input: ToolInput<serde_json::Value>,
|
|
event_stream: ToolCallEventStream,
|
|
cx: &mut App,
|
|
) -> Task<Result<AgentToolOutput, AgentToolOutput>> {
|
|
let tool_input: ToolInput<T::Input> = input.cast();
|
|
let task = self.0.clone().run(tool_input, event_stream, cx);
|
|
cx.spawn(async move |_cx| match task.await {
|
|
Ok(output) => {
|
|
let raw_output = serde_json::to_value(&output).unwrap_or_else(|e| {
|
|
log::error!("Failed to serialize tool output: {e}");
|
|
serde_json::Value::Null
|
|
});
|
|
Ok(AgentToolOutput {
|
|
raw_output,
|
|
llm_output: vec![output.into()],
|
|
})
|
|
}
|
|
Err(error_output) => {
|
|
let raw_output = serde_json::to_value(&error_output).unwrap_or_else(|e| {
|
|
log::error!("Failed to serialize tool error output: {e}");
|
|
serde_json::Value::Null
|
|
});
|
|
Err(AgentToolOutput {
|
|
llm_output: vec![error_output.into()],
|
|
raw_output,
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
fn replay(
|
|
&self,
|
|
input: serde_json::Value,
|
|
output: serde_json::Value,
|
|
event_stream: ToolCallEventStream,
|
|
cx: &mut App,
|
|
) -> Result<()> {
|
|
let input = serde_json::from_value(input)?;
|
|
let output = serde_json::from_value(output)?;
|
|
self.0.replay(input, output, event_stream, cx)
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
sender: mpsc::UnboundedSender<Result<ThreadEvent>>,
|
|
active_compaction: Rc<RefCell<Option<acp_thread::ContextCompactionId>>>,
|
|
}
|
|
|
|
impl ThreadEventStream {
|
|
fn new(sender: mpsc::UnboundedSender<Result<ThreadEvent>>) -> Self {
|
|
Self {
|
|
sender,
|
|
active_compaction: Rc::default(),
|
|
}
|
|
}
|
|
|
|
fn send_user_message(&self, message: &UserMessage) {
|
|
self.sender
|
|
.unbounded_send(Ok(ThreadEvent::UserMessage(message.clone())))
|
|
.ok();
|
|
}
|
|
|
|
fn send_text(&self, text: &str) {
|
|
self.sender
|
|
.unbounded_send(Ok(ThreadEvent::AgentText(text.to_string())))
|
|
.ok();
|
|
}
|
|
|
|
fn send_thinking(&self, text: &str) {
|
|
self.sender
|
|
.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.sender
|
|
.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)
|
|
.name(tool_name)
|
|
.kind(kind)
|
|
.raw_input(input)
|
|
}
|
|
|
|
fn update_tool_call_fields(
|
|
&self,
|
|
tool_call_id: &acp::ToolCallId,
|
|
fields: acp::ToolCallUpdateFields,
|
|
meta: Option<acp::Meta>,
|
|
) {
|
|
self.sender
|
|
.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.sender
|
|
.unbounded_send(Ok(ThreadEvent::ToolCallAuthorizationResolved {
|
|
tool_call_id: tool_call_id.clone(),
|
|
outcome,
|
|
}))
|
|
.ok();
|
|
}
|
|
|
|
fn send_retry(&self, status: acp_thread::RetryStatus) {
|
|
self.sender
|
|
.unbounded_send(Ok(ThreadEvent::Retry(status)))
|
|
.ok();
|
|
}
|
|
|
|
fn send_context_compaction(
|
|
&self,
|
|
id: acp_thread::ContextCompactionId,
|
|
status: acp_thread::ContextCompactionStatus,
|
|
) {
|
|
if status == acp_thread::ContextCompactionStatus::InProgress {
|
|
self.active_compaction.replace(Some(id.clone()));
|
|
}
|
|
self.sender
|
|
.unbounded_send(Ok(ThreadEvent::ContextCompaction(
|
|
acp_thread::ContextCompaction {
|
|
id,
|
|
status,
|
|
error: None,
|
|
summary: Vec::new(),
|
|
},
|
|
)))
|
|
.ok();
|
|
}
|
|
|
|
fn send_context_compaction_update(
|
|
&self,
|
|
id: acp_thread::ContextCompactionId,
|
|
summary_delta: &str,
|
|
) {
|
|
self.sender
|
|
.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,
|
|
) {
|
|
if status != acp_thread::ContextCompactionStatus::InProgress
|
|
&& self.active_compaction.borrow().as_ref() == Some(&id)
|
|
{
|
|
self.active_compaction.replace(None);
|
|
}
|
|
self.sender
|
|
.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.sender
|
|
.unbounded_send(Ok(ThreadEvent::Stop(reason)))
|
|
.ok();
|
|
}
|
|
|
|
fn send_canceled(&self) {
|
|
// The bridge stops consuming at Stop, and its entry cancellation scan
|
|
// may have run before the queued compaction-start event was applied.
|
|
let compaction_id = self.active_compaction.replace(None);
|
|
if let Some(compaction_id) = compaction_id {
|
|
self.update_context_compaction_status(
|
|
compaction_id,
|
|
acp_thread::ContextCompactionStatus::Canceled,
|
|
);
|
|
}
|
|
self.send_stop(acp::StopReason::Cancelled);
|
|
}
|
|
|
|
fn send_error(&self, error: impl Into<anyhow::Error>) {
|
|
self.sender.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<Arc<dyn Fs>>,
|
|
cancellation_rx: watch::Receiver<bool>,
|
|
/// Shared, thread-scoped sandbox grants (see [`Thread::sandbox_grants`]).
|
|
sandbox_grants: Rc<RefCell<ThreadSandboxGrants>>,
|
|
/// 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<WeakEntity<Thread>>,
|
|
}
|
|
|
|
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<RefCell<ThreadSandboxGrants>>,
|
|
) -> (Self, ToolCallEventStreamReceiver) {
|
|
let (events_tx, events_rx) = mpsc::unbounded::<Result<ThreadEvent>>();
|
|
let (_cancellation_tx, cancellation_rx) = watch::channel(false);
|
|
|
|
// The raw and scoped ids deliberately differ (mirroring
|
|
// `scoped_tool_call_id`) so that conflating them fails tests instead
|
|
// of silently passing.
|
|
let stream = ToolCallEventStream::new(
|
|
"test_id".into(),
|
|
acp::ToolCallId::new("0:test_id"),
|
|
ThreadEventStream::new(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<bool>) {
|
|
let (events_tx, events_rx) = mpsc::unbounded::<Result<ThreadEvent>>();
|
|
let (cancellation_tx, cancellation_rx) = watch::channel(false);
|
|
|
|
// The raw and scoped ids deliberately differ (mirroring
|
|
// `scoped_tool_call_id`) so that conflating them fails tests instead
|
|
// of silently passing.
|
|
let stream = ToolCallEventStream::new(
|
|
"test_id".into(),
|
|
acp::ToolCallId::new("0:test_id"),
|
|
ThreadEventStream::new(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<bool>) {
|
|
cancellation_tx.send(true).ok();
|
|
}
|
|
|
|
fn new(
|
|
tool_use_id: LanguageModelToolUseId,
|
|
tool_call_id: acp::ToolCallId,
|
|
stream: ThreadEventStream,
|
|
fs: Option<Arc<dyn Fs>>,
|
|
cancellation_rx: watch::Receiver<bool>,
|
|
sandbox_grants: Rc<RefCell<ThreadSandboxGrants>>,
|
|
thread: Option<WeakEntity<Thread>>,
|
|
) -> 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<WeakEntity<Thread>>, 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<Output = ()> + '_ {
|
|
let mut rx = self.cancellation_rx.clone();
|
|
async move {
|
|
loop {
|
|
if *rx.borrow() {
|
|
return;
|
|
}
|
|
if rx.changed().await.is_err() {
|
|
// Sender dropped, will never be cancelled
|
|
std::future::pending::<()>().await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns true if the user has cancelled this tool call.
|
|
/// This is useful for checking cancellation state after an operation completes,
|
|
/// to determine if the completion was due to user cancellation.
|
|
pub fn was_cancelled_by_user(&self) -> bool {
|
|
*self.cancellation_rx.clone().borrow()
|
|
}
|
|
|
|
pub fn tool_use_id(&self) -> &LanguageModelToolUseId {
|
|
&self.tool_use_id
|
|
}
|
|
|
|
/// The ACP-facing id for this tool call (see [`scoped_tool_call_id`]).
|
|
pub fn tool_call_id(&self) -> &acp::ToolCallId {
|
|
&self.tool_call_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<acp::Meta>,
|
|
) {
|
|
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<acp_thread::Diff>) {
|
|
self.stream
|
|
.sender
|
|
.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
|
|
.sender
|
|
.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 <display_name> MCP tool" → sets `tools.<tool_id>.default = "allow"` or "deny"
|
|
/// - "Only this time" → allow/deny once
|
|
pub fn authorize_third_party_tool(
|
|
&self,
|
|
title: impl Into<String>,
|
|
tool_id: String,
|
|
display_name: String,
|
|
cx: &mut App,
|
|
) -> Task<Result<()>> {
|
|
let title = title.into();
|
|
let options = acp_thread::PermissionOptions::Dropdown(vec![
|
|
acp_thread::PermissionOptionChoice {
|
|
allow: acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new(format!("always_allow_mcp:{tool_id}")),
|
|
format!("Always for {display_name} MCP tool"),
|
|
acp::PermissionOptionKind::AllowAlways,
|
|
),
|
|
deny: acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new(format!("always_deny_mcp:{tool_id}")),
|
|
format!("Always for {display_name} MCP tool"),
|
|
acp::PermissionOptionKind::RejectAlways,
|
|
),
|
|
sub_patterns: vec![],
|
|
},
|
|
acp_thread::PermissionOptionChoice {
|
|
allow: acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new("allow"),
|
|
"Only this time",
|
|
acp::PermissionOptionKind::AllowOnce,
|
|
),
|
|
deny: acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new("deny"),
|
|
"Only this time",
|
|
acp::PermissionOptionKind::RejectOnce,
|
|
),
|
|
sub_patterns: vec![],
|
|
},
|
|
]);
|
|
|
|
// MCP tools are gated only by tool id (no per-input pattern
|
|
// matching), so we pass a single empty input value just to satisfy
|
|
// `decide_permission_from_settings`' signature.
|
|
let check_settings: Box<dyn Fn(&App) -> ToolPermissionDecision> =
|
|
Box::new(move |cx: &App| {
|
|
let settings = agent_settings::AgentSettings::get_global(cx);
|
|
decide_permission_from_settings(&tool_id, &[String::new()], settings)
|
|
});
|
|
|
|
self.run_authorization_loop(title, options, None, Some(check_settings), cx)
|
|
}
|
|
|
|
/// Gate a tool call on user permission, driven by the agent's
|
|
/// tool-permission settings.
|
|
///
|
|
/// Evaluates the current settings up-front: returns `Ok(())` immediately
|
|
/// if the tool is already allowed, an error if it is denied, and
|
|
/// otherwise prompts the user for a decision. While a prompt is pending,
|
|
/// a subscription to `SettingsStore` watches for changes (for example,
|
|
/// when the user clicks "Always for …" on a sibling tool call and the
|
|
/// new rule becomes globally visible). When settings change, the current
|
|
/// prompt is dismissed and the decision is re-evaluated. This closes the
|
|
/// gap where an "Always for …" decision on one pending tool call would
|
|
/// not propagate to other pending tool calls in the same turn or in
|
|
/// subagent turns.
|
|
///
|
|
/// For authorizations that must always prompt regardless of settings
|
|
/// (e.g. symlink-escape confirmations, sensitive settings-file edits),
|
|
/// use [`Self::prompt`] instead.
|
|
pub fn authorize(
|
|
&self,
|
|
title: impl Into<String>,
|
|
context: ToolPermissionContext,
|
|
cx: &mut App,
|
|
) -> Task<Result<()>> {
|
|
let title = title.into();
|
|
let options = context.build_permission_options();
|
|
|
|
let tool_name = context.tool_name.clone();
|
|
let input_values = context.input_values.clone();
|
|
let check_settings: Box<dyn Fn(&App) -> ToolPermissionDecision> =
|
|
Box::new(move |cx: &App| {
|
|
decide_permission_from_settings(
|
|
&tool_name,
|
|
&input_values,
|
|
agent_settings::AgentSettings::get_global(cx),
|
|
)
|
|
});
|
|
|
|
self.run_authorization_loop(title, options, Some(context), Some(check_settings), cx)
|
|
}
|
|
|
|
/// Like [`Self::authorize`], but always prompts the user without
|
|
/// consulting settings. Use this for authorizations that must be
|
|
/// confirmed even when the user has configured `always_allow` rules —
|
|
/// for example, symlink-escape confirmations or edits that target
|
|
/// sensitive settings files.
|
|
pub fn authorize_always_prompt(
|
|
&self,
|
|
title: impl Into<String>,
|
|
context: ToolPermissionContext,
|
|
cx: &mut App,
|
|
) -> Task<Result<()>> {
|
|
let title = title.into();
|
|
let options = context.build_permission_options();
|
|
self.run_authorization_loop(title, options, Some(context), None, cx)
|
|
}
|
|
|
|
/// 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<Result<()>> {
|
|
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
|
|
.sender
|
|
.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::<SettingsStore>(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<Result<()>> {
|
|
// 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();
|
|
// The update must target the ACP-facing (scoped) id: an update keyed
|
|
// by the raw provider id matches no existing tool call, and creating
|
|
// one from empty fields is rejected downstream ("title is required"),
|
|
// which tears down the stream and turns the prompt into a phantom
|
|
// decline.
|
|
let tool_call_id = self.tool_call_id.clone();
|
|
cx.spawn(async move |_cx| {
|
|
let (response_tx, response_rx) = oneshot::channel();
|
|
if let Err(error) =
|
|
stream
|
|
.sender
|
|
.unbounded_send(Ok(ThreadEvent::ToolCallAuthorization(
|
|
ToolCallAuthorization {
|
|
tool_call: acp::ToolCallUpdate::new(
|
|
tool_call_id,
|
|
// Leave the title untouched so the card keeps
|
|
// showing the command (matching the escalation
|
|
// flow).
|
|
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"))?;
|
|
ensure_tool_call_authorization_not_interrupted(&outcome)?;
|
|
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<RefCell<ThreadSandboxGrants>>,
|
|
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<RefCell<ThreadSandboxGrants>>,
|
|
thread: Option<WeakEntity<Thread>>,
|
|
fs: Option<Arc<dyn Fs>>,
|
|
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<Arc<dyn Fs>>,
|
|
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<String>,
|
|
reason: String,
|
|
docs_section: Option<String>,
|
|
retries: usize,
|
|
cx: &mut App,
|
|
) -> Task<Result<SandboxFallbackDecision>> {
|
|
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
|
|
.sender
|
|
.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<Arc<dyn Fs>>, 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<String>,
|
|
message: Option<String>,
|
|
options: Vec<acp::PermissionOption>,
|
|
cx: &mut App,
|
|
) -> Task<Result<acp::PermissionOptionId>> {
|
|
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
|
|
.sender
|
|
.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)
|
|
})
|
|
}
|
|
|
|
/// Requests structured input from the user via an elicitation form and
|
|
/// returns the raw response (accept with content, decline, or cancel).
|
|
///
|
|
/// Unlike [`Self::prompt_for_decision`], which presents a fixed set of
|
|
/// buttons, this renders a form from the provided JSON schema — so callers
|
|
/// can offer free-text fields, single-select enums, and typed inputs. The
|
|
/// session scope is attached by the connection layer.
|
|
pub fn request_elicitation(
|
|
&self,
|
|
message: String,
|
|
schema: acp::ElicitationSchema,
|
|
cx: &mut App,
|
|
) -> Task<Result<acp::CreateElicitationResponse>> {
|
|
let stream = self.stream.clone();
|
|
let tool_call_id = self.tool_call_id.clone();
|
|
cx.spawn(async move |_cx| {
|
|
let (response_tx, response_rx) = oneshot::channel();
|
|
if let Err(error) =
|
|
stream
|
|
.sender
|
|
.unbounded_send(Ok(ThreadEvent::Elicitation(ElicitationRequest {
|
|
tool_call_id,
|
|
message,
|
|
schema,
|
|
response: response_tx,
|
|
})))
|
|
{
|
|
log::error!("Failed to send elicitation request: {error}");
|
|
return Err(anyhow!("Failed to send elicitation request: {error}"));
|
|
}
|
|
|
|
response_rx
|
|
.await
|
|
.map_err(|_| anyhow!("elicitation channel closed"))
|
|
})
|
|
}
|
|
|
|
/// Prompts the user for authorization.
|
|
///
|
|
/// When `check_settings` is `Some`, this gate is settings-driven: the
|
|
/// settings are evaluated up-front (an Allow or Deny result resolves the
|
|
/// task immediately without prompting), and while a prompt is pending a
|
|
/// `SettingsStore` subscription watches for changes. A subsequent Allow
|
|
/// or Deny dismisses the prompt UI and resolves the task without user
|
|
/// interaction.
|
|
///
|
|
/// When `check_settings` is `None`, the user is always prompted and
|
|
/// settings changes are ignored. This suits prompts that aren't
|
|
/// settings-driven (e.g. symlink-escape confirmations).
|
|
fn run_authorization_loop(
|
|
&self,
|
|
title: String,
|
|
options: acp_thread::PermissionOptions,
|
|
context: Option<ToolPermissionContext>,
|
|
check_settings: Option<Box<dyn Fn(&App) -> ToolPermissionDecision>>,
|
|
cx: &mut App,
|
|
) -> Task<Result<()>> {
|
|
// Short-circuit when current settings yield a definitive answer.
|
|
if let Some(check) = check_settings.as_ref() {
|
|
match check(cx) {
|
|
ToolPermissionDecision::Allow => return Task::ready(Ok(())),
|
|
ToolPermissionDecision::Deny(reason) => {
|
|
return Task::ready(Err(anyhow!(reason)));
|
|
}
|
|
ToolPermissionDecision::Confirm => {}
|
|
}
|
|
}
|
|
|
|
let fs = self.fs.clone();
|
|
let stream = self.stream.clone();
|
|
let tool_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
|
|
.sender
|
|
.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::<SettingsStore>(move |_cx| {
|
|
settings_tx.send(()).ok();
|
|
})
|
|
});
|
|
|
|
// Race the user's response against settings changes. On each
|
|
// settings change, re-evaluate `check_settings`: if it now
|
|
// yields a definitive Allow or Deny, resolve the prompt
|
|
// without user interaction. Otherwise keep waiting on the
|
|
// same prompt.
|
|
loop {
|
|
let settings_changed = async {
|
|
if settings_rx.changed().await.is_err() {
|
|
std::future::pending::<()>().await;
|
|
}
|
|
};
|
|
futures::select_biased! {
|
|
outcome = (&mut response_rx).fuse() => {
|
|
let outcome = outcome.map_err(|_| anyhow!("authorization channel closed"))?;
|
|
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<Arc<dyn Fs>>,
|
|
cx: &AsyncApp,
|
|
) -> Result<()> {
|
|
let option_id = outcome.option_id.0.as_ref();
|
|
let err = || Err(anyhow!("Permission to run tool denied by user"));
|
|
|
|
let always_permission = option_id
|
|
.strip_prefix("always_allow:")
|
|
.map(|tool| (tool, ToolPermissionMode::Allow))
|
|
.or_else(|| {
|
|
option_id
|
|
.strip_prefix("always_deny:")
|
|
.map(|tool| (tool, ToolPermissionMode::Deny))
|
|
})
|
|
.or_else(|| {
|
|
option_id
|
|
.strip_prefix("always_allow_mcp:")
|
|
.map(|tool| (tool, ToolPermissionMode::Allow))
|
|
})
|
|
.or_else(|| {
|
|
option_id
|
|
.strip_prefix("always_deny_mcp:")
|
|
.map(|tool| (tool, ToolPermissionMode::Deny))
|
|
});
|
|
|
|
if let Some((tool, mode)) = always_permission {
|
|
let params = outcome.params.as_ref();
|
|
Self::persist_always_permission(tool, mode, params, fs, cx);
|
|
return if mode == ToolPermissionMode::Allow {
|
|
Ok(())
|
|
} else {
|
|
err()
|
|
};
|
|
}
|
|
|
|
// Handle simple "allow" / "deny" (once, no persistence)
|
|
if option_id == "allow" || option_id == "deny" {
|
|
debug_assert!(
|
|
outcome.params.is_none(),
|
|
"unexpected params for once-only permission"
|
|
);
|
|
return if option_id == "allow" { Ok(()) } else { err() };
|
|
}
|
|
|
|
debug_assert!(false, "unexpected permission option_id: {option_id}");
|
|
|
|
err()
|
|
}
|
|
|
|
/// Persists an "always allow" or "always deny" permission, using sub_patterns
|
|
/// from params when present.
|
|
fn persist_always_permission(
|
|
tool: &str,
|
|
mode: ToolPermissionMode,
|
|
params: Option<&acp_thread::SelectedPermissionParams>,
|
|
fs: Option<Arc<dyn Fs>>,
|
|
cx: &AsyncApp,
|
|
) {
|
|
let Some(fs) = fs else {
|
|
return;
|
|
};
|
|
|
|
match params {
|
|
Some(acp_thread::SelectedPermissionParams::Terminal {
|
|
patterns: sub_patterns,
|
|
}) => {
|
|
debug_assert!(
|
|
!sub_patterns.is_empty(),
|
|
"empty sub_patterns for tool {tool} — callers should pass None instead"
|
|
);
|
|
let tool = tool.to_string();
|
|
let sub_patterns = sub_patterns.clone();
|
|
cx.update(|cx| {
|
|
update_settings_file(fs, cx, move |settings, _| {
|
|
let agent = settings.agent.get_or_insert_default();
|
|
for pattern in sub_patterns {
|
|
match mode {
|
|
ToolPermissionMode::Allow => {
|
|
agent.add_tool_allow_pattern(&tool, pattern);
|
|
}
|
|
ToolPermissionMode::Deny => {
|
|
agent.add_tool_deny_pattern(&tool, pattern);
|
|
}
|
|
// If there's no matching pattern this will
|
|
// default to confirm, so falling through is
|
|
// fine here.
|
|
ToolPermissionMode::Confirm => (),
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
None => {
|
|
let tool = tool.to_string();
|
|
cx.update(|cx| {
|
|
update_settings_file(fs, cx, move |settings, _| {
|
|
settings
|
|
.agent
|
|
.get_or_insert_default()
|
|
.set_tool_default_permission(&tool, mode);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub struct ToolCallEventStreamReceiver(mpsc::UnboundedReceiver<Result<ThreadEvent>>);
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
impl ToolCallEventStreamReceiver {
|
|
pub async fn expect_authorization(&mut self) -> ToolCallAuthorization {
|
|
let event = self.0.next().await;
|
|
if let Some(Ok(ThreadEvent::ToolCallAuthorization(auth))) = event {
|
|
auth
|
|
} else {
|
|
panic!("Expected ToolCallAuthorization but got: {:?}", event);
|
|
}
|
|
}
|
|
|
|
pub async fn expect_elicitation(&mut self) -> ElicitationRequest {
|
|
let event = self.0.next().await;
|
|
if let Some(Ok(ThreadEvent::Elicitation(request))) = event {
|
|
request
|
|
} else {
|
|
panic!("Expected Elicitation 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<acp_thread::Diff> {
|
|
let event = self.0.next().await;
|
|
if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateDiff(
|
|
update,
|
|
)))) = event
|
|
{
|
|
update.diff
|
|
} else {
|
|
panic!("Expected diff but got: {:?}", event);
|
|
}
|
|
}
|
|
|
|
pub async fn expect_terminal(&mut self) -> Entity<acp_thread::Terminal> {
|
|
let event = self.0.next().await;
|
|
if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateTerminal(
|
|
update,
|
|
)))) = event
|
|
{
|
|
update.terminal
|
|
} else {
|
|
panic!("Expected terminal but got: {:?}", event);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
impl std::ops::Deref for ToolCallEventStreamReceiver {
|
|
type Target = mpsc::UnboundedReceiver<Result<ThreadEvent>>;
|
|
|
|
fn deref(&self) -> &Self::Target {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
impl std::ops::DerefMut for ToolCallEventStreamReceiver {
|
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
|
&mut self.0
|
|
}
|
|
}
|
|
|
|
impl From<&str> for UserMessageContent {
|
|
fn from(text: &str) -> Self {
|
|
Self::Text(text.into())
|
|
}
|
|
}
|
|
|
|
impl From<String> for UserMessageContent {
|
|
fn from(text: String) -> Self {
|
|
Self::Text(text)
|
|
}
|
|
}
|
|
|
|
impl UserMessageContent {
|
|
pub fn from_content_block(value: acp::ContentBlock, path_style: PathStyle) -> Self {
|
|
match value {
|
|
acp::ContentBlock::Text(text_content) => Self::Text(text_content.text),
|
|
acp::ContentBlock::Image(image_content) => Self::Image(convert_image(image_content)),
|
|
acp::ContentBlock::Audio(_) => {
|
|
// TODO
|
|
Self::Text("[audio]".to_string())
|
|
}
|
|
acp::ContentBlock::ResourceLink(resource_link) => {
|
|
match MentionUri::parse(&resource_link.uri, path_style) {
|
|
Ok(uri) => Self::Mention {
|
|
uri,
|
|
content: 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<UserMessageContent> for acp::ContentBlock {
|
|
fn from(content: UserMessageContent) -> Self {
|
|
match content {
|
|
UserMessageContent::Text(text) => text.into(),
|
|
UserMessageContent::Image(image) => {
|
|
acp::ContentBlock::Image(acp::ImageContent::new(image.source, "image/png"))
|
|
}
|
|
UserMessageContent::Mention { uri, content } => acp::ContentBlock::Resource(
|
|
acp::EmbeddedResource::new(acp::EmbeddedResourceResource::TextResourceContents(
|
|
acp::TextResourceContents::new(content, uri.to_uri().to_string()),
|
|
)),
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn convert_image(image_content: acp::ImageContent) -> LanguageModelImage {
|
|
LanguageModelImage {
|
|
source: image_content.data.into(),
|
|
}
|
|
}
|
|
|
|
#[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;
|
|
|
|
#[test]
|
|
fn compaction_capacity_respects_prompt_and_combined_limits() {
|
|
assert_eq!(
|
|
compaction_input_capacity(90_000, Some(200_000), Some(16_384)),
|
|
90_000
|
|
);
|
|
assert_eq!(
|
|
compaction_input_capacity(128_000, Some(128_000), Some(64_000)),
|
|
64_000
|
|
);
|
|
assert_eq!(
|
|
compaction_input_capacity(90_000, None, Some(64_000)),
|
|
90_000
|
|
);
|
|
assert_eq!(
|
|
compaction_input_capacity(2_000, Some(2_000), Some(3_000)),
|
|
0
|
|
);
|
|
assert_eq!(
|
|
compaction_input_capacity(128_000, Some(128_000), None),
|
|
128_000
|
|
);
|
|
}
|
|
|
|
async fn setup_thread_for_test(cx: &mut TestAppContext) -> (Entity<Thread>, ThreadEventStream) {
|
|
cx.update(|cx| {
|
|
let settings_store = settings::SettingsStore::test(cx);
|
|
cx.set_global(settings_store);
|
|
|
|
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::new(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<Arc<dyn LanguageModel>>) {
|
|
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<dyn LanguageModelProvider>,
|
|
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 Compaction (Completed)\n\nOlder context\n\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<Message> {
|
|
Arc::new(Message::User(UserMessage {
|
|
id,
|
|
content: vec![UserMessageContent::Text(text.to_string())].into(),
|
|
}))
|
|
}
|
|
|
|
fn agent_text_message(text: &str) -> Arc<Message> {
|
|
Arc::new(Message::Agent(AgentMessage {
|
|
content: vec![AgentMessageContent::Text(text.to_string())],
|
|
..Default::default()
|
|
}))
|
|
}
|
|
|
|
fn summary_compaction(summary: &str) -> Arc<Message> {
|
|
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<String> {
|
|
messages
|
|
.iter()
|
|
.skip(1)
|
|
.map(LanguageModelRequestMessage::string_contents)
|
|
.collect()
|
|
}
|
|
|
|
fn request_texts(messages: &[LanguageModelRequestMessage]) -> Vec<String> {
|
|
messages
|
|
.iter()
|
|
.map(LanguageModelRequestMessage::string_contents)
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn test_subagent_partial_output_filters_non_text() {
|
|
let tool_use = LanguageModelToolUse {
|
|
id: LanguageModelToolUseId::from("tool"),
|
|
name: Arc::from("echo"),
|
|
raw_input: "{}".to_string(),
|
|
input: language_model::LanguageModelToolUseInput::Json(json!({})),
|
|
is_input_complete: true,
|
|
thought_signature: None,
|
|
};
|
|
let tool_result = LanguageModelToolResult {
|
|
tool_use_id: tool_use.id.clone(),
|
|
tool_name: tool_use.name.clone(),
|
|
is_error: false,
|
|
content: vec![LanguageModelToolResultContent::Text(Arc::from(
|
|
"tool result",
|
|
))],
|
|
output: Some(json!("raw tool result")),
|
|
};
|
|
let messages = [
|
|
user_text_message(ClientUserMessageId::new(), "old task"),
|
|
agent_text_message("old output"),
|
|
user_text_message(ClientUserMessageId::new(), "current task"),
|
|
Arc::new(Message::Agent(AgentMessage {
|
|
content: vec![
|
|
AgentMessageContent::Text("first".to_string()),
|
|
AgentMessageContent::Thinking {
|
|
text: "hidden thinking".to_string(),
|
|
signature: None,
|
|
},
|
|
AgentMessageContent::Text(" part".to_string()),
|
|
AgentMessageContent::RedactedThinking("redacted thinking".to_string()),
|
|
],
|
|
..AgentMessage::default()
|
|
})),
|
|
Arc::new(Message::Resume),
|
|
summary_compaction("compaction summary"),
|
|
Arc::new(Message::Agent(AgentMessage {
|
|
content: vec![AgentMessageContent::ToolUse(tool_use)],
|
|
tool_results: IndexMap::from_iter([(tool_result.tool_use_id.clone(), tool_result)]),
|
|
..AgentMessage::default()
|
|
})),
|
|
agent_text_message(""),
|
|
Arc::new(Message::Agent(AgentMessage {
|
|
content: vec![AgentMessageContent::Thinking {
|
|
text: "more thinking".to_string(),
|
|
signature: None,
|
|
}],
|
|
..AgentMessage::default()
|
|
})),
|
|
agent_text_message("second part"),
|
|
];
|
|
let pending_message = AgentMessage {
|
|
content: vec![
|
|
AgentMessageContent::Text("pending".to_string()),
|
|
AgentMessageContent::Thinking {
|
|
text: "pending thinking".to_string(),
|
|
signature: None,
|
|
},
|
|
AgentMessageContent::Text(" part".to_string()),
|
|
],
|
|
..AgentMessage::default()
|
|
};
|
|
|
|
assert_eq!(
|
|
subagent_partial_output_from_messages(&messages, Some(&pending_message)),
|
|
"first part\n\nsecond part\n\npending part"
|
|
);
|
|
assert_eq!(subagent_partial_output_from_messages(&[], None), "");
|
|
assert_eq!(
|
|
subagent_partial_output_from_messages(&[], Some(&pending_message)),
|
|
""
|
|
);
|
|
let mut messages = messages.to_vec();
|
|
messages.push(user_text_message(ClientUserMessageId::new(), "next task"));
|
|
assert_eq!(subagent_partial_output_from_messages(&messages, None), "");
|
|
}
|
|
|
|
#[test]
|
|
fn test_subagent_partial_output_bounds_messages_and_unicode_characters() {
|
|
let first = "🦀".repeat(4096);
|
|
let second_prefix = "🚀".repeat(2048);
|
|
let second_suffix = "🦀".repeat(2048);
|
|
let third_prefix = "🌍".repeat(4095);
|
|
let messages = [
|
|
user_text_message(ClientUserMessageId::new(), "current task"),
|
|
agent_text_message("discarded message"),
|
|
agent_text_message(&format!("discarded prefix{first}")),
|
|
Arc::new(Message::Agent(AgentMessage {
|
|
content: vec![
|
|
AgentMessageContent::Text(format!("discarded prefix{second_prefix}")),
|
|
AgentMessageContent::Thinking {
|
|
text: "hidden thinking".to_string(),
|
|
signature: None,
|
|
},
|
|
AgentMessageContent::Text(second_suffix.clone()),
|
|
],
|
|
..AgentMessage::default()
|
|
})),
|
|
];
|
|
let pending_message = AgentMessage {
|
|
content: vec![
|
|
AgentMessageContent::Text(format!("discarded prefix{third_prefix}")),
|
|
AgentMessageContent::Thinking {
|
|
text: "pending thinking".to_string(),
|
|
signature: None,
|
|
},
|
|
AgentMessageContent::Text("🦀".to_string()),
|
|
],
|
|
..AgentMessage::default()
|
|
};
|
|
|
|
let output = subagent_partial_output_from_messages(&messages, Some(&pending_message));
|
|
assert_eq!(
|
|
output,
|
|
format!("{first}\n\n{second_prefix}{second_suffix}\n\n{third_prefix}🦀")
|
|
);
|
|
assert_eq!(output.chars().count(), 12_292);
|
|
assert_eq!(output.len(), 49_156);
|
|
}
|
|
|
|
#[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 thread_id = thread.read_with(cx, |thread, _| thread.id().to_string());
|
|
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.thread_id.as_deref(),
|
|
Some(thread_id.as_str())
|
|
);
|
|
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(&acp::SessionId::new("thread-id"), &messages, Some(0.2));
|
|
|
|
assert_eq!(request.thread_id.as_deref(), Some("thread-id"));
|
|
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_independent_input_limit(cx: &mut TestAppContext) {
|
|
let (thread, _event_stream) = setup_thread_for_test(cx).await;
|
|
let mut model = FakeLanguageModel::default();
|
|
model.set_max_token_count(200_000);
|
|
model.set_max_input_tokens(90_000);
|
|
model.set_max_output_tokens(Some(16_384));
|
|
let model = Arc::new(model);
|
|
let user_message_id = ClientUserMessageId::new();
|
|
|
|
cx.update(|cx| {
|
|
set_auto_compact_settings(
|
|
cx,
|
|
agent_settings::AutoCompactSettings {
|
|
enabled: true,
|
|
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 the independent input limit",
|
|
));
|
|
for (input_tokens, expected_target) in [(80_999, None), (81_000, Some(1))] {
|
|
thread.request_token_usage.insert(
|
|
user_message_id.clone(),
|
|
language_model::TokenUsage {
|
|
input_tokens,
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
assert_eq!(thread.compaction_message_target_ix(cx), expected_target);
|
|
assert_eq!(thread.input_token_capacity(), Some(90_000));
|
|
assert_eq!(thread.latest_token_usage().unwrap().max_tokens, 200_000);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
#[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<Thread>,
|
|
count: usize,
|
|
) -> Vec<Entity<Thread>> {
|
|
cx.update(|cx| {
|
|
let mut subagents = Vec::new();
|
|
for _ in 0..count {
|
|
let subagent = cx.new(|cx| Thread::new_subagent(parent, cx));
|
|
parent.update(cx, |thread, _cx| {
|
|
thread.register_running_subagent(subagent.downgrade());
|
|
});
|
|
subagents.push(subagent);
|
|
}
|
|
subagents
|
|
})
|
|
}
|
|
|
|
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<Self::Input, serde_json::Value>,
|
|
_cx: &mut App,
|
|
) -> SharedString {
|
|
"Registered Image Tool".into()
|
|
}
|
|
|
|
fn run(
|
|
self: Arc<Self>,
|
|
_input: ToolInput<Self::Input>,
|
|
_event_stream: ToolCallEventStream,
|
|
_cx: &mut App,
|
|
) -> Task<Result<Self::Output, Self::Output>> {
|
|
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::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
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());
|
|
}
|
|
|
|
/// Regression test: the Windows-drive (DrvFs) warning prompt must target
|
|
/// the ACP-facing *scoped* tool-call id. When it used the raw provider id,
|
|
/// the update matched no existing tool call, the ACP layer rejected the
|
|
/// resulting title-less insert ("title is required for a tool call"), the
|
|
/// event stream was torn down, and every terminal command was auto-declined
|
|
/// without the user ever seeing a prompt.
|
|
#[gpui::test]
|
|
async fn test_windows_fs_warning_targets_scoped_tool_call_id(cx: &mut TestAppContext) {
|
|
crate::tests::init_test(cx);
|
|
|
|
let (event_stream, mut receiver) = ToolCallEventStream::test();
|
|
let authorize = cx.update(|cx| event_stream.authorize_windows_fs_warning(cx));
|
|
|
|
let authorization = receiver.expect_authorization().await;
|
|
assert_eq!(
|
|
&authorization.tool_call.tool_call_id,
|
|
event_stream.tool_call_id(),
|
|
"the warning prompt must reference the scoped ACP tool-call id, \
|
|
not the raw provider id"
|
|
);
|
|
let details =
|
|
acp_thread::sandbox_authorization_details_from_meta(&authorization.tool_call.meta)
|
|
.expect("warning authorization should include sandbox details");
|
|
assert!(details.warn_windows_fs);
|
|
|
|
authorization
|
|
.response
|
|
.send(acp_thread::SelectedPermissionOutcome::new(
|
|
acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowOnce.as_id()),
|
|
acp::PermissionOptionKind::AllowOnce,
|
|
))
|
|
.unwrap();
|
|
authorize.await.unwrap();
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
#[test]
|
|
fn test_retry_strategy_does_not_retry_status_less_provider_rejection() {
|
|
// A rejection like OpenAI's `cyber_policy` content-policy error has
|
|
// no HTTP status of its own (it arrives as a Zed cloud upstream
|
|
// error code); retrying sends the identical request and gets the
|
|
// identical rejection, so it must not retry.
|
|
let error = LanguageModelCompletionError::from_provider_response(
|
|
language_model::OPEN_AI_PROVIDER_NAME,
|
|
None,
|
|
Some("cyber_policy".to_string()),
|
|
"This content was flagged as potentially violating our terms of use.".to_string(),
|
|
None,
|
|
language_model::ProviderErrorCategory::Other,
|
|
);
|
|
let LanguageModelCompletionError::ProviderRejection {
|
|
category,
|
|
code,
|
|
message,
|
|
..
|
|
} = &error
|
|
else {
|
|
panic!("expected a ProviderRejection, got {error:?}");
|
|
};
|
|
assert_eq!(*category, language_model::ProviderErrorCategory::Other);
|
|
assert_eq!(code.as_deref(), Some("cyber_policy"));
|
|
assert_eq!(
|
|
message,
|
|
"This content was flagged as potentially violating our terms of use."
|
|
);
|
|
|
|
assert!(Thread::retry_strategy_for(&error).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_retry_strategy_does_not_retry_non_retryable_status() {
|
|
// 401 Unauthorized is permanent: retrying with the same credentials
|
|
// fails the same way.
|
|
let error = LanguageModelCompletionError::from_http_status(
|
|
language_model::LanguageModelProviderName::new("Anthropic"),
|
|
http_client::StatusCode::UNAUTHORIZED,
|
|
"invalid x-api-key".to_string(),
|
|
None,
|
|
);
|
|
assert!(Thread::retry_strategy_for(&error).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_retry_strategy_retries_status_less_transient_category() {
|
|
let error = LanguageModelCompletionError::from_provider_response(
|
|
language_model::ANTHROPIC_PROVIDER_NAME,
|
|
None,
|
|
Some("rate_limit_error".to_string()),
|
|
"Rate limit exceeded".to_string(),
|
|
None,
|
|
language_model::ProviderErrorCategory::RateLimit,
|
|
);
|
|
|
|
assert_eq!(
|
|
Thread::retry_strategy_for(&error),
|
|
Some(RetryStrategy::ExponentialBackoff)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_retry_strategy_uses_exponential_backoff_without_retry_after() {
|
|
for status in [
|
|
http_client::StatusCode::TOO_MANY_REQUESTS,
|
|
http_client::StatusCode::INTERNAL_SERVER_ERROR,
|
|
http_client::StatusCode::SERVICE_UNAVAILABLE,
|
|
http_client::StatusCode::from_u16(529).unwrap(),
|
|
] {
|
|
let error = LanguageModelCompletionError::from_http_status(
|
|
language_model::LanguageModelProviderName::new("Anthropic"),
|
|
status,
|
|
"upstream failure".to_string(),
|
|
None,
|
|
);
|
|
let strategy = Thread::retry_strategy_for(&error)
|
|
.unwrap_or_else(|| panic!("expected a retry strategy for status {status}"));
|
|
assert_eq!(
|
|
strategy,
|
|
RetryStrategy::ExponentialBackoff,
|
|
"status {status} should use exponential backoff when no retry_after is given"
|
|
);
|
|
assert_eq!(strategy.delay_after(&error, 0), None);
|
|
assert_eq!(
|
|
strategy.delay_after(&error, 1),
|
|
Some(BASE_RETRY_DELAY),
|
|
"first retry should use the base delay"
|
|
);
|
|
assert_eq!(
|
|
strategy.delay_after(&error, 2),
|
|
Some(BASE_RETRY_DELAY * 2),
|
|
"second retry should double the delay"
|
|
);
|
|
assert_eq!(
|
|
strategy.delay_after(&error, MAX_RETRY_ATTEMPTS + 1),
|
|
None,
|
|
"retrying should stop once attempts are exhausted"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_retry_strategy_honors_retry_after_with_a_fixed_delay() {
|
|
let retry_after = Duration::from_secs(3);
|
|
let error = LanguageModelCompletionError::from_http_status(
|
|
language_model::LanguageModelProviderName::new("Anthropic"),
|
|
http_client::StatusCode::TOO_MANY_REQUESTS,
|
|
"rate limited".to_string(),
|
|
Some(retry_after),
|
|
);
|
|
let strategy = Thread::retry_strategy_for(&error).expect("429 should retry");
|
|
assert_eq!(
|
|
strategy,
|
|
RetryStrategy::FixedDelay {
|
|
delay: retry_after,
|
|
max_attempts: MAX_RETRY_ATTEMPTS,
|
|
}
|
|
);
|
|
// The delay stays fixed across attempts, unlike exponential backoff.
|
|
assert_eq!(strategy.delay_after(&error, 1), Some(retry_after));
|
|
assert_eq!(
|
|
strategy.delay_after(&error, MAX_RETRY_ATTEMPTS),
|
|
Some(retry_after)
|
|
);
|
|
assert_eq!(strategy.delay_after(&error, MAX_RETRY_ATTEMPTS + 1), None);
|
|
}
|
|
|
|
#[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 registered_tool_call_id = scoped_tool_call_id(0, ®istered_tool_use_id).to_string();
|
|
let missing_tool_call_id = scoped_tool_call_id(0, &missing_tool_use_id).to_string();
|
|
let mut tool_names_by_id = HashMap::default();
|
|
let mut tool_use_ids_with_image_content = HashSet::default();
|
|
while let Some(event) = replay_events.next().await {
|
|
let event = event.unwrap();
|
|
match event {
|
|
ThreadEvent::ToolCall(tool_call) => {
|
|
tool_names_by_id.insert(tool_call.tool_call_id.to_string(), tool_call.name);
|
|
}
|
|
ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update))
|
|
if update.fields.content.as_ref().is_some_and(|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(®istered_tool_call_id));
|
|
assert!(tool_use_ids_with_image_content.contains(&missing_tool_call_id));
|
|
assert_eq!(
|
|
tool_names_by_id
|
|
.get(®istered_tool_call_id)
|
|
.and_then(Option::as_deref),
|
|
Some(ReplayImageTool::NAME)
|
|
);
|
|
assert_eq!(
|
|
tool_names_by_id
|
|
.get(&missing_tool_call_id)
|
|
.and_then(Option::as_deref),
|
|
Some("missing_image_tool")
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_set_model_propagates_to_subagents(cx: &mut TestAppContext) {
|
|
let (parent, _event_stream) = setup_thread_for_test(cx).await;
|
|
let subagents = setup_parent_with_subagents(cx, &parent, 2);
|
|
|
|
let new_model: Arc<dyn LanguageModel> = Arc::new(FakeLanguageModel::with_id_and_thinking(
|
|
"test-provider",
|
|
"new-model",
|
|
"New Model",
|
|
false,
|
|
));
|
|
|
|
cx.update(|cx| {
|
|
parent.update(cx, |thread, cx| {
|
|
thread.set_model(new_model, cx);
|
|
});
|
|
|
|
for subagent in &subagents {
|
|
let subagent_model_id = subagent.read(cx).model().unwrap().id();
|
|
assert_eq!(
|
|
subagent_model_id.0.as_ref(),
|
|
"new-model",
|
|
"Subagent model should match parent model after set_model"
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_set_summarization_model_propagates_to_subagents(cx: &mut TestAppContext) {
|
|
let (parent, _event_stream) = setup_thread_for_test(cx).await;
|
|
let subagents = setup_parent_with_subagents(cx, &parent, 2);
|
|
|
|
let summary_model: Arc<dyn LanguageModel> =
|
|
Arc::new(FakeLanguageModel::with_id_and_thinking(
|
|
"test-provider",
|
|
"summary-model",
|
|
"Summary Model",
|
|
false,
|
|
));
|
|
|
|
cx.update(|cx| {
|
|
parent.update(cx, |thread, cx| {
|
|
thread.set_summarization_model(Some(summary_model), cx);
|
|
});
|
|
|
|
for subagent in &subagents {
|
|
let subagent_summary_id = subagent.read(cx).summarization_model().unwrap().id();
|
|
assert_eq!(
|
|
subagent_summary_id.0.as_ref(),
|
|
"summary-model",
|
|
"Subagent summarization model should match parent after set_summarization_model"
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_set_thinking_enabled_propagates_to_subagents(cx: &mut TestAppContext) {
|
|
let (parent, _event_stream) = setup_thread_for_test(cx).await;
|
|
let subagents = setup_parent_with_subagents(cx, &parent, 2);
|
|
|
|
cx.update(|cx| {
|
|
parent.update(cx, |thread, cx| {
|
|
thread.set_thinking_enabled(true, cx);
|
|
});
|
|
|
|
for subagent in &subagents {
|
|
assert!(
|
|
subagent.read(cx).thinking_enabled(),
|
|
"Subagent thinking should be enabled after parent enables it"
|
|
);
|
|
}
|
|
|
|
parent.update(cx, |thread, cx| {
|
|
thread.set_thinking_enabled(false, cx);
|
|
});
|
|
|
|
for subagent in &subagents {
|
|
assert!(
|
|
!subagent.read(cx).thinking_enabled(),
|
|
"Subagent thinking should be disabled after parent disables it"
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_set_thinking_effort_propagates_to_subagents(cx: &mut TestAppContext) {
|
|
let (parent, _event_stream) = setup_thread_for_test(cx).await;
|
|
let subagents = setup_parent_with_subagents(cx, &parent, 2);
|
|
|
|
cx.update(|cx| {
|
|
parent.update(cx, |thread, cx| {
|
|
thread.set_thinking_effort(Some("high".to_string()), cx);
|
|
});
|
|
|
|
for subagent in &subagents {
|
|
assert_eq!(
|
|
subagent.read(cx).thinking_effort().map(|s| s.as_str()),
|
|
Some("high"),
|
|
"Subagent thinking effort should match parent"
|
|
);
|
|
}
|
|
|
|
parent.update(cx, |thread, cx| {
|
|
thread.set_thinking_effort(None, cx);
|
|
});
|
|
|
|
for subagent in &subagents {
|
|
assert_eq!(
|
|
subagent.read(cx).thinking_effort(),
|
|
None,
|
|
"Subagent thinking effort should be None after parent clears it"
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_subagent_inherits_settings_at_creation(cx: &mut TestAppContext) {
|
|
let (parent, _event_stream) = setup_thread_for_test(cx).await;
|
|
|
|
cx.update(|cx| {
|
|
parent.update(cx, |thread, cx| {
|
|
thread.set_speed(Speed::Fast, cx);
|
|
thread.set_thinking_enabled(true, cx);
|
|
thread.set_thinking_effort(Some("high".to_string()), cx);
|
|
thread.set_profile(AgentProfileId("custom-profile".into()), cx);
|
|
});
|
|
});
|
|
|
|
let subagents = setup_parent_with_subagents(cx, &parent, 1);
|
|
|
|
cx.update(|cx| {
|
|
let sub = subagents[0].read(cx);
|
|
assert_eq!(sub.speed(), Some(Speed::Fast));
|
|
assert!(sub.thinking_enabled());
|
|
assert_eq!(sub.thinking_effort().map(|s| s.as_str()), Some("high"));
|
|
assert_eq!(sub.profile(), &AgentProfileId("custom-profile".into()));
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_set_speed_propagates_to_subagents(cx: &mut TestAppContext) {
|
|
let (parent, _event_stream) = setup_thread_for_test(cx).await;
|
|
let subagents = setup_parent_with_subagents(cx, &parent, 2);
|
|
|
|
cx.update(|cx| {
|
|
parent.update(cx, |thread, cx| {
|
|
thread.set_speed(Speed::Fast, cx);
|
|
});
|
|
|
|
for subagent in &subagents {
|
|
assert_eq!(
|
|
subagent.read(cx).speed(),
|
|
Some(Speed::Fast),
|
|
"Subagent speed should match parent after set_speed"
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_dropped_subagent_does_not_panic(cx: &mut TestAppContext) {
|
|
let (parent, _event_stream) = setup_thread_for_test(cx).await;
|
|
let subagents = setup_parent_with_subagents(cx, &parent, 1);
|
|
|
|
// Drop the subagent so the WeakEntity can no longer be upgraded
|
|
drop(subagents);
|
|
|
|
// Should not panic even though the subagent was dropped
|
|
cx.update(|cx| {
|
|
parent.update(cx, |thread, cx| {
|
|
thread.set_thinking_enabled(true, cx);
|
|
thread.set_speed(Speed::Fast, cx);
|
|
thread.set_thinking_effort(Some("high".to_string()), cx);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_handle_tool_use_json_parse_error_adds_tool_use_to_content(
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
let (thread, event_stream) = setup_thread_for_test(cx).await;
|
|
|
|
let tool_use_id = LanguageModelToolUseId::from("test_tool_id");
|
|
let tool_name: Arc<str> = Arc::from("test_tool");
|
|
let raw_input: Arc<str> = Arc::from("{invalid json");
|
|
let json_parse_error = "expected value at line 1 column 1".to_string();
|
|
|
|
let (_cancellation_tx, cancellation_rx) = watch::channel(false);
|
|
|
|
let (_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));
|
|
})
|
|
}
|
|
}
|