Files
galaxy/app/src/ai/agent/mod.rs
T

3340 lines
116 KiB
Rust

pub(crate) mod conversation;
pub(crate) mod conversation_yaml;
pub(crate) mod todos;
pub(crate) mod api;
pub(crate) mod comment;
pub(crate) mod icons;
pub(crate) mod linearization;
pub(crate) mod redaction;
pub(crate) mod task;
mod task_store;
pub(super) mod telemetry;
pub(super) mod util;
use std::collections::{HashMap, HashSet};
use std::fmt::Display;
use std::ops::{AddAssign, Deref, DerefMut, Range};
use std::sync::Arc;
use std::time::Duration;
// Re-export types that were moved to the ai crate.
pub use ai::agent::action::*;
pub use ai::agent::action_result::*;
use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationConfigStatus};
pub use ai::agent::{AIAgentCitation, FileLocations};
use ai::skills::ParsedSkill;
use chrono::{DateTime, Local, TimeDelta};
use comment::ReviewComment;
use derivative::Derivative;
use galaxy_core::channel::ChannelState;
use galaxy_core::features::FeatureFlag;
use markdown_parser::{parse_markdown, FormattedTable, FormattedText, FormattedTextInline};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use session_sharing_protocol::common::ParticipantId;
use task::TaskId;
pub use telemetry::AIIdentifiers;
use uuid::Uuid;
use warp_editor::render::model::LineCount;
use warp_multi_agent_api::{diff_hunk as diff_hunk_api, AgentEvent, AgentType};
pub use self::api::{MaybeAIAgentOutputMessage, MessageToAIAgentOutputMessageError};
use super::llms::LLMId;
use crate::ai::block_context::BlockContext;
use crate::ai::blocklist::block::view_impl::output::are_all_text_sections_empty;
use crate::ai::skills::SkillDescriptor;
use crate::ai_assistant::execution_context::WarpAiExecutionContext;
use crate::code::editor_management::CodeSource;
use crate::code_review::comments::{
AttachedReviewComment as CodeReviewComment, ReviewCommentBatch,
};
use crate::search::slash_command_menu::static_commands::commands;
use crate::server::server_api::{AIApiError, DeserializationError};
use crate::terminal::model::block::BlockId;
use crate::terminal::shell::ShellType;
use crate::TelemetryEvent;
/// A server supplied ID for a specific AI generated output.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
pub struct ServerOutputId(String);
impl std::fmt::Display for ServerOutputId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Display only the inner UUID string without the wrapper
write!(f, "{}", self.0)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct InvokeSkillUserQuery {
pub query: String,
pub referenced_attachments: HashMap<String, AIAgentAttachment>,
}
impl ServerOutputId {
pub fn new(value: String) -> Self {
ServerOutputId(value)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CancellationReason {
/// The user explicitly cancelled without providing a follow-up.
ManuallyCancelled,
/// Warp automatically cancelled the local run so it could continue in Cloud Mode.
AutomaticCloudHandoff,
/// The user submitted a follow-up query during streaming which implicitly cancelled the current one.
FollowUpSubmitted {
is_for_same_conversation: bool,
},
/// The user executed a shell command in the middle of the response stream.
UserCommandExecuted,
/// The user reverted the conversation to a previous state, deleting exchanges.
Reverted,
// The user deleted the conversation while it was in progress.
Deleted,
/// The long-running command completed while the agent was still streaming a response started via inline agent view.
/// This should be treated as a successful completion, not a cancellation.
/// Note this is only used for inline agent view (user starting an agent to monitor an already running command),
/// not when CLI subagent monitors a requested command.
CommandFinishedDuringInlineAgentView,
/// The user manually took control of a long-running command away from the agent.
/// The agent conversation is still in progress — it will resume after the command
/// finishes or once the user hands control back. The stream is cancelled only to
/// stop the CLI subagent monitoring loop, not to end the conversation.
CLISubagentUserTakeover,
/// An agent-issued command caused the shell process to exit (e.g. it ran
/// `exit`, or ran a failing command after enabling `set -e`). The in-flight
/// stream/actions are cancelled to stop work, but the conversation is
/// finalized as a terminal `Error` (with a shell-exit message) by the
/// controller rather than reported as a user cancellation.
AgentExitedShell,
}
/// How a [`CancellationReason`] maps to the conversation's resulting status.
/// This is the single source of truth consumed by the stream- and
/// action-cancellation machinery; see [`CancellationReason::conversation_outcome`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CancellationOutcome {
/// Leave the conversation `InProgress`; it will continue on its own (a
/// follow-up request or a resumed long-running command) without further user
/// input.
KeepInProgress,
/// Finalize the conversation as a successful completion (`Success`).
Succeeded,
/// Finalize the conversation as a user cancellation (`Cancelled`).
Cancelled,
/// Terminal, but a dedicated path (not the cancellation machinery) writes the
/// status — the cancellation is only a stop signal and must not stamp a status.
/// Currently used for shell exit, which is finalized as `Error` by
/// `fail_conversation_due_to_shell_exit`. Unlike `KeepInProgress`, the
/// conversation is ending; only the status write is suppressed.
FinalizedExternally,
}
impl Display for CancellationReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CancellationReason::ManuallyCancelled => write!(f, "manual cancellation"),
CancellationReason::AutomaticCloudHandoff => write!(f, "automatic cloud handoff"),
CancellationReason::FollowUpSubmitted { .. } => write!(f, "follow-up submission"),
CancellationReason::UserCommandExecuted => write!(f, "user command execution"),
CancellationReason::Reverted => write!(f, "revert"),
CancellationReason::Deleted => write!(f, "deleted"),
CancellationReason::CommandFinishedDuringInlineAgentView => {
write!(f, "LRC command completed")
}
CancellationReason::CLISubagentUserTakeover => {
write!(f, "CLI subagent user takeover")
}
CancellationReason::AgentExitedShell => {
write!(f, "agent command exited the shell")
}
}
}
}
impl CancellationReason {
pub fn is_follow_up_for_same_conversation(&self) -> bool {
matches!(
self,
CancellationReason::FollowUpSubmitted {
is_for_same_conversation: true
}
)
}
}
impl CancellationReason {
pub fn is_manually_cancelled(&self) -> bool {
matches!(self, CancellationReason::ManuallyCancelled)
}
pub fn is_reverted(&self) -> bool {
matches!(self, CancellationReason::Reverted)
}
/// How a cancellation reason maps to the
/// conversation's resulting status. Every site that finalizes a cancelled
/// stream or action consults this instead of re-deriving the disposition,
/// so the reason -> status mapping lives in one exhaustive place.
/// Note that sometimes the action result is treated as authoritative for determining
/// conversation status even when there is a cancellation reason (taking priority over this)
pub fn conversation_outcome(&self) -> CancellationOutcome {
match self {
// The conversation continues without further user input (a follow-up
// request or a resumed long-running command drives it forward), so
// its status must stay InProgress.
CancellationReason::FollowUpSubmitted {
is_for_same_conversation: true,
}
| CancellationReason::CLISubagentUserTakeover => CancellationOutcome::KeepInProgress,
// A long-running command finishing (optimistically) or a revert are
// successful completions rather than cancellations.
CancellationReason::CommandFinishedDuringInlineAgentView
| CancellationReason::Reverted => CancellationOutcome::Succeeded,
// The shell died under the agent; a dedicated path finalizes this as a
// terminal `Error`, so the cancellation machinery must not stamp a status.
CancellationReason::AgentExitedShell => CancellationOutcome::FinalizedExternally,
CancellationReason::ManuallyCancelled
| CancellationReason::AutomaticCloudHandoff
| CancellationReason::UserCommandExecuted
| CancellationReason::Deleted
| CancellationReason::FollowUpSubmitted {
is_for_same_conversation: false,
} => CancellationOutcome::Cancelled,
}
}
}
#[derive(Clone, Debug)]
pub enum FinishedAIAgentOutput {
/// The user manually cancelled output streaming.
Cancelled {
// The output received up til the point of cancellation, if any.
output: Option<Shared<AIAgentOutput>>,
/// Why the stream was cancelled.
reason: CancellationReason,
},
/// Output streaming failed.
Error {
// The output received up til the error was encountered, if any.
output: Option<Shared<AIAgentOutput>>,
error: RenderableAIError,
},
/// Output streaming completed successfully.
Success { output: Shared<AIAgentOutput> },
}
impl Display for FinishedAIAgentOutput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FinishedAIAgentOutput::Cancelled { .. } => write!(f, "Cancelled"),
FinishedAIAgentOutput::Error { error, .. } => write!(f, "Error: {error}"),
FinishedAIAgentOutput::Success { output } => write!(f, "\n{output}"),
}
}
}
impl FinishedAIAgentOutput {
pub fn server_output_id(&self) -> Option<ServerOutputId> {
self.output()
.and_then(|output| output.get().server_output_id.clone())
}
pub fn model_id(&self) -> Option<LLMId> {
self.output()
.and_then(|output| output.get().model_info.as_ref().map(|m| m.model_id.clone()))
}
pub fn output(&self) -> Option<&Shared<AIAgentOutput>> {
match self {
Self::Cancelled { output, .. } => output.as_ref(),
Self::Error { .. } => None,
Self::Success { output } => Some(output),
}
}
}
#[derive(Debug)]
pub struct Shared<T> {
value: Arc<RwLock<T>>,
}
impl<T> Clone for Shared<T>
where
T: Clone,
{
fn clone(&self) -> Self {
Self {
value: Arc::new(RwLock::new(self.value.read().clone())),
}
}
}
impl<T: Clone + std::fmt::Debug> Shared<T> {
pub fn new(value: T) -> Self {
Self {
value: Arc::new(RwLock::new(value)),
}
}
pub fn get(&self) -> impl Deref<Target = T> + '_ {
self.value.read()
}
/// Returns an owned `Shared` pointing to the same underlying `T`.
///
/// While `Clone` performs a deep copy on the other value, this ultimately points to the same
/// value `T`.
pub fn get_owned(&self) -> Shared<T> {
Self {
value: self.value.clone(),
}
}
fn get_mut(&self) -> impl DerefMut<Target = T> + '_ {
self.value.write()
}
}
impl<T: Display> Display for Shared<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.value.read().fmt(f)
}
}
/// Status of output streaming from the AI API.
#[derive(Clone, Debug)]
pub enum AIAgentOutputStatus {
Streaming {
output: Option<Shared<AIAgentOutput>>,
},
Finished {
finished_output: FinishedAIAgentOutput,
},
}
impl Display for AIAgentOutputStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AIAgentOutputStatus::Streaming { .. } => write!(f, "Streaming..."),
AIAgentOutputStatus::Finished { finished_output } => write!(f, "{finished_output}"),
}
}
}
impl AIAgentOutputStatus {
pub fn server_output_id(&self) -> Option<ServerOutputId> {
self.output()
.and_then(|output| output.get().server_output_id.clone())
}
pub fn cancel_reason(&self) -> Option<&CancellationReason> {
match self {
Self::Finished {
finished_output: FinishedAIAgentOutput::Cancelled { reason, .. },
} => Some(reason),
_ => None,
}
}
pub fn model_id(&self) -> Option<LLMId> {
self.output()
.and_then(|output| output.get().model_info.as_ref().map(|m| m.model_id.clone()))
}
pub fn output(&self) -> Option<&Shared<AIAgentOutput>> {
match self {
Self::Streaming { output, .. } => output.as_ref(),
Self::Finished {
finished_output, ..
} => finished_output.output(),
}
}
pub fn is_cancelled(&self) -> bool {
matches!(
self,
Self::Finished {
finished_output: FinishedAIAgentOutput::Cancelled { .. },
..
}
)
}
pub fn is_finished(&self) -> bool {
match self {
Self::Streaming { .. } => false,
Self::Finished { .. } => true,
}
}
pub fn is_finished_and_successful(&self) -> bool {
matches!(
self,
Self::Finished {
finished_output: FinishedAIAgentOutput::Success { .. }
}
)
}
pub fn is_streaming(&self) -> bool {
matches!(self, Self::Streaming { .. })
}
}
// This value is the cost of a single request.
// It is returned as part of the final response chunk from the agent.
#[derive(Clone, Copy, Default, Deserialize, Serialize, PartialOrd, Derivative)]
#[derivative(Debug, PartialEq, Eq)]
pub struct RequestCost(f64);
impl RequestCost {
pub fn new(value: f64) -> Self {
Self(value)
}
pub fn value(&self) -> f64 {
self.0
}
pub fn zero() -> Self {
Self(0.0)
}
pub fn one() -> Self {
Self(1.0)
}
}
impl AddAssign for RequestCost {
fn add_assign(&mut self, rhs: Self) {
self.0 += rhs.0;
}
}
impl std::iter::Sum for RequestCost {
fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
Self(iter.map(|cost| cost.0).sum())
}
}
impl std::fmt::Display for RequestCost {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// The AI output received in response to a user prompt/query.
#[derive(Clone, Default, Derivative)]
#[derivative(Debug, PartialEq, Eq)]
pub struct AIAgentOutput {
pub messages: Vec<AIAgentOutputMessage>,
/// The set of documents that were referenced in the LLM's response.
pub citations: Vec<AIAgentCitation>,
/// Unique ID generated by `warp-server`. Used to join client and server telemetry and logs.
pub server_output_id: Option<ServerOutputId>,
/// Optional metadata that may be attached by the `AIAgentApi` when emitting this output.
///
/// This is guaranteed to be stored and passed back with this output if/when it is passed back
/// to `AIAgentApi` as conversation history.
pub api_metadata_bytes: Option<Vec<u8>>,
/// Suggested objects to apply to the output.
pub suggestions: Option<Suggestions>,
/// Information about the model that generated this output.
pub model_info: Option<OutputModelInfo>,
/// Telemetry events related to the AI Agent Output that we want to send after completion.
#[derivative(Debug = "ignore")]
#[derivative(PartialEq = "ignore")]
pub telemetry_events: Vec<TelemetryEvent>,
/// The number of requests that the request cost.
pub request_cost: Option<RequestCost>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OutputModelInfo {
pub model_id: LLMId,
pub display_name: String,
pub is_fallback: bool,
/// When the provider-side prompt cache for this request is expected to
/// expire. `None` means unknown / no cache-expiry info.
pub prompt_cache_expires_at: Option<DateTime<Local>>,
}
impl Display for AIAgentOutput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for (i, message) in self.messages.iter().enumerate() {
if i > 0 {
writeln!(f)?;
}
write!(f, "Message {}: {}", i + 1, message)?;
}
Ok(())
}
}
impl AIAgentOutput {
/// Returns only the text from agent output messages in the output.
pub fn text_from_agent_output(&self) -> impl Iterator<Item = &AIAgentText> {
self.messages
.iter()
.filter_map(|message| match &message.message {
AIAgentOutputMessageType::Text(text) => Some(text),
_ => None,
})
}
/// Returns only the text from reasoning messages in the output.
pub fn text_from_agent_reasoning(&self) -> impl Iterator<Item = &AIAgentText> {
self.messages
.iter()
.filter_map(|message| match &message.message {
AIAgentOutputMessageType::Reasoning { text, .. } => Some(text),
_ => None,
})
}
/// Returns all of the text contained in the output, including agent output, reasoning,
/// and conversation summaries.
///
/// IMPORTANT: This must stay in sync with the rendering code in `output.rs` — every
/// message type whose sections increment `text_section_index` during rendering must
/// also be yielded here, otherwise link detection indices will be offset.
pub fn all_text(&self) -> impl Iterator<Item = &AIAgentText> {
self.messages
.iter()
.filter_map(|message| match &message.message {
AIAgentOutputMessageType::Text(text) => Some(text),
AIAgentOutputMessageType::Reasoning { text, .. } => Some(text),
AIAgentOutputMessageType::Summarization {
text,
summarization_type: SummarizationType::ConversationSummary,
..
} => Some(text),
_ => None,
})
// It's important to filter these out, because we filter these out when rendering the output
// and the text_section_index must match for detected links to work.
.filter(|text| !are_all_text_sections_empty(&text.sections))
}
/// Returns all of the text contained in the output with their message IDs, including agent output,
/// reasoning, and conversation summaries.
///
/// IMPORTANT: This must stay in sync with the rendering code in `output.rs` — see [`all_text`].
pub fn all_text_with_message_id(&self) -> impl Iterator<Item = (&MessageId, &AIAgentText)> {
self.messages
.iter()
.filter_map(|message| match &message.message {
AIAgentOutputMessageType::Text(text) => Some((&message.id, text)),
AIAgentOutputMessageType::Reasoning { text, .. } => Some((&message.id, text)),
AIAgentOutputMessageType::Summarization {
text,
summarization_type: SummarizationType::ConversationSummary,
..
} => Some((&message.id, text)),
_ => None,
})
// It's important to filter these out, because we filter these out when rendering the output
// and the text_section_index must match for detected links to work.
.filter(|(_, text)| !are_all_text_sections_empty(&text.sections))
}
pub fn actions(&self) -> impl Iterator<Item = &AIAgentAction> {
self.messages
.iter()
.filter_map(|message| match &message.message {
AIAgentOutputMessageType::Action(action) => Some(action),
_ => None,
})
}
pub fn todo_operations(&self) -> impl Iterator<Item = &TodoOperation> {
self.messages
.iter()
.filter_map(|message| match &message.message {
AIAgentOutputMessageType::TodoOperation(operation) => Some(operation),
_ => None,
})
}
/// Format this output for copying to clipboard.
/// This extracts all content (text, code, and action results) with proper formatting.
pub fn format_for_copy(
&self,
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
) -> String {
let mut result = Vec::new();
let mut last_was_action = false;
// Process all messages in order, collecting all content
for message in &self.messages {
match &message.message {
AIAgentOutputMessageType::Text(text) => {
// If the last message was an action and this is text, add some separation
if last_was_action {
result.push(String::new()); // Add blank line for readability
}
// Collect all text and code sections from this text message
for section in &text.sections {
match section {
AIAgentTextSection::PlainText { text } => {
result.push(text.text().to_string());
}
AIAgentTextSection::Code { .. }
| AIAgentTextSection::Table { .. }
| AIAgentTextSection::Image { .. }
| AIAgentTextSection::MermaidDiagram { .. } => {
result.push(format!("{}", MarkdownTextSection(section)));
}
}
}
last_was_action = false;
}
AIAgentOutputMessageType::Action(action) => {
// Include action results from the action model if available
if let Some(action_model) = action_model {
if let Some(action_result) = action_model.get_action_result(&action.id) {
result.push(format!("{}", MarkdownActionResult(&action_result.result)));
// Add an extra newline after tool call results for readability
result.push(String::new());
last_was_action = true;
}
}
}
AIAgentOutputMessageType::TodoOperation(operation) => {
result.push(format!("{operation}"));
last_was_action = false;
}
AIAgentOutputMessageType::Subagent(subagent) => {
result.push(format!("{subagent}"));
last_was_action = false;
}
AIAgentOutputMessageType::CommentsAddressed {
comments: comment_ids,
} => {
result.push(format!("Addressed {} comments", comment_ids.len()));
last_was_action = false;
}
AIAgentOutputMessageType::Reasoning { .. } => continue,
AIAgentOutputMessageType::Summarization { .. } => continue,
AIAgentOutputMessageType::WebSearch(_) => continue,
AIAgentOutputMessageType::WebFetch(_) => continue,
AIAgentOutputMessageType::DebugOutput { text } => {
result.push(format!("[DEBUG] {text}"));
last_was_action = false;
}
AIAgentOutputMessageType::ArtifactCreated(_) => continue,
AIAgentOutputMessageType::SkillInvoked(_) => continue,
AIAgentOutputMessageType::MessagesReceivedFromAgents { messages } => {
result.push(format!("Received {} messages", messages.len()));
last_was_action = false;
}
AIAgentOutputMessageType::EventsFromAgents { event_ids } => {
result.push(format!("Received {} agent events", event_ids.len()));
last_was_action = false;
}
}
}
// Remove trailing empty lines
while result.last() == Some(&String::new()) {
result.pop();
}
result.join("\n")
}
pub fn extend_citations(&mut self, citations: Vec<AIAgentCitation>) {
let new_citations: Vec<_> = citations
.into_iter()
.filter(|c| !self.citations.contains(c))
.collect();
self.citations.extend(new_citations);
}
/// Calculate the action index for a given action_id by counting preceding actions in the output.
/// Returns the 0-based index of the action, or None if the action is not found.
pub fn calculate_action_index(&self, target_action_id: &AIAgentActionId) -> Option<usize> {
let mut action_index = 0;
for output_message in &self.messages {
if let AIAgentOutputMessageType::Action(AIAgentAction { id, .. }) =
&output_message.message
{
if id == target_action_id {
return Some(action_index);
}
action_index += 1;
}
}
None // Fallback if action_id not found
}
}
/// Represents user visible errors.
#[derive(Clone, Debug)]
pub enum RenderableAIError {
QuotaLimit {
user_display_message: Option<String>,
},
ServerOverloaded,
InternalWarpError,
ContextWindowExceeded(String),
InvalidApiKey {
provider: String,
model_name: String,
},
AwsBedrockCredentialsExpiredOrInvalid {
model_name: String,
},
/// A transient network failure (lost connection or truncated response stream). Carries its
/// own complete user-facing copy; `kind` preserves the structured cause (including the raw
/// API error) so user reports can disambiguate the different causes behind the shared message.
TransientNetworkError {
kind: TransientNetworkErrorKind,
will_attempt_resume: bool,
/// When `will_attempt_resume` is true, this indicates whether we're waiting for network
/// connectivity before attempting the resume.
waiting_for_network: bool,
},
Other {
error_message: String,
will_attempt_resume: bool,
/// When `will_attempt_resume` is true, this indicates whether we're waiting for network
/// connectivity before attempting the resume.
waiting_for_network: bool,
/// True when the error originates from a user-side issue (e.g., model not allowed,
/// blocked due to fraud, plan restriction). Maps the task to FAILED state instead of ERROR.
is_user_error: bool,
},
/// An agent-issued command caused the shell process to exit, so the run
/// cannot continue. Surfaced as a terminal failure (FAILED) rather than a
/// user cancellation.
AgentExitedShell,
}
impl RenderableAIError {
const TRANSIENT_NETWORK_ERROR_MESSAGE: &'static str =
"Warp lost connection while receiving the agent response. This is usually temporary.";
/// User-facing message shown when an agent-issued command exits the shell.
pub const AGENT_EXITED_SHELL_MESSAGE: &'static str =
"The shell exited while the agent was running a command, so the run could not continue. Ensure the agent is not asked to run commands or source scripts that can exit the shell.";
/// Creates a transient network error. `kind` is the structured cause (including the raw API
/// error where one exists), preserved so user reports can disambiguate the different causes
/// behind the shared user-facing copy.
pub fn transient_network_error(
will_attempt_resume: bool,
waiting_for_network: bool,
kind: TransientNetworkErrorKind,
) -> Self {
Self::TransientNetworkError {
kind,
will_attempt_resume,
waiting_for_network,
}
}
fn is_transient_network_transport_error(error: &reqwest::Error) -> bool {
// If reqwest has an HTTP status, the server responded. Preserve the existing generic
// rendering for those failures rather than calling them lost connections.
error.status().is_none()
}
pub fn is_invalid_api_key(&self) -> bool {
matches!(self, Self::InvalidApiKey { .. })
}
pub fn is_aws_bedrock_credentials_error(&self) -> bool {
matches!(self, Self::AwsBedrockCredentialsExpiredOrInvalid { .. })
}
/// Returns true if an automatic resume will be attempted for this error.
pub fn will_attempt_resume(&self) -> bool {
matches!(
self,
Self::Other {
will_attempt_resume: true,
..
} | Self::TransientNetworkError {
will_attempt_resume: true,
..
}
)
}
/// Whether the failed-output UI should be suppressed while an automatic resume is in
/// flight. Release builds stay quiet so transient blips that recover on their own
/// don't surface an alarming error; dogfood builds (Local/Dev) keep the old, more
/// aggressive behavior so developers still see every transport failure.
pub fn should_suppress_during_recovery(&self) -> bool {
self.will_attempt_resume() && !ChannelState::channel().is_dogfood()
}
/// Constructs a generic [`RenderableAIError::Other`] from a message.
/// `is_user_error` selects the task classification (true → FAILED, false →
/// ERROR). The resume/network flags are false: this is for terminal,
/// out-of-band errors that are not auto-resumed.
pub fn other(error_message: impl Into<String>, is_user_error: bool) -> Self {
Self::Other {
error_message: error_message.into(),
will_attempt_resume: false,
waiting_for_network: false,
is_user_error,
}
}
}
/// The cause behind a [`RenderableAIError::TransientNetworkError`]. Kept structured (rather than
/// collapsed to a free-form string) so user reports preserve the raw error; rendered to text only
/// at display time.
#[derive(Clone, Debug, thiserror::Error)]
pub enum TransientNetworkErrorKind {
/// A lost connection or truncated response stream — the raw underlying API error. Rendered via
/// `Debug` so reports preserve the full structured error rather than its terse `Display`.
#[error("{0:?}")]
Api(Arc<AIApiError>),
/// The response stream completed with an unfinished exchange and no error event.
#[error("stream completed with an unfinished exchange and no error event")]
UnfinishedExchange,
/// The conversation was left in a transient-error state but the last exchange carried no
/// structured error to surface.
#[error("no structured error on the last exchange")]
MissingExchangeError,
}
impl From<&Arc<AIApiError>> for RenderableAIError {
fn from(value: &Arc<AIApiError>) -> Self {
// Non-retryable 4xx errors (403 fraud block, 400 model/plan restriction, etc.)
// are user-originating — map them to a user error so the task reaches FAILED
// state rather than ERROR state.
let is_user_error = !value.is_recoverable();
match value.as_ref() {
AIApiError::QuotaLimit {
user_display_message,
} => Self::QuotaLimit {
user_display_message: user_display_message.clone(),
},
AIApiError::ServerOverloaded => Self::ServerOverloaded,
AIApiError::Transport(error)
| AIApiError::Deserialization(DeserializationError::Transport(error)) => {
// A transport error with no HTTP status is a lost-connection failure; one that
// carries a status means the server responded, so it gets generic rendering.
if Self::is_transient_network_transport_error(error) {
Self::transient_network_error(
false,
false,
TransientNetworkErrorKind::Api(value.clone()),
)
} else {
Self::Other {
error_message: format!("Request failed with error: {value:?}"),
will_attempt_resume: false,
waiting_for_network: false,
is_user_error,
}
}
}
AIApiError::UnexpectedEof => Self::transient_network_error(
false,
false,
TransientNetworkErrorKind::Api(value.clone()),
),
AIApiError::Deserialization(DeserializationError::Json(_))
| AIApiError::NoContextFound
| AIApiError::ErrorStatus(_, _)
| AIApiError::Other(_)
| AIApiError::Stream { .. } => Self::Other {
error_message: format!("Request failed with error: {value:?}"),
will_attempt_resume: false,
waiting_for_network: false,
is_user_error,
},
}
}
}
impl Display for RenderableAIError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::QuotaLimit {
user_display_message,
} => {
if let Some(message) = user_display_message {
write!(f, "{message}")
} else {
write!(f, "Quota limit reached.")
}
}
Self::ServerOverloaded => {
write!(f, "Warp is currently overloaded. Please try again later.")
}
Self::InternalWarpError => write!(f, "Internal Warp error."),
Self::ContextWindowExceeded(message) => {
write!(f, "Context window exceeded: {message}")
}
Self::InvalidApiKey { provider, .. } => {
write!(f, "Invalid API key for {provider}")
}
Self::AwsBedrockCredentialsExpiredOrInvalid { model_name } => {
write!(
f,
"AWS Bedrock credentials expired or invalid for {model_name}"
)
}
Self::TransientNetworkError { kind, .. } => {
write!(
f,
"{}\n\nDebug info: {kind}",
Self::TRANSIENT_NETWORK_ERROR_MESSAGE
)
}
Self::Other { error_message, .. } => write!(f, "{error_message}"),
Self::AgentExitedShell => write!(f, "{}", Self::AGENT_EXITED_SHELL_MESSAGE),
}
}
}
#[allow(unused)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProgrammingLanguage {
Shell(ShellType),
Other(String),
}
impl ProgrammingLanguage {
pub fn display_name(&self) -> String {
match self {
Self::Shell(shell_type) => shell_type.name().to_owned(),
Self::Other(language) => language.to_lowercase(),
}
}
/// Returns the file extension for the given programming language.
// TODO(INT-605): Refactor so we don't have to edit this function and the `languages` crate.
#[cfg_attr(target_family = "wasm", allow(unused))]
pub fn to_extension(&self) -> Option<&str> {
match self {
// The arms below cover both canonical language names emitted by the agent (e.g.
// "rust", "kotlin") and common markdown code-fence aliases (e.g. "rs", "kt") to keep
// syntax highlighting working when the model uses either. The set of recognized
// languages here is kept in sync with `SUPPORTED_LANGUAGES` in the `languages` crate.
Self::Other(language) => match language.to_lowercase().as_str() {
"rust" | "rs" => Some("rs"),
"go" | "golang" => Some("go"),
"python" | "py" => Some("py"),
"javascript" | "js" => Some("js"),
"typescript" | "ts" => Some("ts"),
"jsx" => Some("jsx"),
"tsx" => Some("tsx"),
"yaml" | "yml" => Some("yaml"),
"cpp" | "c++" => Some("cpp"),
"java" => Some("java"),
"groovy" => Some("java"),
"shell" => Some("sh"),
"c#" | "csharp" => Some("cs"),
"html" => Some("html"),
"css" => Some("css"),
"c" => Some("c"),
"json" => Some("json"),
"jq" => Some("jq"),
"hcl" | "terraform" | "tf" => Some("hcl"),
"lua" => Some("lua"),
"ruby" | "rb" => Some("rb"),
"php" => Some("php"),
"toml" => Some("toml"),
"swift" => Some("swift"),
"kotlin" | "kt" => Some("kt"),
"powershell" => Some("ps1"),
"elixir" => Some("exs"),
"scala" => Some("scala"),
"sql" => Some("sql"),
"objective-c" | "objc" => Some("m"),
"starlark" => Some("bzl"),
"xml" => Some("xml"),
"vue" => Some("vue"),
"dockerfile" | "docker" | "containerfile" => Some("dockerfile"),
_ => None,
},
Self::Shell(ShellType::PowerShell) => Some("ps1"),
_ => None,
}
}
/// Return whether this language is a shell language.
/// This is used to determine whether to show the "execute in terminal" button.
pub fn is_shell(&self) -> bool {
matches!(self, Self::Shell(_))
}
}
impl Display for ProgrammingLanguage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProgrammingLanguage::Shell(shell_type) => write!(f, "{}", shell_type.name()),
ProgrammingLanguage::Other(language) => write!(f, "{}", language.to_lowercase()),
}
}
}
impl From<String> for ProgrammingLanguage {
// Returns a programming language for a markdown language specifier
fn from(value: String) -> Self {
if let Some(shell_type) = ShellType::from_markdown_language_spec(value.as_str()) {
ProgrammingLanguage::Shell(shell_type)
} else {
ProgrammingLanguage::Other(value)
}
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum AgentOutputImageLayout {
Block,
Inline,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct SuggestedAgentModeWorkflow {
pub name: String,
pub prompt: String,
pub logging_id: SuggestedLoggingId,
}
/// A ID for an AI action generated as part of an [`AIAgentOutput`].
///
/// The internal ID itself should be opaque to all callers. This ID may be relayed back to the AI with
/// the `AIAgentActionResult` from the action.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct AIAgentActionId(String);
impl From<String> for AIAgentActionId {
fn from(value: String) -> Self {
AIAgentActionId(value)
}
}
impl From<AIAgentActionId> for String {
fn from(value: AIAgentActionId) -> Self {
value.0
}
}
impl Display for AIAgentActionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
impl From<crate::persistence::model::AIAgentActionId> for AIAgentActionId {
fn from(value: crate::persistence::model::AIAgentActionId) -> Self {
Self(value.0)
}
}
impl From<AIAgentActionId> for crate::persistence::model::AIAgentActionId {
fn from(value: AIAgentActionId) -> Self {
crate::persistence::model::AIAgentActionId(value.0)
}
}
/// An "action" included in an AI output.
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct AIAgentAction {
/// Unique ID for the action.
pub id: AIAgentActionId,
/// The ID of the task to which this action belongs.
pub task_id: TaskId,
/// The action itself.
pub action: AIAgentActionType,
/// `true` if this action requires a corresponding `AIAgentActionResult` to be sent back to the
/// AI API.
///
/// If this is `true`, a corresponding result _must_ be included in the next query to the AI.
pub requires_result: bool,
/// The original tool name as sent by the model (e.g. "create_plan", "create_notebook").
/// Used to distinguish between tools that share the same `AIAgentActionType` variant.
pub tool_name: Option<String>,
}
impl Display for AIAgentAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.action)
}
}
impl AIAgentAction {
pub fn is_request_file_edit(&self) -> bool {
matches!(self.action, AIAgentActionType::RequestFileEdits { .. })
}
pub fn is_request_command_output(&self) -> bool {
self.action.is_request_command_output()
}
pub fn is_agent_monitored_request_command_output(&self) -> bool {
matches!(
self.action,
AIAgentActionType::RequestCommandOutput {
wait_until_completion: false,
..
}
)
}
pub fn is_get_specific_files(&self) -> bool {
self.action.is_read_files()
}
pub fn is_get_relevant_files(&self) -> bool {
self.action.is_search_codebase()
}
pub fn is_grep(&self) -> bool {
self.action.is_grep()
}
pub fn is_file_glob(&self) -> bool {
self.action.is_file_glob()
}
pub fn executable_command(&self) -> Option<String> {
match &self.action {
AIAgentActionType::RequestCommandOutput { command, .. } => Some(command.clone()),
_ => None,
}
}
pub fn is_write_to_shell_command(&self) -> bool {
self.action.is_write_to_shell_command()
}
pub fn matches_command(&self, command: &String) -> bool {
Some(command) == self.executable_command().as_ref()
}
}
pub struct MarkdownTextSection<'a>(pub &'a AIAgentTextSection);
impl<'a> std::fmt::Display for MarkdownTextSection<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.0 {
AIAgentTextSection::PlainText { text } => {
write!(f, "{}", text.text())
}
AIAgentTextSection::Code {
code,
language,
source,
} => {
write!(f, "```")?;
if let Some(lang) = language {
write!(f, "{}", lang.display_name())?;
}
if let Some(CodeSource::Link {
path,
range_start,
range_end,
}) = source
{
write!(f, " path={path:?}")?;
if let (Some(range_start), Some(range_end)) = (range_start, range_end) {
write!(
f,
" start={} end={}",
range_start.line_num, range_end.line_num
)?;
}
}
writeln!(f)?;
writeln!(f, "{code}")?;
write!(f, "```")
}
AIAgentTextSection::Table { table } => {
write!(f, "{}", table.markdown_source)
}
AIAgentTextSection::Image { image } => {
write!(f, "{}", image.markdown_source)
}
AIAgentTextSection::MermaidDiagram { diagram } => {
write!(f, "{}", diagram.markdown_source)
}
}
}
}
pub struct MarkdownActionResult<'a>(pub &'a AIAgentActionResultType);
impl<'a> std::fmt::Display for MarkdownActionResult<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.0 {
AIAgentActionResultType::RequestCommandOutput(result) => match result {
RequestCommandOutputResult::Completed {
command,
output,
exit_code: _,
..
} => {
write!(
f,
"\n**Command Executed:**\n```bash\n{command}\n```\n\n**Output:**\n```\n{output}\n```"
)
}
RequestCommandOutputResult::LongRunningCommandSnapshot {
command,
grid_contents,
..
} => {
write!(
f,
"\n```bash\n{command}\n```\n\n**Current Output:**\n```\n{grid_contents}\n```"
)
}
RequestCommandOutputResult::CancelledBeforeExecution => {
write!(f, "\n_Command cancelled_")
}
RequestCommandOutputResult::Denylisted { command } => {
write!(
f,
"\nCommand ({command}) was on denylist and so was not allowed to run"
)
}
},
AIAgentActionResultType::WriteToLongRunningShellCommand(result) => match result {
WriteToLongRunningShellCommandResult::CommandFinished { output, .. } => {
write!(f, "\n```\n{output}\n```")
}
WriteToLongRunningShellCommandResult::Snapshot { grid_contents, .. } => {
write!(f, "\n```\n{grid_contents}\n```")
}
WriteToLongRunningShellCommandResult::Cancelled => {
write!(f, "\n_Command cancelled_")
}
WriteToLongRunningShellCommandResult::Error(e) => {
write!(f, "\n_Write to command failed: {e:?}")
}
},
AIAgentActionResultType::RequestFileEdits(result) => match result {
RequestFileEditsResult::Success { diff, .. } => {
write!(f, "\n\n**Diff:**\n```diff\n{diff}\n```\n\n")
}
RequestFileEditsResult::Cancelled => write!(f, "\n_File edits cancelled_"),
RequestFileEditsResult::DiffApplicationFailed { error } => {
write!(f, "\n_File edits failed: {error} _")
}
},
AIAgentActionResultType::ReadFiles(result) => match result {
ReadFilesResult::Success { files } => {
write!(f, "\n\n**Files Read:**\n\n")?;
for file in files {
writeln!(f, "**{}**", file.file_name)?;
let content = &file.content;
if let AnyFileContent::StringContent(text) = content {
if !text.trim().is_empty() {
writeln!(f, "```\n{text}\n```\n")?;
}
}
}
Ok(())
}
ReadFilesResult::Error(error) => write!(f, "\n_Read files error: {error} _"),
ReadFilesResult::Cancelled => write!(f, "\n_Read files cancelled_"),
},
AIAgentActionResultType::UploadArtifact(result) => match result {
UploadArtifactResult::Success {
artifact_uid,
filepath,
mime_type,
description,
size_bytes,
} => {
write!(f, "\n**Artifact Uploaded:** `{artifact_uid}`")?;
if let Some(filepath) = filepath {
write!(f, "\n\n**File:** `{filepath}`")?;
}
write!(
f,
"\n\n**MIME Type:** `{mime_type}`\n\n**Size:** `{size_bytes}` bytes"
)?;
if let Some(description) = description {
write!(f, "\n\n**Description:** {description}")?;
}
Ok(())
}
UploadArtifactResult::Error(error) => {
write!(f, "\n_Upload artifact error: {error} _")
}
UploadArtifactResult::Cancelled => write!(f, "\n_Upload artifact cancelled_"),
},
AIAgentActionResultType::SearchCodebase(result) => match result {
SearchCodebaseResult::Success { files } => {
write!(f, "\n\n**Codebase Search Results:**\n\n")?;
for file in files {
writeln!(f, "- **{}**", file.file_name)?;
let content = &file.content;
if let AnyFileContent::StringContent(text) = content {
if !text.trim().is_empty() {
writeln!(f, "```\n{text}\n```\n")?;
}
}
}
Ok(())
}
SearchCodebaseResult::Failed { message, .. } => {
write!(f, "\n_Codebase search failed: {message} _")
}
SearchCodebaseResult::Cancelled => write!(f, "\n_Codebase search cancelled_"),
},
AIAgentActionResultType::FileGlobV2(result) => match result {
FileGlobV2Result::Success { matched_files, .. } => {
write!(f, "\n\n**File Glob Results:**\n\n")?;
for file in matched_files {
writeln!(f, "- **{}**", file.file_path)?;
}
Ok(())
}
FileGlobV2Result::Error(message) => {
write!(f, "\n_File glob error: {message} _")
}
FileGlobV2Result::Cancelled => write!(f, "\n_File glob cancelled_"),
},
AIAgentActionResultType::Grep(result) => match result {
GrepResult::Success { matched_files } => {
write!(f, "\n\n**Grep Results:**\n\n")?;
for file in matched_files {
writeln!(f, "- **{}**", file.file_path)?;
}
Ok(())
}
GrepResult::Error(message) => {
write!(f, "\n_Grep error: {message} _")
}
GrepResult::Cancelled => write!(f, "\n_Grep cancelled_"),
},
AIAgentActionResultType::ReadDocuments(result) => match result {
ReadDocumentsResult::Success { documents } => {
write!(f, "\n\n**Documents Read:**\n\n")?;
for document in documents {
writeln!(f, "**Document {}**", document.document_id)?;
if !document.content.trim().is_empty() {
writeln!(f, "```\n{}\n```\n", document.content)?;
}
}
Ok(())
}
ReadDocumentsResult::Error(error) => {
write!(f, "\n_Read documents error: {error} _")
}
ReadDocumentsResult::Cancelled => write!(f, "\n_Read documents cancelled_"),
},
AIAgentActionResultType::EditDocuments(result) => match result {
EditDocumentsResult::Success { updated_documents } => {
write!(f, "\n\n**Documents Edited:**\n\n")?;
for document in updated_documents {
writeln!(f, "**Document {}**", document.document_id)?;
if !document.content.trim().is_empty() {
writeln!(f, "```\n{}\n```\n", document.content)?;
}
}
Ok(())
}
EditDocumentsResult::Error(error) => {
write!(f, "\n_Edit documents error: {error} _")
}
EditDocumentsResult::Cancelled => write!(f, "\n_Edit documents cancelled_"),
},
AIAgentActionResultType::CreateDocuments(result) => match result {
CreateDocumentsResult::Success { created_documents } => {
write!(f, "\n\n**Documents Created:**\n\n")?;
for document in created_documents {
writeln!(f, "**Document {}**", document.document_id)?;
if !document.content.trim().is_empty() {
writeln!(f, "```\n{}\n```\n", document.content)?;
}
}
Ok(())
}
CreateDocumentsResult::Error(error) => {
write!(f, "\n_Create documents error: {error} _")
}
CreateDocumentsResult::Cancelled => write!(f, "\n_Create documents cancelled_"),
},
AIAgentActionResultType::ReadShellCommandOutput(result) => match result {
ReadShellCommandOutputResult::CommandFinished { output, .. } => {
write!(f, "\n```\n{output}\n```")
}
ReadShellCommandOutputResult::LongRunningCommandSnapshot {
command,
grid_contents,
..
} => {
write!(
f,
"\n```bash\n{command}\n```\n\n**Current Output:**\n```\n{grid_contents}\n```"
)
}
ReadShellCommandOutputResult::Cancelled => {
write!(f, "\n_Command cancelled_")
}
ReadShellCommandOutputResult::Error(e) => {
write!(f, "\n_Read shell command output failed: {e:?}_")
}
},
other => {
write!(f, "{other}")
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct AIAgentActionResult {
pub id: AIAgentActionId,
pub task_id: TaskId,
pub result: AIAgentActionResultType,
}
impl Display for AIAgentActionResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.result)
}
}
impl AIAgentActionResult {
/// Returns `true` if this action was explicitly rejected by the user.
pub fn is_rejected(&self) -> bool {
matches!(
self.result,
AIAgentActionResultType::RequestFileEdits(RequestFileEditsResult::Cancelled)
| AIAgentActionResultType::RequestCommandOutput(
RequestCommandOutputResult::CancelledBeforeExecution
)
| AIAgentActionResultType::ReadFiles(ReadFilesResult::Cancelled)
| AIAgentActionResultType::UploadArtifact(UploadArtifactResult::Cancelled)
| AIAgentActionResultType::SearchCodebase(SearchCodebaseResult::Cancelled)
| AIAgentActionResultType::Grep(GrepResult::Cancelled)
| AIAgentActionResultType::FileGlob(FileGlobResult::Cancelled)
| AIAgentActionResultType::ReadMCPResource(ReadMCPResourceResult::Cancelled)
| AIAgentActionResultType::CallMCPTool(CallMCPToolResult::Cancelled)
| AIAgentActionResultType::SuggestNewConversation(
SuggestNewConversationResult::Cancelled,
)
| AIAgentActionResultType::SuggestPrompt(SuggestPromptResult::Cancelled),
)
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub(crate) struct FormattedTextLineWrapper {
/// The raw text with the Markdown formatting syntax stripped.
/// This is needed for find & link/secret detection.
stripped_text: String,
/// Pre-extracted URL hyperlinks from this line.
/// The AI formatted text wrapper only supports URL hyperlinks (since it's constructed via markdown).
hyperlinks: Vec<(Range<usize>, String)>,
}
impl FormattedTextLineWrapper {
/// Returns the raw text with the Markdown formatting syntax stripped.
pub fn raw_text(&self) -> &str {
&self.stripped_text
}
pub fn hyperlinks(&self) -> Vec<(Range<usize>, String)> {
self.hyperlinks.clone()
}
}
#[derive(Debug, Clone)]
pub(crate) struct FormattedTextWrapper {
/// Private to prevent direct mutation that would desync the cached `formatted_text` Arc.
lines: Vec<FormattedTextLineWrapper>,
formatted_text: Arc<FormattedText>,
}
impl PartialEq for FormattedTextWrapper {
fn eq(&self, other: &Self) -> bool {
self.lines == other.lines
}
}
impl Eq for FormattedTextWrapper {}
impl FormattedTextWrapper {
pub fn lines(&self) -> &[FormattedTextLineWrapper] {
&self.lines
}
/// Returns a cheap clone of the cached [`FormattedText`], avoiding a per-call deep copy.
pub fn formatted_text_arc(&self) -> Arc<FormattedText> {
Arc::clone(&self.formatted_text)
}
}
impl From<FormattedText> for FormattedTextWrapper {
fn from(value: FormattedText) -> Self {
let formatted_text = Arc::new(value);
let lines = formatted_text
.lines
.iter()
.map(|line| FormattedTextLineWrapper {
stripped_text: line.raw_text(),
hyperlinks: line
.hyperlinks(true)
.into_iter()
.filter_map(|(r, u)| Some((r, u.url()?)))
.collect(),
})
.collect();
Self {
lines,
formatted_text,
}
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct AgentOutputText {
pub(crate) formatted_lines: Option<FormattedTextWrapper>,
/// The raw text with the Markdown formatting syntax. This is needed for restoring the
/// Markdown formatting when reopening warp.
markdown_text: String,
}
impl AgentOutputText {
/// Returns the original responded text with the Markdown format syntax.
pub fn text(&self) -> &str {
self.markdown_text.as_str()
}
/// Note that mutating the returned string will not automatically reparse the text and update `formatted_lines`.
pub fn mut_text(&mut self) -> &mut String {
&mut self.markdown_text
}
pub fn reparse_markdown(&mut self) {
let parsed_result = parse_markdown(self.markdown_text.as_str());
self.formatted_lines = parsed_result.map(|formatted| formatted.into()).ok();
}
}
impl From<String> for AgentOutputText {
fn from(value: String) -> Self {
let parsed_result = parse_markdown(value.as_str());
Self {
formatted_lines: parsed_result.map(|formatted| formatted.into()).ok(),
markdown_text: value,
}
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum AgentOutputTableRendering {
Legacy { content: String },
Structured { table: FormattedTable },
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct AgentOutputTable {
pub markdown_source: String,
pub rendering: AgentOutputTableRendering,
}
impl AgentOutputTable {
pub fn legacy(content: String) -> Self {
Self {
markdown_source: content.clone(),
rendering: AgentOutputTableRendering::Legacy { content },
}
}
pub fn structured(markdown_source: String, table: FormattedTable) -> Self {
Self {
markdown_source,
rendering: AgentOutputTableRendering::Structured { table },
}
}
fn plain_text_for_cell(cell: &FormattedTextInline) -> String {
cell.iter().map(|fragment| fragment.text.as_str()).collect()
}
fn plain_text_for_row(cells: &[FormattedTextInline]) -> String {
cells
.iter()
.map(Self::plain_text_for_cell)
.collect::<Vec<_>>()
.join("\t")
}
pub fn rendered_lines(&self) -> Vec<String> {
match &self.rendering {
AgentOutputTableRendering::Legacy { content } => {
content.lines().map(str::to_owned).collect()
}
AgentOutputTableRendering::Structured { table } => {
let mut lines = Vec::with_capacity(1 + table.rows.len());
lines.push(Self::plain_text_for_row(&table.headers));
lines.extend(table.rows.iter().map(|row| Self::plain_text_for_row(row)));
lines
}
}
}
pub fn structured_table(&self) -> Option<&FormattedTable> {
match &self.rendering {
AgentOutputTableRendering::Legacy { .. } => None,
AgentOutputTableRendering::Structured { table } => Some(table),
}
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct AgentOutputImage {
pub alt_text: String,
pub source: String,
/// Optional CommonMark image title preserved from `![alt](src "title")`.
/// Empty titles are normalized to `None` by the shared markdown parser.
pub title: Option<String>,
pub markdown_source: String,
pub layout: AgentOutputImageLayout,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct AgentOutputMermaidDiagram {
pub source: String,
pub markdown_source: String,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum AIAgentTextSection {
/// Plain textual output from the AI.
PlainText { text: AgentOutputText },
/// A snippet of code included as part of the AI's output.
Code {
code: String,
language: Option<ProgrammingLanguage>,
source: Option<CodeSource>,
},
/// A formatted markdown table rendered in a text block.
Table { table: AgentOutputTable },
/// A markdown image rendered as a visual block.
Image { image: AgentOutputImage },
/// A Mermaid diagram rendered as a visual block.
MermaidDiagram { diagram: AgentOutputMermaidDiagram },
}
impl AIAgentTextSection {
pub fn is_empty(&self) -> bool {
match self {
AIAgentTextSection::PlainText { text } => text.text().is_empty(),
AIAgentTextSection::Code { code, .. } => code.is_empty(),
AIAgentTextSection::Table { table } => table.markdown_source.is_empty(),
AIAgentTextSection::Image { image } => image.markdown_source.is_empty(),
AIAgentTextSection::MermaidDiagram { diagram } => diagram.markdown_source.is_empty(),
}
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct AIAgentText {
pub sections: Vec<AIAgentTextSection>,
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct AIAgentTodoId(String);
impl AsRef<str> for AIAgentTodoId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl From<String> for AIAgentTodoId {
fn from(value: String) -> Self {
Self(value)
}
}
impl From<AIAgentTodoId> for String {
fn from(value: AIAgentTodoId) -> Self {
value.0
}
}
impl Display for AIAgentTodoId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct AIAgentTodo {
pub id: AIAgentTodoId,
pub title: String,
pub description: String,
}
impl AIAgentTodo {
pub fn new(id: AIAgentTodoId, title: String, description: String) -> Self {
Self {
id,
title,
description,
}
}
}
impl Display for AIAgentTodo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.id, self.title)?;
Ok(())
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum TodoOperation {
UpdateTodos { todos: Vec<AIAgentTodo> },
MarkAsCompleted { completed_todos: Vec<AIAgentTodo> },
}
impl Display for TodoOperation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TodoOperation::UpdateTodos { todos } => {
write!(f, "UpdateTodos: {} items", todos.len())
}
TodoOperation::MarkAsCompleted { completed_todos } => {
write!(f, "MarkAsCompleted: {} items", completed_todos.len())
}
}
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum SubagentType {
Cli,
Research,
Advice,
ComputerUse,
Summarization,
ConversationSearch {
query: Option<String>,
/// Search targets are mutually exclusive; at most one of `conversation_id` or
/// `agent_run_id` should be populated for a single conversation search subagent.
/// The ID of the conversation being searched.
conversation_id: Option<String>,
/// The ID of the agent run being searched.
agent_run_id: Option<String>,
},
WarpDocumentationSearch,
Unknown,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct SubagentCall {
pub task_id: String,
pub subagent_type: SubagentType,
}
impl Display for SubagentCall {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Subagent: {}", self.task_id)
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct InvokedSkill {
pub name: String,
}
/// Data for a single received message, used for rendering in the UI.
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct ReceivedMessageDisplay {
pub message_id: String,
pub sender_agent_id: String,
pub addresses: Vec<String>,
pub subject: String,
pub message_body: String,
}
impl Display for InvokedSkill {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "InvokedSkill: {}", self.name)
}
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum AIAgentOutputMessageType {
Text(AIAgentText),
Reasoning {
text: AIAgentText,
/// How long the Agent reasoned for.
/// Only populated when the Agent is done reasoning.
finished_duration: Option<Duration>,
},
Summarization {
/// The summarization text sections.
text: AIAgentText,
/// How long the Agent spent summarizing.
/// Only populated when the summarization is done.
finished_duration: Option<Duration>,
summarization_type: SummarizationType,
/// Number of tokens in the summarization.
/// Only populated for ConversationSummary during/after summarization.
token_count: Option<u32>,
},
Subagent(SubagentCall),
Action(AIAgentAction),
TodoOperation(TodoOperation),
WebSearch(WebSearchStatus),
WebFetch(WebFetchStatus),
CommentsAddressed {
comments: Vec<ReviewComment>,
},
/// Debug-only output message for staging/dev builds.
DebugOutput {
text: String,
},
/// Notification that an artifact was created (e.g. a PR).
ArtifactCreated(ArtifactCreatedData),
SkillInvoked(InvokedSkill),
/// Messages received from other agent conversations.
MessagesReceivedFromAgents {
messages: Vec<ReceivedMessageDisplay>,
},
/// Lifecycle events received from other agent conversations.
EventsFromAgents {
event_ids: Vec<String>,
},
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum ArtifactCreatedData {
PullRequest {
url: String,
branch: String,
},
Screenshot {
artifact_uid: String,
mime_type: String,
description: Option<String>,
},
File {
artifact_uid: String,
filepath: String,
filename: String,
mime_type: String,
description: Option<String>,
size_bytes: i64,
},
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum SummarizationType {
ConversationSummary,
ToolCallResultSummary,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum WebSearchStatus {
Searching {
query: Option<String>,
},
Success {
query: String,
pages: Vec<(String, String)>,
},
Error {
query: String,
},
}
/// Status of a web fetch operation (fetching content from specific URLs).
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum WebFetchStatus {
/// Currently fetching content from URLs.
Fetching {
/// The URLs being fetched.
urls: Vec<String>,
},
/// Successfully fetched content from URLs.
Success {
/// The fetched pages: (url, title, success).
pages: Vec<(String, String, bool)>,
},
/// Failed to fetch content.
Error,
}
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct MessageId(String);
impl MessageId {
pub fn new(id: String) -> Self {
Self(id)
}
}
impl Deref for MessageId {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
/// A single output message received in an AI's response to some [`AIAgentInput`].
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct AIAgentOutputMessage {
pub id: MessageId,
pub message: AIAgentOutputMessageType,
pub citations: Vec<AIAgentCitation>,
}
impl Display for AIAgentOutputMessage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.message {
AIAgentOutputMessageType::Text(text)
| AIAgentOutputMessageType::Reasoning { text, .. }
| AIAgentOutputMessageType::Summarization { text, .. } => {
if matches!(self.message, AIAgentOutputMessageType::Reasoning { .. }) {
write!(f, "LLM Reasoning: ")?;
} else if matches!(self.message, AIAgentOutputMessageType::Summarization { .. }) {
write!(f, "Conversation Summary: ")?;
}
for (i, section) in text.sections.iter().enumerate() {
if i > 0 {
writeln!(f)?;
}
match section {
AIAgentTextSection::PlainText { text } => write!(f, "{}", text.text())?,
AIAgentTextSection::Code {
code,
language,
source,
} => {
write!(f, "```")?;
if let Some(lang) = language {
write!(f, "{lang}")?;
}
if let Some(CodeSource::Link {
path,
range_start,
range_end,
}) = source
{
write!(f, " path={path:?}")?;
if let (Some(range_start), Some(range_end)) =
(range_start, range_end)
{
write!(
f,
" start={} end={}",
range_start.line_num, range_end.line_num
)?;
}
}
writeln!(f)?;
writeln!(f, "{code}")?;
write!(f, "```")
}?,
AIAgentTextSection::Table { table } => {
{ write!(f, "{}", table.markdown_source) }?
}
AIAgentTextSection::Image { image } => {
write!(f, "{}", image.markdown_source)?
}
AIAgentTextSection::MermaidDiagram { diagram } => {
write!(f, "{}", diagram.markdown_source)?
}
}
}
}
AIAgentOutputMessageType::Action(action) => write!(f, "Action: {action}")?,
AIAgentOutputMessageType::TodoOperation(todo) => write!(f, "Todo: {todo}")?,
AIAgentOutputMessageType::Subagent(subagent) => write!(f, "Subagent: {subagent}")?,
AIAgentOutputMessageType::WebSearch(status) => match status {
WebSearchStatus::Searching { query } => match query {
Some(q) => write!(f, "Searching web for: {q}")?,
None => write!(f, "Searching web")?,
},
WebSearchStatus::Success { query, pages } => {
write!(f, "Searched web for: {query} ({} results)", pages.len())?
}
WebSearchStatus::Error { query } => write!(f, "Web search failed for: {query}")?,
},
AIAgentOutputMessageType::WebFetch(status) => match status {
WebFetchStatus::Fetching { urls } => {
write!(f, "Fetching {} web pages...", urls.len())?
}
WebFetchStatus::Success { pages } => {
write!(f, "Fetched {} web pages", pages.len())?
}
WebFetchStatus::Error => write!(f, "Web fetch failed")?,
},
AIAgentOutputMessageType::CommentsAddressed {
comments: comment_ids,
} => write!(f, "Addressed {} comments", comment_ids.len())?,
AIAgentOutputMessageType::DebugOutput { text } => write!(f, "[DEBUG] {text}")?,
AIAgentOutputMessageType::ArtifactCreated(data) => match data {
ArtifactCreatedData::PullRequest { url, branch } => {
write!(f, "Created PR: {url} (branch: {branch})")?
}
ArtifactCreatedData::Screenshot { artifact_uid, .. } => {
write!(f, "Screenshot captured (artifact: {artifact_uid})")?
}
ArtifactCreatedData::File {
artifact_uid,
filepath,
..
} => write!(
f,
"File artifact uploaded: {filepath} (artifact: {artifact_uid})"
)?,
},
AIAgentOutputMessageType::SkillInvoked(invoked_skill) => {
write!(f, "Skill Invoked: {}", invoked_skill.name)?
}
AIAgentOutputMessageType::MessagesReceivedFromAgents { messages } => {
write!(f, "Received {} messages", messages.len())?
}
AIAgentOutputMessageType::EventsFromAgents { event_ids } => {
write!(f, "Received {} agent events", event_ids.len())?
}
}
if !self.citations.is_empty() {
writeln!(f)?;
writeln!(f, "Citations:")?;
for citation in &self.citations {
writeln!(f, " - {citation}")?
}
}
Ok(())
}
}
impl AIAgentOutputMessage {
pub fn action(id: MessageId, action: AIAgentAction) -> Self {
Self {
id,
message: AIAgentOutputMessageType::Action(action),
citations: vec![],
}
}
pub fn text(id: MessageId, text: AIAgentText) -> Self {
Self {
id,
message: AIAgentOutputMessageType::Text(text),
citations: vec![],
}
}
pub fn subagent(id: MessageId, subagent: SubagentCall) -> Self {
Self {
id,
message: AIAgentOutputMessageType::Subagent(subagent),
citations: vec![],
}
}
pub fn reasoning(id: MessageId, text: AIAgentText, duration: Option<Duration>) -> Self {
Self {
id,
message: AIAgentOutputMessageType::Reasoning {
text,
finished_duration: duration,
},
citations: vec![],
}
}
pub fn todo_operation(id: MessageId, operation: TodoOperation) -> Self {
Self {
id,
message: AIAgentOutputMessageType::TodoOperation(operation),
citations: vec![],
}
}
pub fn comments_addressed(id: MessageId, comments: Vec<ReviewComment>) -> Self {
Self {
id,
message: AIAgentOutputMessageType::CommentsAddressed { comments },
citations: vec![],
}
}
pub fn debug_output(id: MessageId, text: String) -> Self {
Self {
id,
message: AIAgentOutputMessageType::DebugOutput { text },
citations: vec![],
}
}
pub fn summarization(
id: MessageId,
text: AIAgentText,
duration: Option<Duration>,
summarization_type: SummarizationType,
token_count: Option<u32>,
) -> Self {
Self {
id,
message: AIAgentOutputMessageType::Summarization {
text,
finished_duration: duration,
summarization_type,
token_count,
},
citations: vec![],
}
}
pub fn web_search(id: MessageId, status: WebSearchStatus) -> Self {
Self {
id,
message: AIAgentOutputMessageType::WebSearch(status),
citations: vec![],
}
}
pub fn web_fetch(id: MessageId, status: WebFetchStatus) -> Self {
Self {
id,
message: AIAgentOutputMessageType::WebFetch(status),
citations: vec![],
}
}
pub fn artifact_created(id: MessageId, data: ArtifactCreatedData) -> Self {
Self {
id,
message: AIAgentOutputMessageType::ArtifactCreated(data),
citations: vec![],
}
}
pub fn with_citations(self, citations: Vec<AIAgentCitation>) -> Self {
Self { citations, ..self }
}
pub fn skill_invoked(id: MessageId, invoked_skill: InvokedSkill) -> Self {
Self {
id,
message: AIAgentOutputMessageType::SkillInvoked(invoked_skill),
citations: vec![],
}
}
pub fn messages_received_from_agents(
id: MessageId,
messages: Vec<ReceivedMessageDisplay>,
) -> Self {
Self {
id,
message: AIAgentOutputMessageType::MessagesReceivedFromAgents { messages },
citations: vec![],
}
}
pub fn events_from_agents(id: MessageId, event_ids: Vec<String>) -> Self {
Self {
id,
message: AIAgentOutputMessageType::EventsFromAgents { event_ids },
citations: vec![],
}
}
}
// Information about what MCP capabilities the client has, to
// be provided as context for Agent Mode requests.
#[derive(Debug, Clone)]
#[cfg_attr(target_family = "wasm", allow(dead_code))]
pub struct MCPContext {
// Old flat structure (deprecated but kept for backward compatibility)
#[deprecated]
pub resources: Vec<rmcp::model::Resource>,
#[deprecated]
pub tools: Vec<rmcp::model::Tool>,
// New grouped structure
pub servers: Vec<MCPServer>,
}
#[derive(Debug, Clone)]
#[cfg_attr(target_family = "wasm", allow(dead_code))]
pub struct MCPServer {
pub id: String,
pub name: String,
pub description: String,
pub resources: Vec<rmcp::model::Resource>,
pub tools: Vec<rmcp::model::Tool>,
}
/// Contains context that may be attached to a user query.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AIAgentContext {
Directory {
pwd: Option<String>,
home_dir: Option<String>,
are_file_symbols_indexed: bool,
},
/// Text selected via the cursor within the block list.
SelectedText(String),
/// Information about the execution environment (OS, shell type and version) is included in the
/// query.
ExecutionEnvironment(WarpAiExecutionContext),
/// The current date and time.
CurrentTime {
current_time: DateTime<Local>,
},
/// An image attached to the query.
Image(ImageContext),
/// Indexed codebase possibly relevant to the query.
Codebase {
/// Absolute path to the indexed codebase.
path: String,
/// Repository name.
name: String,
},
ProjectRules {
root_path: String,
active_rules: Vec<FileContext>,
additional_rule_paths: Vec<String>,
},
File(FileContext),
Git {
head: String,
branch: Option<String>,
},
/// Information about the git repository in the current working directory.
Repository {
/// The repository name (e.g. "warp-internal").
name: String,
/// The repository owner/organization (e.g. "warpdotdev"), if determinable from the remote URL.
owner: Option<String>,
},
/// Information about the GitHub pull request associated with the current branch.
PullRequest {
/// The pull request number.
#[serde(default, deserialize_with = "deserialize_pull_request_number")]
number: i32,
/// The pull request state (for example, `OPEN`, `MERGED`, or `CLOSED`).
#[serde(default)]
state: String,
/// Whether the pull request is marked as draft.
#[serde(default)]
draft: bool,
/// The pull request's base branch.
#[serde(default)]
base_branch: String,
},
/// List of available skills is provided to the agent during initialization
/// or when updated.
Skills {
skills: Vec<SkillDescriptor>,
},
#[serde(untagged)]
Block(Box<BlockContext>),
}
fn deserialize_pull_request_number<'de, D>(deserializer: D) -> Result<i32, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
match value {
serde_json::Value::Null => Ok(0),
serde_json::Value::String(s) => {
if !s.chars().all(|c| c.is_ascii_digit()) {
return Ok(0);
}
Ok(s.parse()
.ok()
.filter(|number| *number > 0)
.unwrap_or_default())
}
serde_json::Value::Number(n) => {
let Some(number) = n.as_i64() else {
return Ok(0);
};
Ok(i32::try_from(number)
.ok()
.filter(|number| *number > 0)
.unwrap_or_default())
}
value => Err(serde::de::Error::custom(format!(
"expected string or number for pull request number, got {value}"
))),
}
}
#[derive(Clone, Serialize, Deserialize, Eq, PartialEq)]
pub struct ImageContext {
/// Base64-encoded image data.
pub data: String,
/// MIME type of the media content (e.g., "image/jpeg", "image/png")
pub mime_type: String,
pub file_name: String,
/// Whether this image was exported from Figma, detected via
/// the `Software: Figma` PNG metadata field.
#[serde(default)]
pub is_figma: bool,
}
impl std::fmt::Debug for ImageContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// We log dispatching typed actions (with `ImageContext` as an argument) and we don't want
// to log any UGC in prod.
f.debug_struct("ImageContext")
.field("data", &"REDACTED_B64_IMAGE_DATA_UGC")
.field("mime_type", &self.mime_type)
.field("file_name", &"REDACTED_FILE_NAME_UGC")
.finish()
}
}
/// Source of a document content attachment.
/// Used to identify user-attached plans to track in the UI.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum DocumentContentAttachmentSource {
UserAttached,
PlanEdited,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum AIAgentAttachment {
PlainText(String),
DocumentContent {
document_id: String,
content: String,
source: DocumentContentAttachmentSource,
line_range: Option<Range<LineCount>>,
},
DriveObject {
/// The UID of the drive object.
uid: String,
/// The payload of the drive object (e.g., workflow content).
payload: Option<DriveObjectPayload>,
},
DiffHunk {
file_path: String,
line_range: Range<LineCount>,
diff_content: String,
lines_added: u32,
lines_removed: u32,
current: Option<CurrentHead>,
base: DiffBase,
},
DiffSet {
/// Map from file path to list of diff hunks for that file
file_diffs: HashMap<String, Vec<DiffSetHunk>>,
/// Git branch information for the diff
current: Option<CurrentHead>,
base: DiffBase,
},
/// Reference to a file on the VM filesystem (e.g., downloaded attachments from cloud mode).
/// The server uses this to provide the file as an inline reference to the LLM.
FilePathReference {
/// The UUID of the attachment (from warp-server's presigned URL flow).
file_id: String,
/// The original filename.
file_name: String,
/// The full resolved path on disk where the file was downloaded.
file_path: String,
},
#[serde(untagged)]
Block(BlockContext),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CurrentHead {
BranchName(String),
HeadlessCommitSha(String),
}
impl CurrentHead {
pub fn title(&self) -> String {
match self {
CurrentHead::BranchName(name) => name.clone(),
CurrentHead::HeadlessCommitSha(sha) => {
let short = sha.chars().take(7).collect::<String>();
format!("Commit {short}")
}
}
}
}
impl From<CurrentHead> for warp_multi_agent_api::CurrentRef {
fn from(value: CurrentHead) -> Self {
Self {
r#ref: Some(match value {
CurrentHead::BranchName(name) => {
warp_multi_agent_api::current_ref::Ref::BranchName(name)
}
CurrentHead::HeadlessCommitSha(sha) => {
warp_multi_agent_api::current_ref::Ref::HeadlessCommitSha(sha)
}
}),
}
}
}
impl From<CurrentHead> for diff_hunk_api::Current {
fn from(value: CurrentHead) -> Self {
match value {
CurrentHead::BranchName(name) => diff_hunk_api::Current::CurrentBranchName(name),
CurrentHead::HeadlessCommitSha(sha) => {
diff_hunk_api::Current::CurrentHeadlessCommitSha(sha)
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum DiffBase {
BranchName(String),
HeadlessCommitSha(String),
UncommittedChanges,
}
impl From<DiffBase> for warp_multi_agent_api::BaseRef {
fn from(value: DiffBase) -> Self {
Self {
r#ref: Some(match value {
DiffBase::BranchName(name) => warp_multi_agent_api::base_ref::Ref::BranchName(name),
DiffBase::HeadlessCommitSha(sha) => {
warp_multi_agent_api::base_ref::Ref::HeadlessCommitSha(sha)
}
DiffBase::UncommittedChanges => {
warp_multi_agent_api::base_ref::Ref::UncommittedChanges(())
}
}),
}
}
}
impl From<DiffBase> for diff_hunk_api::Base {
fn from(value: DiffBase) -> Self {
match value {
DiffBase::BranchName(branch_name) => diff_hunk_api::Base::BaseBranchName(branch_name),
DiffBase::HeadlessCommitSha(sha) => diff_hunk_api::Base::BaseHeadlessCommitSha(sha),
DiffBase::UncommittedChanges =>
{
#[warn(clippy::unit_arg)]
diff_hunk_api::Base::UncommittedChanges(())
}
}
}
}
/// A simplified diff hunk for use in DiffSet attachments
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiffSetHunk {
pub line_range: Range<LineCount>,
pub diff_content: String,
pub lines_added: u32,
pub lines_removed: u32,
}
impl DiffSetHunk {
pub fn convert_to_api(self, file_path: String) -> warp_multi_agent_api::diff_set::DiffHunk {
warp_multi_agent_api::diff_set::DiffHunk {
file_path,
line_range: Some(warp_multi_agent_api::FileContentLineRange {
start: self.line_range.start.as_usize() as u32,
end: self.line_range.end.as_usize() as u32,
}),
diff_content: self.diff_content,
lines_added: self.lines_added,
lines_removed: self.lines_removed,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum DriveObjectPayload {
Workflow {
name: String,
description: String,
command: String,
},
Notebook {
title: String,
content: String,
},
GenericStringObject {
payload: String,
object_type: String,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum StaticQueryType {
Install,
Code,
Deploy,
SomethingElse,
EvaluationSuite,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[allow(clippy::enum_variant_names)]
pub enum EntrypointType {
PromptSuggestion {
is_static: bool,
is_coding: bool,
},
ZeroStateAgentModePromptSuggestion,
InitProjectRules,
TriggerPassiveSuggestion {
trigger: Option<PassiveSuggestionTriggerType>,
},
UserInitiated,
AgentInitiated,
SharedSession,
CloneRepository,
ResumeConversation,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[allow(clippy::enum_variant_names)]
pub enum PassiveSuggestionTriggerType {
/// Used for unit test generation.
FilesChanged,
/// Used for unit test generation.
CommandRun,
ShellCommandCompleted,
AgentResponseCompleted,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ShellCommandCompletedTrigger {
// We heap-allocate this because it's large and bloats the size of the
// `ShellCommandCompleted` enum variant relative to other variants.
pub executed_shell_command: Box<BlockContext>,
pub relevant_files: Vec<FileContext>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[allow(clippy::enum_variant_names)]
pub enum PassiveSuggestionTrigger {
FilesChanged,
CommandRun,
ShellCommandCompleted(ShellCommandCompletedTrigger),
AgentResponseCompleted { exchange_id: AIAgentExchangeId },
}
impl From<&PassiveSuggestionTrigger> for PassiveSuggestionTriggerType {
fn from(value: &PassiveSuggestionTrigger) -> Self {
match value {
PassiveSuggestionTrigger::FilesChanged => PassiveSuggestionTriggerType::FilesChanged,
PassiveSuggestionTrigger::CommandRun => PassiveSuggestionTriggerType::CommandRun,
PassiveSuggestionTrigger::ShellCommandCompleted(_) => {
PassiveSuggestionTriggerType::ShellCommandCompleted
}
PassiveSuggestionTrigger::AgentResponseCompleted { .. } => {
PassiveSuggestionTriggerType::AgentResponseCompleted
}
}
}
}
impl PassiveSuggestionTrigger {
/// Returns the block ID that triggered this passive suggestion
/// iff the trigger type was [Self::ShellCommandCompleted].
pub fn block_id(&self) -> Option<BlockId> {
match self {
Self::ShellCommandCompleted(c) => Some(c.executed_shell_command.id.clone()),
_ => None,
}
}
/// Returns the exchange ID that triggered this passive suggestion
/// iff the trigger type was [Self::AgentResponseCompleted].
pub fn exchange_id(&self) -> Option<AIAgentExchangeId> {
match self {
Self::AgentResponseCompleted { exchange_id } => Some(*exchange_id),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum UserQueryMode {
#[default]
Normal,
Plan,
Orchestrate,
}
pub fn extract_user_query_mode(query: String) -> (String, UserQueryMode) {
if let Some(query) = commands::strip_command_prefix(&query, commands::PLAN_NAME) {
(query, UserQueryMode::Plan)
} else if let Some(query) = commands::strip_command_prefix(&query, commands::ORCHESTRATE_NAME) {
(query, UserQueryMode::Orchestrate)
} else {
(query, UserQueryMode::Normal)
}
}
/// Reconstructs the display form of a user query that has been stripped via
/// [`extract_user_query_mode`], by re-prepending the slash-command prefix
/// associated with [`UserQueryMode`].
///
/// This is the inverse of [`extract_user_query_mode`] and the canonical way
/// for UI to render a stored `(mode, query)` pair so the displayed prompt
/// always matches what the user originally submitted.
pub fn display_user_query_with_mode(mode: UserQueryMode, query: &str) -> String {
match mode {
UserQueryMode::Normal => query.to_owned(),
UserQueryMode::Plan => format!("{} {query}", commands::PLAN.name),
UserQueryMode::Orchestrate => format!("{} {query}", commands::ORCHESTRATE.name),
}
}
// TODO(zachbai): Refactor this to consolidate with `LongRunningCommandSnapshot` and `Snapshot`
// variants of `ReadShellCommandOutputResult` and `WriteToLongRunningShellCommandResult`.
#[derive(Clone, Debug, PartialEq)]
pub struct RunningCommand {
pub command: String,
pub block_id: BlockId,
pub grid_contents: String,
pub cursor: String,
pub requested_command_id: Option<AIAgentActionId>,
pub is_alt_screen_active: bool,
}
/// A single search/replace diff entry for a passive code suggestion.
#[derive(Clone, Debug, PartialEq)]
pub struct PassiveCodeDiffEntry {
pub file_path: String,
pub search: String,
pub replace: String,
}
/// The outcome of a passive suggestion that the user interacted with.
#[derive(Clone, Debug, PartialEq)]
pub enum PassiveSuggestionResultType {
Prompt {
prompt: String,
},
CodeDiff {
diffs: Vec<PassiveCodeDiffEntry>,
summary: String,
accepted: bool,
},
}
#[derive(Clone, Debug, PartialEq)]
pub enum AIAgentInput {
/// A user's query to the AI.
UserQuery {
query: String,
context: Arc<[AIAgentContext]>,
static_query_type: Option<StaticQueryType>,
referenced_attachments: HashMap<String, AIAgentAttachment>,
user_query_mode: UserQueryMode,
running_command: Option<RunningCommand>,
intended_agent: Option<AgentType>,
},
AutoCodeDiffQuery {
query: String,
context: Arc<[AIAgentContext]>,
},
ResumeConversation {
context: Arc<[AIAgentContext]>,
},
InitProjectRules {
context: Arc<[AIAgentContext]>,
display_query: Option<String>,
},
CreateEnvironment {
context: Arc<[AIAgentContext]>,
display_query: Option<String>,
repo_paths: Vec<String>,
},
TriggerPassiveSuggestion {
context: Arc<[AIAgentContext]>,
attachments: Vec<AIAgentAttachment>,
trigger: PassiveSuggestionTrigger,
},
CreateNewProject {
query: String,
context: Arc<[AIAgentContext]>,
},
CloneRepository {
clone_repo_url: CloneRepositoryURL,
context: Arc<[AIAgentContext]>,
},
/// A batch of inline code review comments for the agent to address.
CodeReview {
context: Arc<[AIAgentContext]>,
review_comments: AgentReviewCommentBatch,
},
FetchReviewComments {
repo_path: String,
context: Arc<[AIAgentContext]>,
},
SummarizeConversation {
prompt: Option<String>,
context: Arc<[AIAgentContext]>,
},
/// Invoke a skill. The skill content is passed as instructions to the agent.
InvokeSkill {
context: Arc<[AIAgentContext]>,
skill: ParsedSkill,
user_query: Option<InvokeSkillUserQuery>,
},
/// Start a conversation using the prompt stored for an ambient agent run.
/// The server resolves the prompt from the run's latest known prompt.
/// If runtime_skill is provided, the server will create an InvokeSkill message. The skill
/// instructions are sent to the LLM but not displayed in the UI query bubble.
StartFromAmbientRunPrompt {
ambient_run_id: String,
context: Arc<[AIAgentContext]>,
/// Optional skill to use as base context (content hidden from user in UI).
runtime_skill: Option<ai::skills::ParsedSkill>,
/// Optional directory path where the client downloaded task attachments.
/// Passed to the server so it can construct correct file paths for the LLM.
attachments_dir: Option<String>,
},
/// The result of an `AIAgentAction`, relayed back to the LLM for it to continue answering a
/// user query.
ActionResult {
result: AIAgentActionResult,
context: Arc<[AIAgentContext]>,
},
/// Messages received from other agent conversations via the message bus.
MessagesReceivedFromAgents {
messages: Vec<ReceivedMessageInput>,
},
/// Events received from other agent conversations.
EventsFromAgents {
events: Vec<AgentEvent>,
},
/// The result of a passive suggestion that should be
/// handled in the active conversation.
PassiveSuggestionResult {
trigger: Option<PassiveSuggestionTrigger>,
suggestion: PassiveSuggestionResultType,
context: Arc<[AIAgentContext]>,
},
/// Piggybacked orchestration config update from the plan card.
/// Sent on the next outbound request after the user edits the
/// config block or toggles approval.
OrchestrationConfigUpdate {
plan_id: String,
config: OrchestrationConfig,
status: OrchestrationConfigStatus,
},
}
/// Data for a single message received by an agent from another agent.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ReceivedMessageInput {
pub message_id: String,
pub sender_agent_id: String,
pub addresses: Vec<String>,
pub subject: String,
pub message_body: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct AgentReviewCommentBatch {
/// The review comments in this batch. Uses `code_review::comments::ReviewComment`
/// because it contains full target information needed for API conversion and UI rendering.
pub comments: Vec<CodeReviewComment>,
/// All diff hunks that have comments in this batch attached to them, grouped by file name.
pub diff_set: HashMap<String, Vec<DiffSetHunk>>,
}
impl AgentReviewCommentBatch {
pub fn review_comments(&self) -> ReviewCommentBatch {
ReviewCommentBatch::from_comments(self.comments.clone())
}
}
/// A simple struct that holds a URL to be used for the CloneRepository input.
///
/// Needed because we want to display a query that's more than just the URL to the user
/// and the code is setup such that the query string must be preallocated.
#[derive(Clone, Debug, PartialEq)]
pub struct CloneRepositoryURL {
/// The query displayed to the user when a user clones a repository.
query: String,
/// The URL of the repository to clone.
url: String,
}
impl CloneRepositoryURL {
pub fn new(url: String) -> Self {
Self {
query: format!("Clone {url}"),
url,
}
}
pub fn into_url(self) -> String {
self.url
}
}
impl Display for AIAgentInput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UserQuery { .. } => {
write!(f, "UserQuery: {}", self.display_query().unwrap_or_default())
}
Self::AutoCodeDiffQuery { query, .. } => {
write!(f, "AutoCodeDiffQuery: {query}")
}
Self::ActionResult { result, .. } => write!(f, "ActionResult: {result}"),
Self::ResumeConversation { .. } => write!(f, "ResumeConversation"),
Self::InitProjectRules { .. } => write!(f, "InitProjectRules"),
Self::CreateEnvironment { .. } => write!(f, "CreateEnvironment"),
Self::TriggerPassiveSuggestion { .. } => write!(f, "TriggerSuggestPrompt"),
Self::CreateNewProject { .. } => write!(f, "CreateNewProject"),
Self::CloneRepository { .. } => write!(f, "CloneRepository"),
Self::CodeReview { .. } => write!(f, "CodeReview"),
Self::FetchReviewComments { .. } => write!(f, "FetchReviewComments"),
Self::SummarizeConversation { .. } => write!(f, "SummarizeConversation"),
Self::InvokeSkill {
skill, user_query, ..
} => {
if let Some(user_query) = user_query {
if user_query.query.is_empty() {
write!(f, "InvokeSkill: {}", skill.name)
} else {
write!(f, "InvokeSkill: {} {}", skill.name, user_query.query)
}
} else {
write!(f, "InvokeSkill: {}", skill.name)
}
}
Self::StartFromAmbientRunPrompt { .. } => write!(f, "StartFromAmbientRunPrompt"),
Self::MessagesReceivedFromAgents { messages } => {
write!(f, "MessagesReceivedFromAgents({} messages)", messages.len())
}
Self::EventsFromAgents { events } => {
write!(f, "EventsFromAgents({} events)", events.len())
}
Self::PassiveSuggestionResult { .. } => write!(f, "PassiveSuggestionResult"),
Self::OrchestrationConfigUpdate { .. } => write!(f, "OrchestrationConfigUpdate"),
}
}
}
impl AIAgentInput {
/// Display text for any input that surfaces a prompt-like query in the UI
/// (typed queries, slash commands, skill invocations, etc.). Unlike
/// [`Self::is_user_query`], which strictly matches the `UserQuery` variant,
/// this returns `Some` for several input variants.
pub fn display_query(&self) -> Option<String> {
match self {
Self::UserQuery {
query,
user_query_mode,
..
} => Some(display_user_query_with_mode(*user_query_mode, query)),
Self::CreateNewProject { query, .. } => Some(query.clone()),
Self::CloneRepository {
clone_repo_url: url,
..
} => Some(url.query.clone()),
Self::InitProjectRules { display_query, .. }
| Self::CreateEnvironment { display_query, .. } => display_query.clone(),
Self::CodeReview { .. } => Some("Address these comments".to_string()),
Self::FetchReviewComments { .. } => Some(commands::PR_COMMENTS.name.to_string()),
Self::InvokeSkill {
skill, user_query, ..
} => {
if let Some(user_query) = user_query {
if user_query.query.is_empty() {
Some(format!("/{}", skill.name))
} else {
Some(format!("/{} {}", skill.name, user_query.query))
}
} else {
Some(format!("/{}", skill.name))
}
}
Self::ActionResult {
result:
AIAgentActionResult {
result:
AIAgentActionResultType::SuggestPrompt(SuggestPromptResult::Accepted {
query,
}),
..
},
..
} => Some(query.clone()),
Self::PassiveSuggestionResult {
suggestion: PassiveSuggestionResultType::Prompt { prompt },
..
} => Some(prompt.clone()),
Self::AutoCodeDiffQuery { .. }
| Self::ActionResult { .. }
| Self::TriggerPassiveSuggestion { .. }
| Self::ResumeConversation { .. }
| Self::SummarizeConversation { .. }
| Self::StartFromAmbientRunPrompt { .. }
| Self::MessagesReceivedFromAgents { .. }
| Self::EventsFromAgents { .. }
| Self::PassiveSuggestionResult { .. }
| Self::OrchestrationConfigUpdate { .. } => None,
}
}
/// Returns the user query text as it should be displayed in the UI.
/// This includes the "/agent" prefix for the initial conversation query.
pub fn display_user_query(
&self,
initial_conversation_query: Option<&String>,
) -> Option<String> {
let mut query = self.display_query()?;
if self
.user_query_mode()
.is_none_or(|mode| matches!(mode, UserQueryMode::Normal))
&& Some(&query) == initial_conversation_query
&& !self.has_custom_display_query()
{
query = format!("/agent {query}");
}
Some(query)
}
pub fn user_query_mode(&self) -> Option<UserQueryMode> {
match self {
AIAgentInput::UserQuery {
user_query_mode, ..
} => Some(*user_query_mode),
_ => None,
}
}
pub fn action_result(&self) -> Option<&AIAgentActionResult> {
match self {
Self::ActionResult { result, .. } => Some(result),
_ => None,
}
}
pub fn auto_code_diff_query(&self) -> Option<&str> {
let Self::AutoCodeDiffQuery { query, .. } = self else {
return None;
};
Some(query.as_str())
}
pub fn passive_suggestion_trigger(&self) -> Option<&PassiveSuggestionTrigger> {
match self {
AIAgentInput::TriggerPassiveSuggestion { trigger, .. } => Some(trigger),
_ => None,
}
}
pub fn is_passive_suggestion_trigger(&self) -> bool {
matches!(self, AIAgentInput::TriggerPassiveSuggestion { .. })
}
pub fn is_user_query(&self) -> bool {
matches!(self, AIAgentInput::UserQuery { .. })
}
pub fn prompt_suggestion_result(&self) -> Option<&String> {
if let Some(AIAgentActionResult {
result: AIAgentActionResultType::SuggestPrompt(SuggestPromptResult::Accepted { query }),
..
}) = self.action_result()
{
Some(query)
} else {
None
}
}
pub fn is_passive_request(&self) -> bool {
matches!(
self,
AIAgentInput::AutoCodeDiffQuery { .. } | AIAgentInput::TriggerPassiveSuggestion { .. }
)
}
pub fn context(&self) -> Option<&[AIAgentContext]> {
match self {
Self::UserQuery { context, .. }
| Self::ActionResult { context, .. }
| Self::AutoCodeDiffQuery { context, .. }
| Self::ResumeConversation { context, .. }
| Self::InitProjectRules { context, .. }
| Self::CreateEnvironment { context, .. }
| Self::TriggerPassiveSuggestion { context, .. }
| Self::CreateNewProject { context, .. }
| Self::CloneRepository { context, .. }
| Self::CodeReview { context, .. }
| Self::FetchReviewComments { context, .. }
| Self::InvokeSkill { context, .. }
| Self::StartFromAmbientRunPrompt { context, .. }
| Self::PassiveSuggestionResult { context, .. } => Some(context),
Self::SummarizeConversation { context, .. } => Some(context),
Self::MessagesReceivedFromAgents { .. }
| Self::EventsFromAgents { .. }
| Self::OrchestrationConfigUpdate { .. } => None,
}
}
/// Returns all of the attachments for the given input,
/// converting any blocks blocks attached in the context into the correct type of attachment.
pub fn attachments(&self) -> Option<Vec<AIAgentAttachment>> {
match self {
Self::UserQuery {
referenced_attachments,
..
} => {
let res: Vec<AIAgentAttachment> =
referenced_attachments.values().cloned().collect();
Some(res)
}
Self::TriggerPassiveSuggestion { attachments, .. } => Some(attachments.clone()),
Self::ActionResult { .. }
| Self::AutoCodeDiffQuery { .. }
| Self::ResumeConversation { .. }
| Self::InitProjectRules { .. }
| Self::CreateEnvironment { .. }
| Self::CreateNewProject { .. }
| Self::CloneRepository { .. }
| Self::CodeReview { .. }
| Self::FetchReviewComments { .. }
| Self::SummarizeConversation { .. }
| Self::InvokeSkill { .. }
| Self::StartFromAmbientRunPrompt { .. }
| Self::MessagesReceivedFromAgents { .. }
| Self::EventsFromAgents { .. }
| Self::PassiveSuggestionResult { .. }
| Self::OrchestrationConfigUpdate { .. } => None,
}
}
pub fn is_auto_code_diff_query(&self) -> bool {
matches!(self, AIAgentInput::AutoCodeDiffQuery { .. })
}
/// Returns true if this input type provides its own display query that should be preserved
/// without prepending "/agent".
pub fn has_custom_display_query(&self) -> bool {
matches!(
self,
AIAgentInput::InitProjectRules { .. }
| AIAgentInput::CreateEnvironment { .. }
| AIAgentInput::FetchReviewComments { .. }
| AIAgentInput::InvokeSkill { .. }
)
}
}
/// A globally unique ID for an `AIAgentExchange`.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub struct AIAgentExchangeId(Uuid);
impl Display for AIAgentExchangeId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl AIAgentExchangeId {
pub fn new() -> Self {
Self(Uuid::new_v4())
}
}
impl Default for AIAgentExchangeId {
fn default() -> Self {
Self::new()
}
}
impl TryFrom<String> for AIAgentExchangeId {
type Error = anyhow::Error;
fn try_from(value: String) -> Result<Self, Self::Error> {
Ok(Self(Uuid::try_parse(&value)?))
}
}
/// Represents a single user input/AI output pair. Each exchange corresponds to a request to an AI
/// backend model and its response.
#[derive(Debug, Clone)]
pub struct AIAgentExchange {
/// Unique ID for the exchange.
pub id: AIAgentExchangeId,
/// The input originating from the user.
pub input: Vec<AIAgentInput>,
/// The status of the output stream. Updated during the course of the exchange.
pub output_status: AIAgentOutputStatus,
/// The ids for all messages added to the task in this exchange.
pub added_message_ids: HashSet<MessageId>,
/// The time the input was sent.
pub start_time: DateTime<Local>,
/// The time the exchange's output finished streaming, if known.
pub finish_time: Option<DateTime<Local>>,
/// Time to first token for this exchange in milliseconds.
pub time_to_first_token_ms: Option<i64>,
// TODO(CORE-3546): add shell launch data when the input was submitted.
/// The current working directory when the input was submitted.
pub working_directory: Option<String>,
/// The model to which the request was sent.
pub model_id: LLMId,
// The request count for the exchange.
pub request_cost: Option<RequestCost>,
/// The coding model to which the request was sent.
pub coding_model_id: LLMId,
/// The CLI agent model to which the request was sent.
pub cli_agent_model_id: LLMId,
/// The computer use model to which the request was sent.
pub computer_use_model_id: LLMId,
/// The participant who initiated this exchange (for shared sessions)
/// For non-shared sessions, we just leave this as None.
pub response_initiator: Option<ParticipantId>,
}
impl AIAgentExchange {
/// Format the user input part of this exchange for copying to clipboard.
/// We don't copy tool call results.
pub fn format_input_for_copy(&self) -> String {
let user_queries: Vec<String> = self
.input
.iter()
.filter_map(|input| input.display_query())
.collect();
user_queries.join("\n")
}
/// Format the output part of this exchange for copying to clipboard.
pub fn format_output_for_copy(
&self,
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
) -> String {
match self.output_status.output() {
Some(output) => output.get().format_for_copy(action_model),
None => String::new(),
}
}
/// Format the entire exchange (both input and output) for copying to clipboard.
/// Always adds USER: and AGENT: labels.
/// If `skip_agent_label` is true, skips the AGENT: label (for consecutive agent outputs).
pub fn format_for_copy(
&self,
action_model: Option<&crate::ai::blocklist::BlocklistAIActionModel>,
) -> String {
let input_text = self.format_input_for_copy();
let output_text = self.format_output_for_copy(action_model);
let has_user_input = !input_text.is_empty();
let has_agent_output = !output_text.is_empty();
if !has_user_input && !has_agent_output {
return String::new();
}
let mut parts = Vec::new();
if has_user_input {
parts.push(format!("USER:\n{input_text}"));
}
if has_agent_output {
if has_user_input {
parts.push(format!("AGENT:\n{output_text}"));
} else {
parts.push(output_text);
}
}
parts.join("\n\n")
}
pub fn has_user_query(&self) -> bool {
self.input
.iter()
.any(|input| input.display_query().is_some())
}
pub fn has_accepted_file_edit(&self) -> bool {
self.input.iter().any(|input| {
matches!(
input.action_result(),
Some(AIAgentActionResult {
result: AIAgentActionResultType::RequestFileEdits(
RequestFileEditsResult::Success { .. }
),
..
})
)
})
}
pub fn has_passive_request(&self) -> bool {
self.input.iter().any(|input| input.is_passive_request())
}
pub fn has_passive_code_diff(&self) -> bool {
self.input
.iter()
.any(|input| input.auto_code_diff_query().is_some())
|| (FeatureFlag::PromptSuggestionsViaMAA.is_enabled()
&& self.has_passive_request()
&& self.output_status.output().is_some_and(|output| {
output.get().actions().any(|action| {
matches!(action.action, AIAgentActionType::RequestFileEdits { .. })
})
}))
}
pub fn passive_suggestion_trigger(&self) -> Option<&PassiveSuggestionTrigger> {
self.input
.iter()
.find_map(|input| input.passive_suggestion_trigger())
}
pub fn duration(&self) -> Option<TimeDelta> {
self.finish_time
.map(|finish_time| finish_time.signed_duration_since(self.start_time))
}
}
/// Request-level metadata propagated to the `AIAgentApi` that may be used for logging.
#[derive(Clone, Debug, Serialize)]
pub struct RequestMetadata {
/// `true` if the user query was autodetected as AI input.
///
/// This only applies to `AIAgentInput::UserQuery`.
pub is_autodetected_user_query: bool,
/// The entrypoint (onboarding, prompt suggestion, etc.) of the AI conversation.
pub entrypoint: EntrypointType,
/// Whether this request is an automatic resume triggered by a previous error.
pub is_auto_resume_after_error: bool,
}
pub use cloud_object_models::SuggestedLoggingId;
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct SuggestedRule {
pub name: String,
pub content: String,
pub logging_id: SuggestedLoggingId,
}
#[derive(Debug, Default, Clone, Eq, PartialEq)]
pub struct Suggestions {
pub rules: Vec<SuggestedRule>,
pub agent_mode_workflows: Vec<SuggestedAgentModeWorkflow>,
}
impl Suggestions {
/// Extend the suggestions, ensuring that we don't add duplicates by checking the logging_id
pub fn extend(&mut self, other: &Suggestions) {
let existing_logging_ids: Vec<_> = self
.rules
.iter()
.map(|rule| rule.logging_id.clone())
.collect();
let new_rules = other
.rules
.iter()
.filter(|rule| !existing_logging_ids.contains(&rule.logging_id))
.cloned();
self.rules.extend(new_rules);
// Add new agent mode workflows, ensuring no duplicates by logging_id
let existing_workflow_ids: Vec<_> = self
.agent_mode_workflows
.iter()
.map(|workflow| workflow.logging_id.clone())
.collect();
let new_workflows = other
.agent_mode_workflows
.iter()
.filter(|workflow| !existing_workflow_ids.contains(&workflow.logging_id))
.cloned();
self.agent_mode_workflows.extend(new_workflows);
}
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;