## Problem AWS Bedrock's Mantle endpoint (used for GPT-5.x models via the OpenAI-compatible Responses API) has two independent bugs that both cause visible duplication/corruption in the agent panel: 1. **Duplicated message text.** Mantle streams a reply as a series of new `message` output items, where each new item's text is a growing prefix-superset of the previous one, and the full `output_text.delta` sequence is replayed for each item. Forwarding these events through the shared `OpenAiResponseEventMapper` unmodified caused the assistant's reply (and its phase/reasoning metadata) to appear duplicated in the UI. 2. **Colliding tool call ids.** Mantle assigns raw `tool_use` ids as a simple per-request counter (`call_1`, `call_2`, ...) that resets to `call_1` on every request/response cycle to the model — including separate cycles within a single agent turn as it performs tool calls sequentially. Since `acp::ToolCallId` was built directly from that raw id, and `AcpThread::upsert_tool_call` matches tool calls by id across a thread's entire entry list, a later, unrelated tool call reusing a raw id would silently overwrite an earlier, already-completed one instead of appearing as a new entry — visible as "old tool calls getting replaced by new ones." Both were confirmed against real raw Mantle SSE traces and a real persisted thread (`call_1` recurring across 10+ separate request cycles within a single turn). ## Fix **For message duplication:** `MantleResponseEventMapper` wraps `OpenAiResponseEventMapper` and collapses adjacent, same-phase, strict-prefix message extensions into a single logical message, forwarding only the replayed suffix as ordinary incremental text. - Only adjacent, same-phase, strict-prefix extensions are collapsed. Equal, shrinking, divergent, or different-phase messages are still shown as-is. - Any non-message output item (reasoning, tool call) is a hard boundary: it's forwarded unchanged and clears the merge state, so a message after a tool call is never merged with the one before it. - When a message is merged into the previous one, its `StartMessage` and `ReasoningDetails` metadata events are suppressed, since they'd otherwise duplicate what was already emitted for the first message. **For tool call id collisions:** `scoped_tool_call_id(message_ix, tool_use_id)` combines the raw provider id with the index of the `AgentMessage` it belongs to in `Thread::messages`. That index is stable for the lifetime of one request/response cycle (`flush_pending_message` only runs on the next `StartMessage` event), so ids stay unique across the whole thread without needing the raw provider id itself to be globally unique. The index is threaded through explicitly from where each tool call is created (`handle_tool_use_event`/`run_tool`) to where it's later referenced (`process_tool_result`), rather than recomputed later by re-reading `Thread::messages.len()`. We deliberately did not rewrite the raw `tool_use` id itself at the provider layer to make it globally unique, since that id round-trips back to Mantle verbatim as `call_id` in `function_call_output` items on the next request — rewriting it would require a fragile reverse-mapping on every code path that replays tool results. ## Testing - `cargo test -p language_models --lib bedrock` — 23/23 pass (11 tests covering coalescing, independent messages, phase boundaries, tool/reasoning boundaries, chained cumulative extensions, and metadata deduplication). - `cargo test -p agent --lib` — 699/699 pass (2 new regression tests for tool call id scoping: one via live streaming across two request cycles in one turn, one via persistence/`Thread::replay()` across two separate turns). - `script/clippy -p language_models -p agent` — clean. Release Notes: - Fixed duplicated assistant replies and colliding tool calls when using GPT-5.x models on Amazon Bedrock's Mantle endpoint
8382 lines
316 KiB
Rust
8382 lines
316 KiB
Rust
use crate::{
|
|
ApplyCodeActionTool, CodeActionStore, ContextServerRegistry, CopyPathTool, CreateDirectoryTool,
|
|
CreateThreadTool, DbLanguageModel, DbThread, DeletePathTool, DiagnosticsTool, EditFileTool,
|
|
FetchTool, FindPathTool, FindReferencesTool, GetCodeActionsTool, GoToDefinitionTool, GrepTool,
|
|
ListAgentsAndModelsTool, ListDirectoryTool, MovePathTool, ProjectSnapshot, ReadFileTool,
|
|
RenameTool, SandboxedTerminalTool, SpawnAgentTool, SystemPromptTemplate, Template, Templates,
|
|
TerminalTool, ToolPermissionDecision, WebSearchTool, WriteFileTool,
|
|
decide_permission_from_settings,
|
|
};
|
|
use acp_thread::{ClientUserMessageId, MentionUri};
|
|
use action_log::ActionLog;
|
|
use agent_settings::UserAgentsMd;
|
|
|
|
use crate::sandboxing::{
|
|
SandboxRequest, ThreadSandbox, ThreadSandboxGrants, sandbox_git_dirs,
|
|
sandbox_worktree_writable_paths, sandboxing_available_for_project,
|
|
sandboxing_enabled_for_project,
|
|
};
|
|
use agent_client_protocol::schema::v1 as acp;
|
|
use agent_settings::{
|
|
AgentProfileId, AgentProfileSettings, AgentSettings, AutoCompactThreshold, COMPACTION_PROMPT,
|
|
SUMMARIZE_THREAD_DETAILED_PROMPT, SUMMARIZE_THREAD_PROMPT, builtin_profiles,
|
|
};
|
|
use anyhow::{Context as _, Result, anyhow};
|
|
use chrono::{DateTime, Local, Utc};
|
|
use client::UserStore;
|
|
use cloud_api_types::Plan;
|
|
use collections::{HashMap, HashSet, IndexMap};
|
|
use fs::Fs;
|
|
use futures::{
|
|
FutureExt,
|
|
channel::{mpsc, oneshot},
|
|
future::Shared,
|
|
stream::FuturesUnordered,
|
|
};
|
|
use futures::{StreamExt, stream};
|
|
use gpui::{
|
|
App, AppContext, AsyncApp, Context, Entity, EventEmitter, ReadGlobal as _, SharedString, Task,
|
|
WeakEntity,
|
|
};
|
|
use heck::ToSnakeCase as _;
|
|
use language_model::{
|
|
CompletionIntent, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent,
|
|
LanguageModelId, LanguageModelImage, LanguageModelProviderId, LanguageModelRegistry,
|
|
LanguageModelRequest, LanguageModelRequestMessage, LanguageModelRequestTool,
|
|
LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolSchemaFormat,
|
|
LanguageModelToolUse, LanguageModelToolUseId, MessageContent, Role, SelectedModel, Speed,
|
|
StopReason, TokenUsage, ZED_CLOUD_PROVIDER_ID,
|
|
};
|
|
use project::{Project, trusted_worktrees::TrustedWorktrees};
|
|
use prompt_store::ProjectContext;
|
|
use schemars::{JsonSchema, Schema};
|
|
use serde::de::DeserializeOwned;
|
|
use serde::{Deserialize, Serialize};
|
|
use settings::{
|
|
LanguageModelSelection, Settings, SettingsStore, ToolPermissionMode, update_settings_file,
|
|
};
|
|
use std::fmt::Write;
|
|
use std::{cell::RefCell, ops::ControlFlow};
|
|
use std::{
|
|
collections::BTreeMap,
|
|
marker::PhantomData,
|
|
ops::RangeInclusive,
|
|
path::{Path, PathBuf},
|
|
rc::Rc,
|
|
sync::Arc,
|
|
time::{Duration, Instant},
|
|
};
|
|
use util::{ResultExt, debug_panic, markdown::MarkdownCodeBlock, paths::PathStyle};
|
|
use uuid::Uuid;
|
|
|
|
const TOOL_CANCELED_MESSAGE: &str = "Tool canceled by user";
|
|
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)]
|
|
enum RetryStrategy {
|
|
ExponentialBackoff {
|
|
initial_delay: Duration,
|
|
max_attempts: u8,
|
|
},
|
|
Fixed {
|
|
delay: Duration,
|
|
max_attempts: u8,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
|
pub enum Message {
|
|
User(UserMessage),
|
|
Agent(AgentMessage),
|
|
Resume,
|
|
Compaction(CompactionInfo),
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Serialize, Deserialize)]
|
|
pub enum CompactionInfo {
|
|
Summary(SharedString),
|
|
ProviderNative {
|
|
provider: LanguageModelProviderId,
|
|
items: Vec<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(_) => "--- Context Compacted ---\n".into(),
|
|
}
|
|
}
|
|
|
|
pub fn role(&self) -> Role {
|
|
match self {
|
|
Message::User(_) | Message::Resume | Message::Compaction(_) => Role::User,
|
|
Message::Agent(_) => Role::Assistant,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct UserMessage {
|
|
pub id: ClientUserMessageId,
|
|
pub content: Arc<[UserMessageContent]>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum UserMessageContent {
|
|
Text(String),
|
|
Mention {
|
|
uri: MentionUri,
|
|
content: SharedString,
|
|
},
|
|
Image(LanguageModelImage),
|
|
}
|
|
|
|
impl UserMessage {
|
|
pub fn to_markdown(&self) -> String {
|
|
let mut markdown = String::new();
|
|
|
|
for content in &*self.content {
|
|
match content {
|
|
UserMessageContent::Text(text) => {
|
|
markdown.push_str(text);
|
|
markdown.push('\n');
|
|
}
|
|
UserMessageContent::Image(_) => {
|
|
markdown.push_str("<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,
|
|
},
|
|
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 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(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
|
|
.0
|
|
.unbounded_send(Ok(ThreadEvent::ToolCall(
|
|
acp::ToolCall::new(tool_call_id.clone(), tool_use.name.to_string())
|
|
.status(status)
|
|
.raw_input(tool_use.input.to_display_json()),
|
|
)))
|
|
.ok();
|
|
let mut fields = acp::ToolCallUpdateFields::new()
|
|
.status(status)
|
|
.raw_output(output);
|
|
if let Some(content) = replay_content {
|
|
fields = fields.content(content);
|
|
}
|
|
stream.update_tool_call_fields(&tool_call_id, fields, None);
|
|
return;
|
|
};
|
|
|
|
let Ok(input) = tool_use.input.clone().into_json() else {
|
|
return;
|
|
};
|
|
let title = tool.initial_title(input.clone(), cx);
|
|
let kind = tool.kind();
|
|
stream.send_tool_call(&tool_call_id, &tool_use.name, title, kind, input.clone());
|
|
|
|
if let Some(content) = replay_content {
|
|
stream.update_tool_call_fields(
|
|
&tool_call_id,
|
|
acp::ToolCallUpdateFields::new().content(content),
|
|
None,
|
|
);
|
|
}
|
|
|
|
if let Some(output) = output.clone() {
|
|
// For replay, we use a dummy cancellation receiver since the tool already completed
|
|
let (_cancellation_tx, cancellation_rx) = watch::channel(false);
|
|
let tool_event_stream = ToolCallEventStream::new(
|
|
tool_use.id.clone(),
|
|
tool_call_id.clone(),
|
|
stream.clone(),
|
|
Some(self.project.read(cx).fs().clone()),
|
|
cancellation_rx,
|
|
self.sandbox_grants.clone(),
|
|
Some(cx.weak_entity()),
|
|
);
|
|
tool.replay(input, output, tool_event_stream, cx).log_err();
|
|
}
|
|
|
|
stream.update_tool_call_fields(
|
|
&tool_call_id,
|
|
acp::ToolCallUpdateFields::new()
|
|
.status(status)
|
|
.raw_output(output),
|
|
None,
|
|
);
|
|
}
|
|
|
|
/// A canceled tool result carries only the model-facing `TOOL_CANCELED_MESSAGE`
|
|
/// sentinel (inserted exactly when a tool had no real result). It's never
|
|
/// meaningful to the user, so we detect it to skip replaying the tool call.
|
|
fn is_canceled_tool_result(tool_result: &LanguageModelToolResult) -> bool {
|
|
tool_result.is_error
|
|
&& matches!(
|
|
tool_result.content.as_slice(),
|
|
[LanguageModelToolResultContent::Text(text)]
|
|
if text.as_ref() == TOOL_CANCELED_MESSAGE
|
|
)
|
|
}
|
|
|
|
fn tool_result_content_for_replay(
|
|
tool_result: &LanguageModelToolResult,
|
|
) -> Option<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(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
|
|
}
|
|
|
|
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
|
|
.model()
|
|
.cloned()
|
|
.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, request)
|
|
});
|
|
|
|
if compaction.is_some() {
|
|
self.pending_compaction_telemetry = self.build_compaction_telemetry("manual", cx);
|
|
}
|
|
|
|
self.clear_summary();
|
|
cx.notify();
|
|
|
|
let (events_tx, events_rx) = mpsc::unbounded::<Result<ThreadEvent>>();
|
|
let event_stream = ThreadEventStream(events_tx);
|
|
let (cancellation_tx, mut cancellation_rx) = watch::channel(false);
|
|
let task = cx.spawn({
|
|
let event_stream = event_stream.clone();
|
|
async move |this, cx| {
|
|
let result = if let Some((model, request)) = compaction {
|
|
Self::stream_compaction(
|
|
&this,
|
|
&event_stream,
|
|
cancellation_rx.clone(),
|
|
model,
|
|
request,
|
|
CompactionInsertion::Manual { marker_id: id },
|
|
cx,
|
|
)
|
|
.await
|
|
} else {
|
|
Ok(ControlFlow::Continue(()))
|
|
};
|
|
|
|
// If we were cancelled, `cancel()` already took `running_turn`
|
|
// (possibly for a new turn), so leave it alone.
|
|
if *cancellation_rx.borrow() {
|
|
this.update(cx, |this, _| {
|
|
this.emit_compaction_telemetry_outcome("canceled", None)
|
|
})
|
|
.log_err();
|
|
return;
|
|
}
|
|
|
|
match result {
|
|
// On success, the telemetry event is deferred until the next
|
|
// completion reports usage (see `handle_completion_event`),
|
|
// so we leave `pending_compaction_telemetry` in place here.
|
|
Ok(_) => event_stream.send_stop(acp::StopReason::EndTurn),
|
|
Err(error) => {
|
|
log::error!("Manual compaction failed: {:?}", error);
|
|
this.update(cx, |this, _| {
|
|
this.emit_compaction_telemetry_outcome(
|
|
"failed",
|
|
Some(error.to_string()),
|
|
)
|
|
})
|
|
.log_err();
|
|
event_stream.send_error(error);
|
|
}
|
|
}
|
|
|
|
_ = this.update(cx, |this, _| this.running_turn.take());
|
|
}
|
|
});
|
|
self.running_turn = Some(RunningTurn::new(
|
|
event_stream,
|
|
BTreeMap::default(),
|
|
cancellation_tx,
|
|
task,
|
|
));
|
|
|
|
Ok(events_rx)
|
|
}
|
|
|
|
pub fn push_acp_user_block(
|
|
&mut self,
|
|
id: ClientUserMessageId,
|
|
blocks: impl IntoIterator<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(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);
|
|
}
|
|
}
|
|
}
|
|
Err(error) => {
|
|
this.update(cx, |this, _| {
|
|
this.emit_compaction_telemetry_outcome(
|
|
"failed",
|
|
Some(error_message),
|
|
)
|
|
})?;
|
|
return Err(error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Re-read the model and refresh tools on each iteration so that
|
|
// mid-turn changes (e.g. the user switches model, toggles tools,
|
|
// or changes profile) take effect between tool-call rounds.
|
|
// If a refusal fallback is active, use that model instead.
|
|
let (model, request) = this.update(cx, |this, cx| {
|
|
let model = refusal_fallback_model
|
|
.clone()
|
|
.or_else(|| this.model().cloned())
|
|
.ok_or_else(|| anyhow!(NoModelConfiguredError))?;
|
|
this.refresh_turn_tools(cx);
|
|
let request = this.build_completion_request(intent, cx)?;
|
|
this.current_request_token_usage = TokenUsage::default();
|
|
anyhow::Ok((model, request))
|
|
})??;
|
|
|
|
telemetry::event!(
|
|
"Agent Thread Completion",
|
|
thread_id = this.read_with(cx, |this, _| this.id.to_string())?,
|
|
parent_thread_id = this.read_with(cx, |this, _| this
|
|
.parent_thread_id()
|
|
.map(|id| id.to_string()))?,
|
|
prompt_id = this.read_with(cx, |this, _| this.prompt_id.to_string())?,
|
|
model = model.telemetry_id(),
|
|
model_provider = model.provider_id().to_string(),
|
|
attempt
|
|
);
|
|
|
|
log::debug!("Calling model.stream_completion, attempt {}", attempt);
|
|
|
|
let (mut events, mut error) = match model.stream_completion(request, cx).await {
|
|
Ok(events) => (events.fuse(), None),
|
|
Err(err) => (stream::empty().boxed().fuse(), Some(err)),
|
|
};
|
|
let mut tool_results: FuturesUnordered<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.model().cloned()?;
|
|
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", 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<()>> {
|
|
log::debug!("Running compaction");
|
|
let compaction_id = acp_thread::ContextCompactionId(Uuid::new_v4().to_string().into());
|
|
event_stream.send_context_compaction(
|
|
compaction_id.clone(),
|
|
acp_thread::ContextCompactionStatus::InProgress,
|
|
);
|
|
let stream = futures::select! {
|
|
result = model.stream_completion(request, cx).fuse() => result,
|
|
_ = cancellation_rx.changed().fuse() => {
|
|
if *cancellation_rx.borrow() {
|
|
log::debug!("Compaction cancelled before request started");
|
|
return Ok(ControlFlow::Break(()));
|
|
}
|
|
return Ok(ControlFlow::Continue(()));
|
|
}
|
|
};
|
|
let mut stream = stream?;
|
|
|
|
let mut summary = String::new();
|
|
loop {
|
|
let event = futures::select! {
|
|
event = stream.next().fuse() => event,
|
|
_ = cancellation_rx.changed().fuse() => {
|
|
if *cancellation_rx.borrow() {
|
|
log::debug!("Compaction cancelled while summarizing");
|
|
return Ok(ControlFlow::Break(()));
|
|
}
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let Some(event) = event else {
|
|
break;
|
|
};
|
|
|
|
match event? {
|
|
LanguageModelCompletionEvent::Text(text) => {
|
|
summary.push_str(&text);
|
|
event_stream.send_context_compaction_update(compaction_id.clone(), &text);
|
|
}
|
|
LanguageModelCompletionEvent::UsageUpdate(usage) => {
|
|
this.update(cx, |this, _cx| {
|
|
this.accumulate_token_usage(usage);
|
|
})?;
|
|
}
|
|
LanguageModelCompletionEvent::Stop(_)
|
|
| LanguageModelCompletionEvent::Started
|
|
| LanguageModelCompletionEvent::Queued { .. }
|
|
| LanguageModelCompletionEvent::Thinking { .. }
|
|
| LanguageModelCompletionEvent::RedactedThinking { .. }
|
|
| LanguageModelCompletionEvent::ReasoningDetails(_)
|
|
| LanguageModelCompletionEvent::ToolUse(_)
|
|
| LanguageModelCompletionEvent::ToolUseJsonParseError { .. }
|
|
| LanguageModelCompletionEvent::StartMessage { .. }
|
|
| LanguageModelCompletionEvent::Compaction(_) => {}
|
|
}
|
|
}
|
|
|
|
if *cancellation_rx.borrow() {
|
|
log::debug!("Compaction cancelled after summarizing");
|
|
return Ok(ControlFlow::Break(()));
|
|
}
|
|
|
|
let summary = summary.trim().to_string();
|
|
if summary.is_empty() {
|
|
log::warn!("Compaction produced an empty summary");
|
|
return Err(anyhow::anyhow!("Compaction produced an empty summary"));
|
|
}
|
|
|
|
log::debug!("Compaction succeeded:\n{summary}");
|
|
event_stream.update_context_compaction_status(
|
|
compaction_id,
|
|
acp_thread::ContextCompactionStatus::Completed,
|
|
);
|
|
|
|
this.update(cx, |this, cx| {
|
|
let compaction = Arc::new(Message::Compaction(CompactionInfo::Summary(summary.into())));
|
|
match insertion {
|
|
CompactionInsertion::Auto { insertion_ix } => {
|
|
if insertion_ix <= this.messages.len() {
|
|
this.messages.insert(insertion_ix, compaction);
|
|
} else {
|
|
this.messages.push(compaction);
|
|
}
|
|
}
|
|
CompactionInsertion::Manual { marker_id } => {
|
|
this.messages.push(Arc::new(Message::User(UserMessage {
|
|
id: marker_id,
|
|
content: Arc::from([]),
|
|
})));
|
|
this.messages.push(compaction);
|
|
}
|
|
}
|
|
cx.notify();
|
|
})?;
|
|
|
|
Ok(ControlFlow::Continue(()))
|
|
}
|
|
|
|
fn process_tool_result(
|
|
this: &WeakEntity<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::PromptTooLarge { tokens } = &error {
|
|
self.mark_token_limit_exceeded(*tokens, cx);
|
|
}
|
|
|
|
let Some(model) = self.model() else {
|
|
return Err(anyhow!(error));
|
|
};
|
|
|
|
let auto_retry = if model.provider_id() == ZED_CLOUD_PROVIDER_ID {
|
|
plan.is_some()
|
|
} else {
|
|
true
|
|
};
|
|
|
|
if !auto_retry {
|
|
return Err(anyhow!(error));
|
|
}
|
|
|
|
let Some(strategy) = Self::retry_strategy_for(&error) else {
|
|
return Err(anyhow!(error));
|
|
};
|
|
|
|
let max_attempts = match &strategy {
|
|
RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts,
|
|
RetryStrategy::Fixed { max_attempts, .. } => *max_attempts,
|
|
};
|
|
|
|
if attempt > max_attempts {
|
|
return Err(anyhow!(error));
|
|
}
|
|
|
|
let delay = match &strategy {
|
|
RetryStrategy::ExponentialBackoff { initial_delay, .. } => {
|
|
let delay_secs = initial_delay.as_secs() * 2u64.pow((attempt - 1) as u32);
|
|
Duration::from_secs(delay_secs)
|
|
}
|
|
RetryStrategy::Fixed { delay, .. } => *delay,
|
|
};
|
|
log::debug!("Retry attempt {attempt} with delay {delay:?}");
|
|
|
|
Ok(acp_thread::RetryStatus {
|
|
last_error: error.to_string().into(),
|
|
attempt: attempt as usize,
|
|
max_attempts: max_attempts as usize,
|
|
started_at: Instant::now(),
|
|
duration: delay,
|
|
meta: None,
|
|
})
|
|
}
|
|
|
|
/// A helper method that's called on every streamed completion event.
|
|
/// Returns an optional tool result task, which the main agentic loop will
|
|
/// send back to the model when it resolves.
|
|
fn handle_completion_event(
|
|
&mut self,
|
|
event: LanguageModelCompletionEvent,
|
|
event_stream: &ThreadEventStream,
|
|
cancellation_rx: watch::Receiver<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 {
|
|
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.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()
|
|
.filter_map(|(tool_name, tool)| {
|
|
log::trace!("Including tool: {}", tool_name);
|
|
let mut description = tool.description().to_string();
|
|
let mut schema = tool.input_schema(model.tool_input_format()).log_err()?;
|
|
// TEMPORARY (sandboxing feature flag): with the flag off,
|
|
// the fetch and create_directory descriptions/schemas must
|
|
// not advertise sandbox-dependent behavior (host grants,
|
|
// out-of-project creation via the `reason` field), since
|
|
// the corresponding runtime paths are disabled. Restore
|
|
// the pre-sandboxing model-facing surface here rather than
|
|
// forking the tools; delete this when the flag is removed
|
|
// again.
|
|
if !sandboxing_enabled {
|
|
if tool_name.as_ref() == FetchTool::NAME {
|
|
description =
|
|
"Fetches a URL and returns the content as Markdown.".to_string();
|
|
} else if tool_name.as_ref() == CreateDirectoryTool::NAME {
|
|
description = "Creates a new directory at the specified path within \
|
|
the project. Returns confirmation that the directory was \
|
|
created.\n\nThis tool creates a directory and all necessary \
|
|
parent directories. It should be used whenever you need to \
|
|
create new directories within the project.\nThe only supported \
|
|
path outside the project is `~/.agents/skills` or a descendant, \
|
|
for global agent skills."
|
|
.to_string();
|
|
if let Some(properties) = schema
|
|
.get_mut("properties")
|
|
.and_then(|value| value.as_object_mut())
|
|
{
|
|
properties.remove("reason");
|
|
}
|
|
}
|
|
}
|
|
Some(LanguageModelRequestTool::function(
|
|
tool_name.to_string(),
|
|
description,
|
|
schema,
|
|
tool.supports_input_streaming(),
|
|
))
|
|
})
|
|
.collect::<Vec<_>>()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
log::debug!("Building completion request");
|
|
log::debug!("Completion intent: {:?}", completion_intent);
|
|
|
|
let available_tools: Vec<_> = self
|
|
.running_turn
|
|
.as_ref()
|
|
.map(|turn| turn.tools.keys().cloned().collect())
|
|
.unwrap_or_default();
|
|
|
|
log::debug!("Request includes {} tools", available_tools.len());
|
|
let messages = self.build_request_messages(available_tools, cx);
|
|
log::debug!("Request will include {} messages", messages.len());
|
|
|
|
let request = LanguageModelRequest {
|
|
thread_id: Some(self.id.to_string()),
|
|
prompt_id: Some(self.prompt_id.to_string()),
|
|
intent: Some(completion_intent),
|
|
messages,
|
|
tools,
|
|
tool_choice: None,
|
|
stop: Vec::new(),
|
|
temperature: AgentSettings::temperature_for_model(model, cx),
|
|
// Models that can't run with thinking disabled ignore the
|
|
// toggle state, which may be stale from a previously selected
|
|
// model that could.
|
|
thinking_allowed: self.thinking_enabled || !model.supports_disabling_thinking(),
|
|
thinking_effort: self.thinking_effort.clone(),
|
|
speed: self.speed(),
|
|
compact_at_tokens: None,
|
|
};
|
|
|
|
log::debug!("Completion request built successfully");
|
|
Ok(request)
|
|
}
|
|
|
|
fn enabled_tools(&self, cx: &App) -> BTreeMap<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()
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/// Captures the data for an `"Agent Compaction Completed"` telemetry event
|
|
/// at the moment a compaction starts. Returns `None` if there's no model.
|
|
fn build_compaction_telemetry(
|
|
&self,
|
|
trigger: &'static str,
|
|
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 = max_tokens.saturating_sub(model.max_output_tokens().unwrap_or(0));
|
|
let tokens_before = self
|
|
.latest_request_token_usage()
|
|
.map(|usage| total_input_tokens(usage).saturating_add(usage.output_tokens));
|
|
Some(CompactionTelemetry {
|
|
trigger,
|
|
thread_id: self.id.to_string(),
|
|
parent_thread_id: self.parent_thread_id().map(|id| id.to_string()),
|
|
prompt_id: self.prompt_id.to_string(),
|
|
model: model.telemetry_id(),
|
|
model_provider: model.provider_id().to_string(),
|
|
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> {
|
|
let auto_compact = AgentSettings::get_global(cx).auto_compact;
|
|
if !auto_compact.enabled {
|
|
return None;
|
|
}
|
|
|
|
let model = self.model()?;
|
|
let max_token_count = model.max_token_count();
|
|
let max_input_tokens =
|
|
max_token_count.saturating_sub(model.max_output_tokens().unwrap_or(0));
|
|
// Models with a small context window don't leave enough headroom for a
|
|
// compaction pass; the UI warns the user about the token limit instead.
|
|
if max_input_tokens < MIN_COMPACTION_CONTEXT_WINDOW {
|
|
return None;
|
|
}
|
|
let (usage_ix, usage) = {
|
|
let this = &self;
|
|
this.messages
|
|
.iter()
|
|
.enumerate()
|
|
.rev()
|
|
.find_map(|(ix, message)| {
|
|
let Message::User(user_message) = &**message else {
|
|
return None;
|
|
};
|
|
this.request_token_usage
|
|
.get(&user_message.id)
|
|
.copied()
|
|
.map(|usage| (ix, usage))
|
|
})
|
|
}?;
|
|
if latest_compaction_message_ix_before(&self.messages, self.messages.len())
|
|
.is_some_and(|compaction_ix| compaction_ix > usage_ix)
|
|
{
|
|
return None;
|
|
}
|
|
|
|
let active_tokens = total_input_tokens(usage).saturating_add(usage.output_tokens);
|
|
let compaction_threshold =
|
|
auto_compact_threshold_token_count(auto_compact.threshold, max_input_tokens);
|
|
if active_tokens < compaction_threshold {
|
|
return None;
|
|
}
|
|
|
|
let insertion_ix = match self.messages.last() {
|
|
Some(message)
|
|
if matches!(
|
|
&**message,
|
|
Message::User(UserMessage { id, .. }) if !self.request_token_usage.contains_key(id)
|
|
) =>
|
|
{
|
|
self.messages.len().saturating_sub(1)
|
|
}
|
|
_ => self.messages.len(),
|
|
};
|
|
Some(insertion_ix)
|
|
}
|
|
|
|
/// Insertion point for a manually-triggered compaction.
|
|
/// Returns `None` only when there is nothing to summarize (no messages, or the thread already ends in a compaction).
|
|
fn forced_compaction_target_ix(&self) -> Option<usize> {
|
|
if matches!(
|
|
self.messages.last().map(|message| &**message),
|
|
None | Some(Message::Compaction(_))
|
|
) {
|
|
return None;
|
|
}
|
|
Some(self.messages.len())
|
|
}
|
|
|
|
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::*;
|
|
use http_client::StatusCode;
|
|
|
|
// General strategy here:
|
|
// - If retrying won't help (e.g. invalid API key or payload too large), return None so we don't retry at all.
|
|
// - If it's a time-based issue (e.g. server overloaded, rate limit exceeded), retry up to 4 times with exponential backoff.
|
|
// - If it's an issue that *might* be fixed by retrying (e.g. internal server error), retry up to 3 times.
|
|
match error {
|
|
HttpResponseError {
|
|
status_code: StatusCode::TOO_MANY_REQUESTS,
|
|
..
|
|
} => Some(RetryStrategy::ExponentialBackoff {
|
|
initial_delay: BASE_RETRY_DELAY,
|
|
max_attempts: MAX_RETRY_ATTEMPTS,
|
|
}),
|
|
ServerOverloaded { retry_after, .. } | RateLimitExceeded { retry_after, .. } => {
|
|
Some(RetryStrategy::Fixed {
|
|
delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
|
|
max_attempts: MAX_RETRY_ATTEMPTS,
|
|
})
|
|
}
|
|
UpstreamProviderError {
|
|
status,
|
|
retry_after,
|
|
..
|
|
} => match *status {
|
|
StatusCode::TOO_MANY_REQUESTS | StatusCode::SERVICE_UNAVAILABLE => {
|
|
Some(RetryStrategy::Fixed {
|
|
delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
|
|
max_attempts: MAX_RETRY_ATTEMPTS,
|
|
})
|
|
}
|
|
StatusCode::INTERNAL_SERVER_ERROR => Some(RetryStrategy::Fixed {
|
|
delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
|
|
// Internal Server Error could be anything, retry up to 3 times.
|
|
max_attempts: 3,
|
|
}),
|
|
status => {
|
|
// There is no StatusCode variant for the unofficial HTTP 529 ("The service is overloaded"),
|
|
// but we frequently get them in practice. See https://http.dev/529
|
|
if status.as_u16() == 529 {
|
|
Some(RetryStrategy::Fixed {
|
|
delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
|
|
max_attempts: MAX_RETRY_ATTEMPTS,
|
|
})
|
|
} else {
|
|
Some(RetryStrategy::Fixed {
|
|
delay: retry_after.unwrap_or(BASE_RETRY_DELAY),
|
|
max_attempts: 2,
|
|
})
|
|
}
|
|
}
|
|
},
|
|
ApiInternalServerError { .. } => Some(RetryStrategy::Fixed {
|
|
delay: BASE_RETRY_DELAY,
|
|
max_attempts: 3,
|
|
}),
|
|
ApiReadResponseError { .. }
|
|
| HttpSend { .. }
|
|
| DeserializeResponse { .. }
|
|
| BadRequestFormat { .. } => Some(RetryStrategy::Fixed {
|
|
delay: BASE_RETRY_DELAY,
|
|
max_attempts: 3,
|
|
}),
|
|
// Retrying these errors definitely shouldn't help.
|
|
HttpResponseError {
|
|
status_code:
|
|
StatusCode::PAYLOAD_TOO_LARGE | StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED,
|
|
..
|
|
}
|
|
| AuthenticationError { .. }
|
|
| PermissionError { .. }
|
|
| NoApiKey { .. }
|
|
| ApiEndpointNotFound { .. }
|
|
| PromptTooLarge { .. } => None,
|
|
// These errors might be transient, so retry them
|
|
SerializeRequest { .. } | BuildRequestBody { .. } | StreamEndedUnexpectedly { .. } => {
|
|
Some(RetryStrategy::Fixed {
|
|
delay: BASE_RETRY_DELAY,
|
|
max_attempts: 1,
|
|
})
|
|
}
|
|
// Retry all other 4xx and 5xx errors once.
|
|
HttpResponseError { status_code, .. }
|
|
if status_code.is_client_error() || status_code.is_server_error() =>
|
|
{
|
|
Some(RetryStrategy::Fixed {
|
|
delay: BASE_RETRY_DELAY,
|
|
max_attempts: 3,
|
|
})
|
|
}
|
|
// Retrying won't help for Payment Required errors.
|
|
PaymentRequired => None,
|
|
// Retrying won't help until the user consents to data retention
|
|
// or switches models.
|
|
DataRetentionConsentRequired { .. } => None,
|
|
// Conservatively assume that any other errors are non-retryable
|
|
HttpResponseError { .. } | Other(..) => Some(RetryStrategy::Fixed {
|
|
delay: BASE_RETRY_DELAY,
|
|
max_attempts: 2,
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn total_input_tokens(usage: language_model::TokenUsage) -> u64 {
|
|
usage
|
|
.input_tokens
|
|
.saturating_add(usage.cache_creation_input_tokens)
|
|
.saturating_add(usage.cache_read_input_tokens)
|
|
}
|
|
|
|
fn auto_compact_threshold_token_count(
|
|
threshold: AutoCompactThreshold,
|
|
max_token_count: u64,
|
|
) -> u64 {
|
|
match threshold {
|
|
AutoCompactThreshold::Percentage(percent) => {
|
|
((max_token_count as f64) * percent).ceil() as u64
|
|
}
|
|
AutoCompactThreshold::TokensUsed(tokens) => tokens,
|
|
AutoCompactThreshold::TokensRemaining(tokens) => {
|
|
max_token_count.saturating_sub(tokens).saturating_add(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Snapshot of the data needed to report an `"Agent Compaction Completed"`
|
|
/// telemetry event, captured when a compaction starts.
|
|
struct CompactionTelemetry {
|
|
/// `"auto"` for threshold-triggered compaction, `"manual"` for `/compact`.
|
|
trigger: &'static str,
|
|
thread_id: String,
|
|
parent_thread_id: Option<String>,
|
|
prompt_id: String,
|
|
model: String,
|
|
model_provider: 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,
|
|
model_provider = self.model_provider,
|
|
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(
|
|
messages: &[Arc<Message>],
|
|
temperature: Option<f32>,
|
|
) -> LanguageModelRequest {
|
|
let mut request = LanguageModelRequest {
|
|
intent: Some(CompletionIntent::ThreadSummarization),
|
|
temperature,
|
|
..Default::default()
|
|
};
|
|
extend_request_history_until(messages, &mut request.messages, messages.len());
|
|
request.messages.push(LanguageModelRequestMessage {
|
|
role: Role::User,
|
|
content: vec![SUMMARIZE_THREAD_PROMPT.into()],
|
|
cache: false,
|
|
reasoning_details: None,
|
|
});
|
|
request
|
|
}
|
|
|
|
pub async fn stream_thread_title(
|
|
model: Arc<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(format: LanguageModelToolSchemaFormat) -> Schema {
|
|
language_model::tool_schema::root_schema_for::<Self::Input>(format)
|
|
}
|
|
|
|
/// Returns whether the tool supports streaming of tool use parameters.
|
|
fn supports_input_streaming() -> bool {
|
|
false
|
|
}
|
|
|
|
/// Some tools rely on a provider for the underlying billing or other reasons.
|
|
/// Allow the tool to check if they are compatible, or should be filtered out.
|
|
fn supports_provider(_provider: &LanguageModelProviderId) -> bool {
|
|
true
|
|
}
|
|
|
|
/// 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, format: LanguageModelToolSchemaFormat) -> Result<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, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
|
|
let mut json = serde_json::to_value(T::input_schema(format))?;
|
|
language_model::tool_schema::adapt_schema_to_format(&mut json, format)?;
|
|
Ok(json)
|
|
}
|
|
|
|
fn supports_provider(&self, provider: &LanguageModelProviderId) -> bool {
|
|
T::supports_provider(provider)
|
|
}
|
|
|
|
fn 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(mpsc::UnboundedSender<Result<ThreadEvent>>);
|
|
|
|
impl ThreadEventStream {
|
|
fn send_user_message(&self, message: &UserMessage) {
|
|
self.0
|
|
.unbounded_send(Ok(ThreadEvent::UserMessage(message.clone())))
|
|
.ok();
|
|
}
|
|
|
|
fn send_text(&self, text: &str) {
|
|
self.0
|
|
.unbounded_send(Ok(ThreadEvent::AgentText(text.to_string())))
|
|
.ok();
|
|
}
|
|
|
|
fn send_thinking(&self, text: &str) {
|
|
self.0
|
|
.unbounded_send(Ok(ThreadEvent::AgentThinking(text.to_string())))
|
|
.ok();
|
|
}
|
|
|
|
fn send_tool_call(
|
|
&self,
|
|
id: &acp::ToolCallId,
|
|
tool_name: &str,
|
|
title: SharedString,
|
|
kind: acp::ToolKind,
|
|
input: serde_json::Value,
|
|
) {
|
|
self.0
|
|
.unbounded_send(Ok(ThreadEvent::ToolCall(Self::initial_tool_call(
|
|
id,
|
|
tool_name,
|
|
title.to_string(),
|
|
kind,
|
|
input,
|
|
))))
|
|
.ok();
|
|
}
|
|
|
|
fn initial_tool_call(
|
|
id: &acp::ToolCallId,
|
|
tool_name: &str,
|
|
title: String,
|
|
kind: acp::ToolKind,
|
|
input: serde_json::Value,
|
|
) -> acp::ToolCall {
|
|
acp::ToolCall::new(id.clone(), title)
|
|
.kind(kind)
|
|
.raw_input(input)
|
|
.meta(acp_thread::meta_with_tool_name(tool_name))
|
|
}
|
|
|
|
fn update_tool_call_fields(
|
|
&self,
|
|
tool_call_id: &acp::ToolCallId,
|
|
fields: acp::ToolCallUpdateFields,
|
|
meta: Option<acp::Meta>,
|
|
) {
|
|
self.0
|
|
.unbounded_send(Ok(ThreadEvent::ToolCallUpdate(
|
|
acp::ToolCallUpdate::new(tool_call_id.clone(), fields)
|
|
.meta(meta)
|
|
.into(),
|
|
)))
|
|
.ok();
|
|
}
|
|
|
|
fn resolve_tool_call_authorization(
|
|
&self,
|
|
tool_call_id: &acp::ToolCallId,
|
|
outcome: acp_thread::SelectedPermissionOutcome,
|
|
) {
|
|
self.0
|
|
.unbounded_send(Ok(ThreadEvent::ToolCallAuthorizationResolved {
|
|
tool_call_id: tool_call_id.clone(),
|
|
outcome,
|
|
}))
|
|
.ok();
|
|
}
|
|
|
|
fn send_retry(&self, status: acp_thread::RetryStatus) {
|
|
self.0.unbounded_send(Ok(ThreadEvent::Retry(status))).ok();
|
|
}
|
|
|
|
fn send_context_compaction(
|
|
&self,
|
|
id: acp_thread::ContextCompactionId,
|
|
status: acp_thread::ContextCompactionStatus,
|
|
) {
|
|
self.0
|
|
.unbounded_send(Ok(ThreadEvent::ContextCompaction(
|
|
acp_thread::ContextCompaction {
|
|
id,
|
|
status,
|
|
summary: None,
|
|
},
|
|
)))
|
|
.ok();
|
|
}
|
|
|
|
fn send_context_compaction_update(
|
|
&self,
|
|
id: acp_thread::ContextCompactionId,
|
|
summary_delta: &str,
|
|
) {
|
|
self.0
|
|
.unbounded_send(Ok(ThreadEvent::ContextCompactionUpdate(
|
|
acp_thread::ContextCompactionUpdate {
|
|
id,
|
|
summary_delta: summary_delta.to_string(),
|
|
status: None,
|
|
},
|
|
)))
|
|
.ok();
|
|
}
|
|
|
|
fn update_context_compaction_status(
|
|
&self,
|
|
id: acp_thread::ContextCompactionId,
|
|
status: acp_thread::ContextCompactionStatus,
|
|
) {
|
|
self.0
|
|
.unbounded_send(Ok(ThreadEvent::ContextCompactionUpdate(
|
|
acp_thread::ContextCompactionUpdate {
|
|
id,
|
|
summary_delta: String::new(),
|
|
status: Some(status),
|
|
},
|
|
)))
|
|
.ok();
|
|
}
|
|
|
|
fn send_stop(&self, reason: acp::StopReason) {
|
|
self.0.unbounded_send(Ok(ThreadEvent::Stop(reason))).ok();
|
|
}
|
|
|
|
fn send_canceled(&self) {
|
|
self.0
|
|
.unbounded_send(Ok(ThreadEvent::Stop(acp::StopReason::Cancelled)))
|
|
.ok();
|
|
}
|
|
|
|
fn send_error(&self, error: impl Into<anyhow::Error>) {
|
|
self.0.unbounded_send(Err(error.into())).ok();
|
|
}
|
|
}
|
|
|
|
/// The user's choice when the OS sandbox could not be created for a command
|
|
/// (see [`ToolCallEventStream::authorize_sandbox_fallback`]). Only the
|
|
/// Bubblewrap sandboxes (Linux directly, Windows via WSL) can fail to create a
|
|
/// sandbox, so this is gated to those platforms.
|
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub(crate) enum SandboxFallbackDecision {
|
|
/// Try creating the sandbox again (e.g. after the user installed `bwrap`).
|
|
Retry,
|
|
/// Run the command without a sandbox.
|
|
RunUnsandboxed,
|
|
/// Don't run the command at all.
|
|
Deny,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct ToolCallEventStream {
|
|
tool_use_id: LanguageModelToolUseId,
|
|
/// The ACP-facing id for this tool call (see [`scoped_tool_call_id`]).
|
|
/// Distinct from `tool_use_id`, which is the raw, provider-issued id.
|
|
tool_call_id: acp::ToolCallId,
|
|
stream: ThreadEventStream,
|
|
fs: Option<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);
|
|
|
|
let stream = ToolCallEventStream::new(
|
|
"test_id".into(),
|
|
acp::ToolCallId::new("test_id"),
|
|
ThreadEventStream(events_tx),
|
|
None,
|
|
cancellation_rx,
|
|
sandbox_grants,
|
|
None,
|
|
);
|
|
|
|
(stream, ToolCallEventStreamReceiver(events_rx))
|
|
}
|
|
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub fn test_with_cancellation() -> (Self, ToolCallEventStreamReceiver, watch::Sender<bool>) {
|
|
let (events_tx, events_rx) = mpsc::unbounded::<Result<ThreadEvent>>();
|
|
let (cancellation_tx, cancellation_rx) = watch::channel(false);
|
|
|
|
let stream = ToolCallEventStream::new(
|
|
"test_id".into(),
|
|
acp::ToolCallId::new("test_id"),
|
|
ThreadEventStream(events_tx),
|
|
None,
|
|
cancellation_rx,
|
|
Rc::new(RefCell::new(ThreadSandboxGrants::default())),
|
|
None,
|
|
);
|
|
|
|
(
|
|
stream,
|
|
ToolCallEventStreamReceiver(events_rx),
|
|
cancellation_tx,
|
|
)
|
|
}
|
|
|
|
/// Signal cancellation for this event stream. Only available in tests.
|
|
#[cfg(any(test, feature = "test-support"))]
|
|
pub fn signal_cancellation_with_sender(cancellation_tx: &mut watch::Sender<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
|
|
}
|
|
|
|
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
|
|
.0
|
|
.unbounded_send(Ok(ThreadEvent::ToolCallUpdate(
|
|
acp_thread::ToolCallUpdateDiff {
|
|
id: self.tool_call_id.clone(),
|
|
diff,
|
|
}
|
|
.into(),
|
|
)))
|
|
.ok();
|
|
}
|
|
|
|
pub fn subagent_spawned(&self, id: acp::SessionId) {
|
|
self.stream
|
|
.0
|
|
.unbounded_send(Ok(ThreadEvent::SubagentSpawned(id)))
|
|
.ok();
|
|
}
|
|
|
|
/// Authorize a third-party tool (e.g., MCP tool from a context server).
|
|
///
|
|
/// Unlike built-in tools, third-party tools don't support pattern-based permissions.
|
|
/// They only support `default` (allow/deny/confirm) per tool.
|
|
///
|
|
/// Uses the dropdown authorization flow with two granularities:
|
|
/// - "Always for <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(),
|
|
reason,
|
|
};
|
|
let allow_thread_label = if self.is_subagent(cx) {
|
|
"Allow for this subagent"
|
|
} else {
|
|
"Allow for this thread"
|
|
};
|
|
let options = acp_thread::PermissionOptions::Flat(vec![
|
|
acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowOnce.as_id()),
|
|
"Allow once",
|
|
acp::PermissionOptionKind::AllowOnce,
|
|
),
|
|
acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowThread.as_id()),
|
|
allow_thread_label,
|
|
acp::PermissionOptionKind::AllowAlways,
|
|
),
|
|
acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowAlways.as_id()),
|
|
"Allow always",
|
|
acp::PermissionOptionKind::AllowAlways,
|
|
),
|
|
acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new(acp_thread::SandboxPermission::Deny.as_id()),
|
|
"Deny",
|
|
acp::PermissionOptionKind::RejectOnce,
|
|
),
|
|
]);
|
|
|
|
let fs = self.fs.clone();
|
|
let stream = self.stream.clone();
|
|
let tool_call_id = self.tool_call_id.clone();
|
|
let sandbox_grants = self.sandbox_grants.clone();
|
|
let thread = self.thread.clone();
|
|
let auto_allow_outcome = match auto_resolve_permission_outcome(&options, true) {
|
|
Ok(outcome) => outcome,
|
|
Err(error) => return Task::ready(Err(error)),
|
|
};
|
|
cx.spawn(async move |cx| {
|
|
let (response_tx, mut response_rx) = oneshot::channel();
|
|
if let Err(error) = stream
|
|
.0
|
|
.unbounded_send(Ok(ThreadEvent::ToolCallAuthorization(
|
|
ToolCallAuthorization {
|
|
tool_call: acp::ToolCallUpdate::new(
|
|
tool_call_id.clone(),
|
|
// Leave the title untouched so the card keeps
|
|
// showing the command (matching the fallback flow).
|
|
acp::ToolCallUpdateFields::new(),
|
|
)
|
|
.meta(acp_thread::meta_with_sandbox_authorization(
|
|
sandbox_authorization_details,
|
|
)),
|
|
options,
|
|
response: response_tx,
|
|
context: None,
|
|
kind: acp_thread::AuthorizationKind::PermissionGrant,
|
|
},
|
|
)))
|
|
{
|
|
log::error!("Failed to send sandbox authorization: {error}");
|
|
return Err(anyhow!("Failed to send sandbox authorization: {error}"));
|
|
}
|
|
|
|
let (mut settings_tx, mut settings_rx) = watch::channel(());
|
|
let _settings_subscription = cx.update(|cx| {
|
|
cx.observe_global::<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"))?;
|
|
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(());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
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 path in request.write_paths {
|
|
agent.add_sandbox_write_path(path);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/// 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
|
|
.0
|
|
.unbounded_send(Ok(ThreadEvent::ToolCallAuthorization(
|
|
ToolCallAuthorization {
|
|
// Deliberately leave the tool-call title untouched so
|
|
// the card keeps showing the *command* (not the
|
|
// failure reason): it's critical the user can see what
|
|
// they're approving to run unsandboxed. The reason is
|
|
// surfaced separately by the fallback details / warning.
|
|
tool_call: acp::ToolCallUpdate::new(
|
|
tool_call_id.clone(),
|
|
acp::ToolCallUpdateFields::new(),
|
|
)
|
|
.meta(
|
|
acp_thread::meta_with_sandbox_fallback_authorization(details),
|
|
),
|
|
options,
|
|
response: response_tx,
|
|
context: None,
|
|
kind: acp_thread::AuthorizationKind::ActionChoice,
|
|
},
|
|
)))
|
|
{
|
|
log::error!("Failed to send sandbox fallback authorization: {error}");
|
|
return Err(anyhow!(
|
|
"Failed to send sandbox fallback authorization: {error}"
|
|
));
|
|
}
|
|
|
|
let outcome = response_rx
|
|
.await
|
|
.map_err(|_| anyhow!("authorization channel closed"))?;
|
|
|
|
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
|
|
.0
|
|
.unbounded_send(Ok(ThreadEvent::ToolCallAuthorization(
|
|
ToolCallAuthorization {
|
|
tool_call: acp::ToolCallUpdate::new(tool_call_id.clone(), fields),
|
|
options,
|
|
response: response_tx,
|
|
context: None,
|
|
kind: acp_thread::AuthorizationKind::ActionChoice,
|
|
},
|
|
)))
|
|
{
|
|
log::error!("Failed to send tool call decision prompt: {error}");
|
|
return Err(anyhow!("Failed to send tool call decision prompt: {error}"));
|
|
}
|
|
|
|
let outcome = response_rx
|
|
.await
|
|
.map_err(|_| anyhow!("authorization channel closed"))?;
|
|
Ok(outcome.option_id)
|
|
})
|
|
}
|
|
|
|
/// Prompts the user for authorization.
|
|
///
|
|
/// When `check_settings` is `Some`, this gate is settings-driven: the
|
|
/// settings are evaluated up-front (an Allow or Deny result resolves the
|
|
/// task immediately without prompting), and while a prompt is pending a
|
|
/// `SettingsStore` subscription watches for changes. A subsequent Allow
|
|
/// or Deny dismisses the prompt UI and resolves the task without user
|
|
/// interaction.
|
|
///
|
|
/// When `check_settings` is `None`, the user is always prompted and
|
|
/// settings changes are ignored. This suits prompts that aren't
|
|
/// settings-driven (e.g. symlink-escape confirmations).
|
|
fn run_authorization_loop(
|
|
&self,
|
|
title: String,
|
|
options: acp_thread::PermissionOptions,
|
|
context: Option<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
|
|
.0
|
|
.unbounded_send(Ok(ThreadEvent::ToolCallAuthorization(
|
|
ToolCallAuthorization {
|
|
tool_call: acp::ToolCallUpdate::new(
|
|
tool_call_id.clone(),
|
|
acp::ToolCallUpdateFields::new().title(title),
|
|
),
|
|
options,
|
|
response: response_tx,
|
|
context,
|
|
kind: acp_thread::AuthorizationKind::PermissionGrant,
|
|
},
|
|
)))
|
|
{
|
|
log::error!("Failed to send tool call authorization: {error}");
|
|
return Err(anyhow!("Failed to send tool call authorization: {error}"));
|
|
}
|
|
|
|
let Some(check_settings) = check_settings else {
|
|
let outcome = response_rx
|
|
.await
|
|
.map_err(|_| anyhow!("authorization channel closed"))?;
|
|
|
|
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"))?;
|
|
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_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 std::sync::Arc;
|
|
|
|
async fn setup_thread_for_test(cx: &mut TestAppContext) -> (Entity<Thread>, ThreadEventStream) {
|
|
cx.update(|cx| {
|
|
let settings_store = settings::SettingsStore::test(cx);
|
|
cx.set_global(settings_store);
|
|
});
|
|
|
|
let fs = fs::FakeFs::new(cx.background_executor.clone());
|
|
let templates = Templates::new();
|
|
let project = Project::test(fs.clone(), [], cx).await;
|
|
|
|
cx.update(|cx| {
|
|
let project_context = cx.new(|_cx| prompt_store::ProjectContext::default());
|
|
let context_server_store = project.read(cx).context_server_store();
|
|
let context_server_registry =
|
|
cx.new(|cx| ContextServerRegistry::new(context_server_store, cx));
|
|
|
|
let thread = cx.new(|cx| {
|
|
Thread::new(
|
|
project,
|
|
project_context,
|
|
context_server_registry,
|
|
templates,
|
|
None,
|
|
cx,
|
|
)
|
|
});
|
|
|
|
let (event_tx, _event_rx) = mpsc::unbounded();
|
|
let event_stream = ThreadEventStream(event_tx);
|
|
|
|
(thread, event_stream)
|
|
})
|
|
}
|
|
|
|
fn 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);
|
|
}
|
|
|
|
#[test]
|
|
fn test_summary_compaction_renders_for_request_and_markdown() {
|
|
let message = Message::Compaction(CompactionInfo::Summary("Older context".into()));
|
|
|
|
assert_eq!(message.role(), Role::User);
|
|
assert_eq!(message.to_markdown(), "--- Context Compacted ---\n");
|
|
|
|
let request_messages = message.to_request();
|
|
assert_eq!(request_messages.len(), 1);
|
|
assert_eq!(request_messages[0].role, Role::User);
|
|
assert!(!request_messages[0].cache);
|
|
assert_eq!(request_messages[0].reasoning_details, None);
|
|
assert_eq!(request_messages[0].content.len(), 1);
|
|
let language_model::MessageContent::Text(text) = &request_messages[0].content[0] else {
|
|
panic!("expected text summary context");
|
|
};
|
|
assert_eq!(
|
|
text.as_str(),
|
|
"The previous conversation was compacted. Use this summary as context:\n\nOlder context"
|
|
);
|
|
}
|
|
|
|
fn user_text_message(id: ClientUserMessageId, text: &str) -> Arc<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()
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_thread_summary_request_uses_compacted_history(cx: &mut TestAppContext) {
|
|
let (thread, _event_stream) = setup_thread_for_test(cx).await;
|
|
let summary_model = Arc::new(FakeLanguageModel::default());
|
|
|
|
let summary_task = cx.update(|cx| {
|
|
thread.update(cx, |thread, cx| {
|
|
thread.set_summarization_model(Some(summary_model.clone()), cx);
|
|
thread
|
|
.messages
|
|
.push(user_text_message(ClientUserMessageId::new(), "old user"));
|
|
thread.messages.push(agent_text_message("old assistant"));
|
|
thread.messages.push(summary_compaction("first summary"));
|
|
thread.messages.push(user_text_message(
|
|
ClientUserMessageId::new(),
|
|
"between user",
|
|
));
|
|
thread
|
|
.messages
|
|
.push(agent_text_message("between assistant"));
|
|
thread.messages.push(summary_compaction("latest summary"));
|
|
thread
|
|
.messages
|
|
.push(user_text_message(ClientUserMessageId::new(), "after user"));
|
|
thread.messages.push(agent_text_message("after assistant"));
|
|
|
|
thread.summary(cx)
|
|
})
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
let summary_request = summary_model.pending_completions().pop().unwrap();
|
|
assert_eq!(
|
|
summary_request.intent,
|
|
Some(CompletionIntent::ThreadContextSummarization)
|
|
);
|
|
assert_eq!(
|
|
request_texts(&summary_request.messages),
|
|
vec![
|
|
"old user".to_string(),
|
|
"between user".to_string(),
|
|
summary_request_text("latest summary"),
|
|
"after user".to_string(),
|
|
"after assistant".to_string(),
|
|
SUMMARIZE_THREAD_DETAILED_PROMPT.to_string(),
|
|
]
|
|
);
|
|
|
|
summary_model.send_completion_stream_text_chunk(&summary_request, "thread summary");
|
|
summary_model.end_completion_stream(&summary_request);
|
|
assert_eq!(summary_task.await.as_deref(), Some("thread summary"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_thread_title_request_uses_compacted_history() {
|
|
let messages = vec![
|
|
user_text_message(ClientUserMessageId::new(), "old user"),
|
|
agent_text_message("old assistant"),
|
|
summary_compaction("first summary"),
|
|
user_text_message(ClientUserMessageId::new(), "between user"),
|
|
agent_text_message("between assistant"),
|
|
summary_compaction("latest summary"),
|
|
user_text_message(ClientUserMessageId::new(), "after user"),
|
|
agent_text_message("after assistant"),
|
|
];
|
|
|
|
let request = build_thread_title_request(&messages, Some(0.2));
|
|
|
|
assert_eq!(request.intent, Some(CompletionIntent::ThreadSummarization));
|
|
assert_eq!(request.temperature, Some(0.2));
|
|
assert_eq!(
|
|
request_texts(&request.messages),
|
|
vec![
|
|
"old user".to_string(),
|
|
"between user".to_string(),
|
|
summary_request_text("latest summary"),
|
|
"after user".to_string(),
|
|
"after assistant".to_string(),
|
|
SUMMARIZE_THREAD_PROMPT.to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_compaction_threshold_uses_percentage_setting(cx: &mut TestAppContext) {
|
|
let (thread, _event_stream) = setup_thread_for_test(cx).await;
|
|
let model = Arc::new(FakeLanguageModel::default());
|
|
let user_message_id = ClientUserMessageId::new();
|
|
|
|
cx.update(|cx| {
|
|
thread.update(cx, |thread, cx| {
|
|
thread.set_model(model, cx);
|
|
thread
|
|
.messages
|
|
.push(user_text_message(user_message_id.clone(), "below limit"));
|
|
thread.request_token_usage.insert(
|
|
user_message_id.clone(),
|
|
language_model::TokenUsage {
|
|
input_tokens: 899_999,
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
assert_eq!(thread.compaction_message_target_ix(cx), None);
|
|
|
|
thread.request_token_usage.insert(
|
|
user_message_id.clone(),
|
|
language_model::TokenUsage {
|
|
input_tokens: 900_000,
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
assert_eq!(thread.compaction_message_target_ix(cx), Some(1));
|
|
});
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_compaction_threshold_accounts_for_max_output_tokens(cx: &mut TestAppContext) {
|
|
let (thread, _event_stream) = setup_thread_for_test(cx).await;
|
|
let model = Arc::new(FakeLanguageModel::default());
|
|
model.set_max_output_tokens(Some(32_000));
|
|
let user_message_id = ClientUserMessageId::new();
|
|
|
|
cx.update(|cx| {
|
|
thread.update(cx, |thread, cx| {
|
|
thread.set_model(model, cx);
|
|
thread.messages.push(user_text_message(
|
|
user_message_id.clone(),
|
|
"near input limit",
|
|
));
|
|
thread.request_token_usage.insert(
|
|
user_message_id.clone(),
|
|
language_model::TokenUsage {
|
|
input_tokens: 871_199,
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
assert_eq!(thread.compaction_message_target_ix(cx), None);
|
|
|
|
thread.request_token_usage.insert(
|
|
user_message_id.clone(),
|
|
language_model::TokenUsage {
|
|
input_tokens: 871_200,
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
assert_eq!(thread.compaction_message_target_ix(cx), Some(1));
|
|
|
|
set_auto_compact_settings(
|
|
cx,
|
|
agent_settings::AutoCompactSettings {
|
|
enabled: true,
|
|
threshold: AutoCompactThreshold::TokensRemaining(20_000),
|
|
},
|
|
);
|
|
thread.request_token_usage.insert(
|
|
user_message_id.clone(),
|
|
language_model::TokenUsage {
|
|
input_tokens: 948_000,
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
assert_eq!(thread.compaction_message_target_ix(cx), None);
|
|
|
|
thread.request_token_usage.insert(
|
|
user_message_id.clone(),
|
|
language_model::TokenUsage {
|
|
input_tokens: 948_001,
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
assert_eq!(thread.compaction_message_target_ix(cx), Some(1));
|
|
});
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_compaction_threshold_respects_enabled_setting(cx: &mut TestAppContext) {
|
|
let (thread, _event_stream) = setup_thread_for_test(cx).await;
|
|
let model = Arc::new(FakeLanguageModel::default());
|
|
let user_message_id = ClientUserMessageId::new();
|
|
|
|
cx.update(|cx| {
|
|
set_auto_compact_settings(
|
|
cx,
|
|
agent_settings::AutoCompactSettings {
|
|
enabled: false,
|
|
threshold: AutoCompactThreshold::Percentage(0.9),
|
|
},
|
|
);
|
|
thread.update(cx, |thread, cx| {
|
|
thread.set_model(model, cx);
|
|
thread
|
|
.messages
|
|
.push(user_text_message(user_message_id.clone(), "near limit"));
|
|
thread.request_token_usage.insert(
|
|
user_message_id.clone(),
|
|
language_model::TokenUsage {
|
|
input_tokens: 960_000,
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
assert_eq!(thread.compaction_message_target_ix(cx), None);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_compaction_threshold_respects_token_settings(cx: &mut TestAppContext) {
|
|
let (thread, _event_stream) = setup_thread_for_test(cx).await;
|
|
let model = Arc::new(FakeLanguageModel::default());
|
|
let user_message_id = ClientUserMessageId::new();
|
|
|
|
cx.update(|cx| {
|
|
set_auto_compact_settings(
|
|
cx,
|
|
agent_settings::AutoCompactSettings {
|
|
enabled: true,
|
|
threshold: AutoCompactThreshold::TokensUsed(100_000),
|
|
},
|
|
);
|
|
thread.update(cx, |thread, cx| {
|
|
thread.set_model(model, cx);
|
|
thread.messages.push(user_text_message(
|
|
user_message_id.clone(),
|
|
"fixed token limit",
|
|
));
|
|
thread.request_token_usage.insert(
|
|
user_message_id.clone(),
|
|
language_model::TokenUsage {
|
|
input_tokens: 99_999,
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
assert_eq!(thread.compaction_message_target_ix(cx), None);
|
|
|
|
thread.request_token_usage.insert(
|
|
user_message_id.clone(),
|
|
language_model::TokenUsage {
|
|
input_tokens: 100_000,
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
assert_eq!(thread.compaction_message_target_ix(cx), Some(1));
|
|
|
|
set_auto_compact_settings(
|
|
cx,
|
|
agent_settings::AutoCompactSettings {
|
|
enabled: true,
|
|
threshold: AutoCompactThreshold::TokensRemaining(20_000),
|
|
},
|
|
);
|
|
thread.request_token_usage.insert(
|
|
user_message_id.clone(),
|
|
language_model::TokenUsage {
|
|
input_tokens: 980_000,
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
assert_eq!(thread.compaction_message_target_ix(cx), None);
|
|
|
|
thread.request_token_usage.insert(
|
|
user_message_id.clone(),
|
|
language_model::TokenUsage {
|
|
input_tokens: 980_001,
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
assert_eq!(thread.compaction_message_target_ix(cx), Some(1));
|
|
});
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_compaction_unavailable_for_small_context_window(cx: &mut TestAppContext) {
|
|
let (thread, _event_stream) = setup_thread_for_test(cx).await;
|
|
let model = Arc::new(FakeLanguageModel::default());
|
|
// A context window below the minimum disables auto-compaction.
|
|
model.set_max_token_count(MIN_COMPACTION_CONTEXT_WINDOW - 1);
|
|
let user_message_id = ClientUserMessageId::new();
|
|
|
|
cx.update(|cx| {
|
|
thread.update(cx, |thread, cx| {
|
|
thread.set_model(model, cx);
|
|
thread
|
|
.messages
|
|
.push(user_text_message(user_message_id.clone(), "near limit"));
|
|
thread.request_token_usage.insert(
|
|
user_message_id.clone(),
|
|
language_model::TokenUsage {
|
|
input_tokens: u64::MAX,
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
assert_eq!(thread.compaction_message_target_ix(cx), None);
|
|
});
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_compaction_inserts_before_new_user_and_requests_compacted_window(
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
let (thread, _event_stream) = setup_thread_for_test(cx).await;
|
|
let model = Arc::new(FakeLanguageModel::default());
|
|
let old_user_message_id = ClientUserMessageId::new();
|
|
let new_user_message_id = ClientUserMessageId::new();
|
|
|
|
cx.update(|cx| {
|
|
thread.update(cx, |thread, cx| {
|
|
thread.set_model(model.clone(), cx);
|
|
thread
|
|
.messages
|
|
.push(user_text_message(old_user_message_id.clone(), "old user"));
|
|
thread.messages.push(agent_text_message("old assistant"));
|
|
thread.request_token_usage.insert(
|
|
old_user_message_id.clone(),
|
|
language_model::TokenUsage {
|
|
input_tokens: 960_000,
|
|
..Default::default()
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
let _events = cx
|
|
.update(|cx| {
|
|
thread.update(cx, |thread, cx| {
|
|
thread.send(new_user_message_id, vec!["new prompt"], cx)
|
|
})
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
let compaction_request = model.pending_completions().pop().unwrap();
|
|
assert_eq!(
|
|
compaction_request.intent,
|
|
Some(CompletionIntent::ThreadContextSummarization)
|
|
);
|
|
let compaction_texts = request_texts_after_system(&compaction_request.messages);
|
|
assert_eq!(compaction_texts.len(), 3);
|
|
assert_eq!(compaction_texts[0], "old user");
|
|
assert_eq!(compaction_texts[1], "old assistant");
|
|
assert_eq!(compaction_texts[2], COMPACTION_PROMPT);
|
|
|
|
model.send_completion_stream_text_chunk(&compaction_request, "compacted old context");
|
|
model.end_completion_stream(&compaction_request);
|
|
cx.run_until_parked();
|
|
|
|
let final_request = model.pending_completions().pop().unwrap();
|
|
assert_eq!(final_request.intent, Some(CompletionIntent::UserPrompt));
|
|
assert_eq!(
|
|
request_texts_after_system(&final_request.messages),
|
|
vec![
|
|
"old user".to_string(),
|
|
summary_request_text("compacted old context"),
|
|
"new prompt".to_string(),
|
|
]
|
|
);
|
|
|
|
model.send_completion_stream_text_chunk(&final_request, "answer");
|
|
model.end_completion_stream(&final_request);
|
|
cx.run_until_parked();
|
|
|
|
cx.update(|cx| {
|
|
thread.read_with(cx, |thread, _cx| {
|
|
assert!(matches!(&*thread.messages[0], Message::User(_)));
|
|
assert!(matches!(&*thread.messages[1], Message::Agent(_)));
|
|
assert!(matches!(
|
|
&*thread.messages[2],
|
|
Message::Compaction(CompactionInfo::Summary(summary)) if summary.as_ref() == "compacted old context"
|
|
));
|
|
assert!(matches!(&*thread.messages[3], Message::User(_)));
|
|
});
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_manual_compact_forces_summary(cx: &mut TestAppContext) {
|
|
let (thread, _event_stream) = setup_thread_for_test(cx).await;
|
|
let model = Arc::new(FakeLanguageModel::default());
|
|
// A context window below the minimum and no recorded token usage would
|
|
// both disable *automatic* compaction. Manual compaction forces it anyway.
|
|
model.set_max_token_count(MIN_COMPACTION_CONTEXT_WINDOW - 1);
|
|
let user_message_id = ClientUserMessageId::new();
|
|
let compact_message_id = ClientUserMessageId::new();
|
|
|
|
cx.update(|cx| {
|
|
thread.update(cx, |thread, cx| {
|
|
thread.set_model(model.clone(), cx);
|
|
thread
|
|
.messages
|
|
.push(user_text_message(user_message_id.clone(), "old user"));
|
|
thread.messages.push(agent_text_message("old assistant"));
|
|
// Auto-compaction would be a no-op here.
|
|
assert_eq!(thread.compaction_message_target_ix(cx), None);
|
|
});
|
|
});
|
|
|
|
let _events = cx
|
|
.update(|cx| {
|
|
thread.update(cx, |thread, cx| {
|
|
thread.compact(compact_message_id.clone(), cx)
|
|
})
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
let compaction_request = model.pending_completions().pop().unwrap();
|
|
assert_eq!(
|
|
compaction_request.intent,
|
|
Some(CompletionIntent::ThreadContextSummarization)
|
|
);
|
|
let compaction_texts = request_texts_after_system(&compaction_request.messages);
|
|
assert_eq!(compaction_texts.len(), 3);
|
|
assert_eq!(compaction_texts[0], "old user");
|
|
assert_eq!(compaction_texts[1], "old assistant");
|
|
assert_eq!(compaction_texts[2], COMPACTION_PROMPT);
|
|
|
|
model.send_completion_stream_text_chunk(&compaction_request, "summary of old context");
|
|
model.end_completion_stream(&compaction_request);
|
|
cx.run_until_parked();
|
|
|
|
// The compaction summary is appended after a zero-content user message
|
|
// marker, and no follow-up model turn is requested — `/compact` only
|
|
// compacts.
|
|
assert!(model.pending_completions().is_empty());
|
|
cx.update(|cx| {
|
|
thread.read_with(cx, |thread, _cx| {
|
|
assert!(matches!(&*thread.messages[0], Message::User(_)));
|
|
assert!(matches!(&*thread.messages[1], Message::Agent(_)));
|
|
assert!(matches!(
|
|
&*thread.messages[2],
|
|
Message::User(UserMessage { id, content }) if id == &compact_message_id && content.is_empty()
|
|
));
|
|
assert!(matches!(
|
|
&*thread.messages[3],
|
|
Message::Compaction(CompactionInfo::Summary(summary)) if summary.as_ref() == "summary of old context"
|
|
));
|
|
// Re-running `/compact` with nothing new to summarize is a
|
|
// no-op: the thread already ends in a compaction.
|
|
assert_eq!(thread.forced_compaction_target_ix(), None);
|
|
});
|
|
|
|
thread
|
|
.update(cx, |thread, cx| thread.truncate(compact_message_id.clone(), cx))
|
|
.unwrap();
|
|
|
|
thread.read_with(cx, |thread, _cx| {
|
|
assert_eq!(thread.messages.len(), 2);
|
|
assert!(matches!(&*thread.messages[0], Message::User(_)));
|
|
assert!(matches!(&*thread.messages[1], Message::Agent(_)));
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Cancelling an in-flight manual compaction must not leave the zero-content
|
|
/// rewind marker (or a partial summary) dangling at the end of the thread.
|
|
#[gpui::test]
|
|
async fn test_manual_compact_cancelled_leaves_no_marker(cx: &mut TestAppContext) {
|
|
let (thread, _event_stream) = setup_thread_for_test(cx).await;
|
|
let model = Arc::new(FakeLanguageModel::default());
|
|
|
|
cx.update(|cx| {
|
|
thread.update(cx, |thread, cx| {
|
|
thread.set_model(model.clone(), cx);
|
|
thread
|
|
.messages
|
|
.push(user_text_message(ClientUserMessageId::new(), "old user"));
|
|
thread.messages.push(agent_text_message("old assistant"));
|
|
});
|
|
});
|
|
|
|
let _events = cx
|
|
.update(|cx| {
|
|
thread.update(cx, |thread, cx| {
|
|
thread.compact(ClientUserMessageId::new(), cx)
|
|
})
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
// The compaction request is in flight but hasn't streamed a summary.
|
|
assert_eq!(model.pending_completions().len(), 1);
|
|
|
|
cx.update(|cx| thread.update(cx, |thread, cx| thread.cancel(cx)))
|
|
.await;
|
|
cx.run_until_parked();
|
|
|
|
thread.read_with(cx, |thread, _cx| {
|
|
assert_eq!(thread.messages.len(), 2);
|
|
assert!(matches!(&*thread.messages[0], Message::User(_)));
|
|
assert!(matches!(&*thread.messages[1], Message::Agent(_)));
|
|
});
|
|
}
|
|
|
|
/// A failed compaction (here, an empty summary) reports an error and leaves
|
|
/// the thread untouched — no marker, no compaction.
|
|
#[gpui::test]
|
|
async fn test_manual_compact_empty_summary_leaves_no_marker(cx: &mut TestAppContext) {
|
|
let (thread, _event_stream) = setup_thread_for_test(cx).await;
|
|
let model = Arc::new(FakeLanguageModel::default());
|
|
|
|
cx.update(|cx| {
|
|
thread.update(cx, |thread, cx| {
|
|
thread.set_model(model.clone(), cx);
|
|
thread
|
|
.messages
|
|
.push(user_text_message(ClientUserMessageId::new(), "old user"));
|
|
thread.messages.push(agent_text_message("old assistant"));
|
|
});
|
|
});
|
|
|
|
let mut events = cx
|
|
.update(|cx| {
|
|
thread.update(cx, |thread, cx| {
|
|
thread.compact(ClientUserMessageId::new(), cx)
|
|
})
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
let request = model.pending_completions().pop().unwrap();
|
|
// End the stream without emitting any summary text.
|
|
model.end_completion_stream(&request);
|
|
cx.run_until_parked();
|
|
|
|
// An error is surfaced, and the thread is left exactly as it was. The
|
|
// compaction task drops the event stream after failing, so the channel
|
|
// closes and this drain terminates.
|
|
let mut saw_error = false;
|
|
while let Some(event) = events.next().await {
|
|
if event.is_err() {
|
|
saw_error = true;
|
|
}
|
|
}
|
|
assert!(saw_error, "expected an error event for the empty summary");
|
|
thread.read_with(cx, |thread, _cx| {
|
|
assert_eq!(thread.messages.len(), 2);
|
|
assert!(matches!(&*thread.messages[0], Message::User(_)));
|
|
assert!(matches!(&*thread.messages[1], Message::Agent(_)));
|
|
});
|
|
}
|
|
|
|
/// `/compact` on an empty thread (nothing to summarize) is a no-op: it
|
|
/// issues no model request and adds no marker.
|
|
#[gpui::test]
|
|
async fn test_manual_compact_noop_on_empty_thread(cx: &mut TestAppContext) {
|
|
let (thread, _event_stream) = setup_thread_for_test(cx).await;
|
|
let model = Arc::new(FakeLanguageModel::default());
|
|
cx.update(|cx| thread.update(cx, |thread, cx| thread.set_model(model.clone(), cx)));
|
|
|
|
let _events = cx
|
|
.update(|cx| {
|
|
thread.update(cx, |thread, cx| {
|
|
thread.compact(ClientUserMessageId::new(), cx)
|
|
})
|
|
})
|
|
.unwrap();
|
|
cx.run_until_parked();
|
|
|
|
assert!(model.pending_completions().is_empty());
|
|
thread.read_with(cx, |thread, _cx| {
|
|
assert!(thread.messages.is_empty());
|
|
});
|
|
}
|
|
|
|
/// The zero-content marker replays as an empty user message, which the UI
|
|
/// drops (it renders content blocks, of which there are none), so reloading
|
|
/// a compacted thread doesn't surface an empty `/compact` bubble.
|
|
#[gpui::test]
|
|
async fn test_manual_compact_marker_replays_as_empty_user_message(cx: &mut TestAppContext) {
|
|
let (thread, _event_stream) = setup_thread_for_test(cx).await;
|
|
let marker_id = ClientUserMessageId::new();
|
|
|
|
let mut replay_events = cx.update(|cx| {
|
|
thread.update(cx, |thread, cx| {
|
|
thread
|
|
.messages
|
|
.push(user_text_message(ClientUserMessageId::new(), "before"));
|
|
thread.messages.push(agent_text_message("answer"));
|
|
thread.messages.push(Arc::new(Message::User(UserMessage {
|
|
id: marker_id.clone(),
|
|
content: Arc::from([]),
|
|
})));
|
|
thread.messages.push(summary_compaction("summary"));
|
|
thread.replay(cx)
|
|
})
|
|
});
|
|
|
|
// Skip the leading "before"/"answer" replay events.
|
|
let _ = replay_events.next().await;
|
|
let _ = replay_events.next().await;
|
|
|
|
let event = replay_events.next().await;
|
|
match event {
|
|
Some(Ok(ThreadEvent::UserMessage(message))) => {
|
|
assert_eq!(message.id, marker_id);
|
|
assert!(
|
|
message.content.is_empty(),
|
|
"marker should replay with no content so the UI renders nothing"
|
|
);
|
|
}
|
|
_ => panic!("expected the marker to replay as a user message, got {event:?}"),
|
|
}
|
|
|
|
let event = replay_events.next().await;
|
|
assert!(
|
|
matches!(&event, Some(Ok(ThreadEvent::ContextCompaction(_)))),
|
|
"expected the compaction to replay after the marker, got {event:?}"
|
|
);
|
|
}
|
|
|
|
#[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![
|
|
PathBuf::from("/tmp/build"),
|
|
PathBuf::from("/tmp/cache"),
|
|
PathBuf::from("/tmp/logs"),
|
|
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());
|
|
}
|
|
|
|
#[test]
|
|
fn test_auto_resolve_permission_outcome_uses_once_only_options() {
|
|
let options = acp_thread::PermissionOptions::Dropdown(vec![
|
|
acp_thread::PermissionOptionChoice {
|
|
allow: acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new("always_allow:test_tool"),
|
|
"Always allow",
|
|
acp::PermissionOptionKind::AllowAlways,
|
|
),
|
|
deny: acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new("always_deny:test_tool"),
|
|
"Always deny",
|
|
acp::PermissionOptionKind::RejectAlways,
|
|
),
|
|
sub_patterns: vec![],
|
|
},
|
|
acp_thread::PermissionOptionChoice {
|
|
allow: acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new("allow"),
|
|
"Allow once",
|
|
acp::PermissionOptionKind::AllowOnce,
|
|
),
|
|
deny: acp::PermissionOption::new(
|
|
acp::PermissionOptionId::new("deny"),
|
|
"Deny once",
|
|
acp::PermissionOptionKind::RejectOnce,
|
|
),
|
|
sub_patterns: vec![],
|
|
},
|
|
]);
|
|
|
|
let allow = auto_resolve_permission_outcome(&options, true)
|
|
.expect("allow auto-resolve should use once-only option");
|
|
assert_eq!(allow.option_id, acp::PermissionOptionId::new("allow"));
|
|
assert_eq!(allow.option_kind, acp::PermissionOptionKind::AllowOnce);
|
|
|
|
let deny = auto_resolve_permission_outcome(&options, false)
|
|
.expect("deny auto-resolve should use once-only option");
|
|
assert_eq!(deny.option_id, acp::PermissionOptionId::new("deny"));
|
|
assert_eq!(deny.option_kind, acp::PermissionOptionKind::RejectOnce);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_replay_tool_call_replays_image_content(cx: &mut TestAppContext) {
|
|
let (thread, _event_stream) = setup_thread_for_test(cx).await;
|
|
|
|
let registered_tool_use_id = LanguageModelToolUseId::from("registered_tool_id");
|
|
let missing_tool_use_id = LanguageModelToolUseId::from("missing_tool_id");
|
|
let image_data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
|
|
let image = LanguageModelImage {
|
|
source: image_data.into(),
|
|
};
|
|
|
|
let mut replay_events = cx.update(|cx| {
|
|
thread.update(cx, |thread, cx| {
|
|
thread.add_tool(ReplayImageTool);
|
|
|
|
let registered_tool_use = LanguageModelToolUse {
|
|
id: registered_tool_use_id.clone(),
|
|
name: ReplayImageTool::NAME.into(),
|
|
raw_input: "null".to_string(),
|
|
input: language_model::LanguageModelToolUseInput::Json(json!(null)),
|
|
is_input_complete: true,
|
|
thought_signature: None,
|
|
};
|
|
let missing_tool_use = LanguageModelToolUse {
|
|
id: missing_tool_use_id.clone(),
|
|
name: "missing_image_tool".into(),
|
|
raw_input: "{}".to_string(),
|
|
input: language_model::LanguageModelToolUseInput::Json(json!({})),
|
|
is_input_complete: true,
|
|
thought_signature: None,
|
|
};
|
|
|
|
let mut tool_results = IndexMap::default();
|
|
tool_results.insert(
|
|
registered_tool_use_id.clone(),
|
|
LanguageModelToolResult {
|
|
tool_use_id: registered_tool_use_id.clone(),
|
|
tool_name: ReplayImageTool::NAME.into(),
|
|
is_error: false,
|
|
content: vec![
|
|
LanguageModelToolResultContent::Text("before".into()),
|
|
LanguageModelToolResultContent::Image(image.clone()),
|
|
LanguageModelToolResultContent::Text("after".into()),
|
|
],
|
|
output: Some(json!("raw output")),
|
|
},
|
|
);
|
|
tool_results.insert(
|
|
missing_tool_use_id.clone(),
|
|
LanguageModelToolResult {
|
|
tool_use_id: missing_tool_use_id.clone(),
|
|
tool_name: "missing_image_tool".into(),
|
|
is_error: false,
|
|
content: vec![LanguageModelToolResultContent::Image(image.clone())],
|
|
output: Some(json!("raw output")),
|
|
},
|
|
);
|
|
|
|
thread.messages.push(Arc::new(Message::Agent(AgentMessage {
|
|
content: vec![
|
|
AgentMessageContent::ToolUse(registered_tool_use),
|
|
AgentMessageContent::ToolUse(missing_tool_use),
|
|
],
|
|
tool_results,
|
|
reasoning_details: None,
|
|
})));
|
|
|
|
thread.replay(cx)
|
|
})
|
|
});
|
|
|
|
let mut tool_use_ids_with_image_content = HashSet::default();
|
|
while let Some(event) = replay_events.next().await {
|
|
let event = event.unwrap();
|
|
if let ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(update)) =
|
|
event
|
|
&& let Some(content) = &update.fields.content
|
|
&& content.iter().any(|content| {
|
|
matches!(
|
|
content,
|
|
acp::ToolCallContent::Content(acp::Content {
|
|
content: acp::ContentBlock::Image(_),
|
|
..
|
|
})
|
|
)
|
|
})
|
|
{
|
|
tool_use_ids_with_image_content.insert(update.tool_call_id.to_string());
|
|
}
|
|
}
|
|
|
|
// Both tool uses live in the message pushed above, at index 0 (see
|
|
// `scoped_tool_call_id`).
|
|
assert!(
|
|
tool_use_ids_with_image_content
|
|
.contains(&scoped_tool_call_id(0, ®istered_tool_use_id).to_string())
|
|
);
|
|
assert!(
|
|
tool_use_ids_with_image_content
|
|
.contains(&scoped_tool_call_id(0, &missing_tool_use_id).to_string())
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_set_model_propagates_to_subagents(cx: &mut TestAppContext) {
|
|
let (parent, _event_stream) = setup_thread_for_test(cx).await;
|
|
let subagents = setup_parent_with_subagents(cx, &parent, 2);
|
|
|
|
let new_model: Arc<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));
|
|
})
|
|
}
|
|
}
|