first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
@@ -1,4 +1,5 @@
use std::{collections::HashMap, path::Path, sync::Arc};
use std::collections::HashMap;
use std::sync::Arc;
use ai::index::full_source_code_embedding::manager::CodebaseIndexManager;
use chrono::Local;
@@ -6,33 +7,28 @@ use galaxy_core::features::FeatureFlag;
use galaxyui::{AppContext, SingletonEntity};
use lazy_static::lazy_static;
use regex::Regex;
use crate::{
ai::{
agent::{
conversation::AIConversationId, AIAgentAttachment, AIAgentContext,
DocumentContentAttachmentSource, DriveObjectPayload,
},
block_context::BlockContext,
blocklist::BlocklistAIContextModel,
document::ai_document_model::{AIDocumentId, AIDocumentModel},
facts::CloudAIFactModel,
skills::list_skills_if_changed,
},
cloud_object::{
model::{
generic_string_model::{CloudStringObject, GenericStringObjectId},
persistence::CloudModel,
},
GenericCloudObject, GenericStringObjectFormat, JsonObjectType, ObjectType,
},
terminal::{
model::{block::BlockId, session::active_session::ActiveSession},
TerminalView,
},
};
use galaxy_graphql::generic_string_object::GenericStringObjectFormat as GraphQLFormat;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{
AIAgentAttachment, AIAgentContext, DocumentContentAttachmentSource, DriveObjectPayload,
};
use crate::ai::block_context::BlockContext;
use crate::ai::blocklist::{BlocklistAIContextModel, SessionContext};
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel};
use crate::ai::facts::CloudAIFactModel;
use crate::ai::skills::list_skills_if_changed;
use crate::cloud_object::model::generic_string_model::{CloudStringObject, GenericStringObjectId};
use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::{
GenericCloudObject, GenericStringObjectFormat, JsonObjectType, ObjectType,
};
#[cfg(not(target_family = "wasm"))]
use crate::remote_server::codebase_index_model::RemoteCodebaseIndexModel;
use crate::terminal::model::block::BlockId;
use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::TerminalView;
lazy_static! {
// Regex to match <block:[block_id]> patterns
pub static ref BLOCK_CONTEXT_ATTACHMENT_REGEX: Regex = Regex::new(r"<block:([^>]+)>")
@@ -56,7 +52,12 @@ pub(super) fn input_context_for_request(
additional_context: Vec<AIAgentContext>,
app: &AppContext,
) -> Arc<[AIAgentContext]> {
let mut context = context_model.pending_context(app, is_user_query);
let current_working_directory_location = active_session.current_working_directory_location(app);
let mut context = context_model.pending_context(
app,
is_user_query,
current_working_directory_location.as_ref(),
);
context.push(AIAgentContext::CurrentTime {
current_time: Local::now(),
@@ -69,29 +70,19 @@ pub(super) fn input_context_for_request(
if FeatureFlag::FullSourceCodeEmbedding.is_enabled()
&& FeatureFlag::CrossRepoContext.is_enabled()
{
for (codebase_path, status) in
CodebaseIndexManager::as_ref(app).get_codebase_index_statuses(app)
{
// TODO(daniel): We should figure out a mechanism for handling stale codebases.
if status.has_synced_version() {
// For now, we pass the name of the directory as the name of the
// codebase.
let codebase_name = codebase_path
.file_name()
.map(|name| name.to_string_lossy())
.unwrap_or_default();
context.push(AIAgentContext::Codebase {
name: codebase_name.into(),
path: codebase_path.to_string_lossy().into(),
})
}
let session_context = SessionContext::from_session(active_session, app);
if session_context.is_remote() {
add_remote_codebase_context(&mut context, &session_context, app);
} else {
add_local_codebase_context(&mut context, app);
}
}
if FeatureFlag::ListSkills.is_enabled() {
let path_origin = SessionContext::from_session(active_session, app).skill_path_origin();
let skills = list_skills_if_changed(
active_session.current_working_directory().map(Path::new),
current_working_directory_location.as_ref(),
&path_origin,
conversation_id,
app,
);
@@ -106,6 +97,52 @@ pub(super) fn input_context_for_request(
context.into()
}
fn add_local_codebase_context(context: &mut Vec<AIAgentContext>, app: &AppContext) {
for (codebase_path, status) in
CodebaseIndexManager::as_ref(app).get_codebase_index_statuses(app)
{
// TODO(daniel): We should figure out a mechanism for handling stale codebases.
if status.has_synced_version() {
// For now, we pass the name of the directory as the name of the
// codebase.
let codebase_name = codebase_path
.file_name()
.map(|name| name.to_string_lossy())
.unwrap_or_default();
context.push(AIAgentContext::Codebase {
name: codebase_name.into(),
path: codebase_path.to_string_lossy().into(),
})
}
}
}
#[cfg(not(target_family = "wasm"))]
fn add_remote_codebase_context(
context: &mut Vec<AIAgentContext>,
session_context: &SessionContext,
app: &AppContext,
) {
let Some(host_id) = session_context.host_id() else {
return;
};
for codebase in RemoteCodebaseIndexModel::as_ref(app).codebases_for_agent_context(host_id) {
context.push(AIAgentContext::Codebase {
name: codebase.name,
path: codebase.path,
});
}
}
#[cfg(target_family = "wasm")]
fn add_remote_codebase_context(
_context: &mut Vec<AIAgentContext>,
_session_context: &SessionContext,
_app: &AppContext,
) {
}
/// Parses context reference strings like <block:123> from the user query and returns
/// a map of reference strings to AIAgentAttachment objects.
///
@@ -211,29 +248,6 @@ pub(super) fn parse_context_attachments(
}
}
// Add pending file attachments as FilePathReference.
// Duplicate basenames get a (1), (2), ... suffix to avoid collisions,
// matching the pattern in build_file_attachment_map.
for file in context_model.pending_files().iter() {
let attachment = AIAgentAttachment::FilePathReference {
file_id: uuid::Uuid::new_v4().to_string(),
file_name: file.file_name.clone(),
file_path: file.file_path.to_string_lossy().to_string(),
};
let mut key = file.file_name.clone();
if referenced_attachments.contains_key(&key) {
let mut suffix = 1;
loop {
key = format!("{} ({suffix})", file.file_name);
if !referenced_attachments.contains_key(&key) {
break;
}
suffix += 1;
}
}
referenced_attachments.insert(key, attachment);
}
// Add pending AI document as attachment if present
if let Some(document_id) = context_model.pending_document_id() {
if let Some(content) = AIDocumentModel::as_ref(ctx).get_document_content(&document_id, ctx)
@@ -3,15 +3,11 @@ use std::collections::HashMap;
use galaxyui::{AppContext, ModelContext, ModelHandle, SingletonEntity};
use itertools::Itertools;
use crate::{
ai::agent::{conversation::AIConversationId, CancellationReason},
BlocklistAIHistoryModel,
};
use super::{
response_stream::{ResponseStream, ResponseStreamId},
BlocklistAIController,
};
use super::response_stream::{ResponseStream, ResponseStreamId};
use super::BlocklistAIController;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::CancellationReason;
use crate::BlocklistAIHistoryModel;
pub(super) struct PendingResponseStreams {
streams: HashMap<ResponseStreamId, ModelHandle<ResponseStream>>,
@@ -38,6 +34,23 @@ impl PendingResponseStreams {
.any(|stream_id| conversation.is_processing_response_stream(stream_id))
}
/// Returns the IDs of all in-flight streams owned by the given conversation.
pub fn stream_ids_for_conversation(
&self,
conversation_id: AIConversationId,
app: &AppContext,
) -> Vec<ResponseStreamId> {
let history_model = BlocklistAIHistoryModel::as_ref(app);
let Some(conversation) = history_model.conversation(&conversation_id) else {
return Vec::new();
};
self.streams
.keys()
.filter(|stream_id| conversation.is_processing_response_stream(stream_id))
.cloned()
.collect()
}
pub fn register_new_stream(
&mut self,
stream_id: ResponseStreamId,
@@ -99,6 +112,11 @@ impl PendingResponseStreams {
false
} else {
for response_stream in streams_to_cancel.into_iter() {
log::info!(
"Canceling active stream for conversation_id={conversation_id:?}, \
reason={reason}, backtrace=\n{}",
std::backtrace::Backtrace::force_capture()
);
response_stream.update(ctx, |stream, ctx| {
stream.cancel(reason, conversation_id, ctx)
});
+305 -142
View File
@@ -1,4 +1,6 @@
use std::{cell::RefCell, rc::Rc};
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use anyhow::anyhow;
use chrono::{DateTime, Local, TimeDelta};
@@ -7,22 +9,54 @@ use galaxyui::{Entity, ModelContext, SingletonEntity};
use uuid::Uuid;
use warp_multi_agent_api::response_event;
use crate::{
ai::{
agent::{
api::{self, generate_multi_agent_output, ConvertToAPITypeError},
conversation::AIConversationId,
AIIdentifiers, CancellationReason,
},
bedrock::client::BedrockClientConfig,
openai::client::OpenAIClientConfig,
provider::ProviderConfig,
},
network::NetworkStatus,
report_error, send_telemetry_from_ctx,
settings::ai::AISettings,
};
use settings::Setting;
use crate::ai::agent::api::{self, generate_multi_agent_output, ConvertToAPITypeError};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{AIIdentifiers, CancellationReason};
use crate::network::NetworkStatus;
use crate::server::server_api::{AIApiError, ServerApiProvider};
use crate::{report_error, send_telemetry_from_ctx};
/// Maximum number of times a single MAA request is re-sent before the failure is
/// surfaced.
const MAX_RETRIES: usize = 3;
/// What to do about a failed or truncated MAA response attempt.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RecoveryAction {
/// Re-send the same request immediately.
RetryNow,
/// Re-send the same request once connectivity returns.
RetryWhenOnline,
/// Resume the conversation with a fresh request after the stream completes.
Resume,
/// Surface the error; the conversation ends in error.
Fail,
}
/// Decides how to recover from a failed response-stream attempt.
///
/// Before any client actions have been received, the request can be re-sent verbatim
/// (immediately, or once connectivity returns). After actions have streamed,
/// re-sending is unsafe, so recovery uses a fresh `ResumeConversation` request.
fn recovery_action(
has_received_client_actions: bool,
is_recoverable: bool,
has_retry_budget: bool,
can_attempt_resume_on_error: bool,
is_online: bool,
) -> RecoveryAction {
if !has_received_client_actions && is_recoverable && has_retry_budget {
if is_online {
RecoveryAction::RetryNow
} else {
RecoveryAction::RetryWhenOnline
}
} else if has_received_client_actions && is_recoverable && can_attempt_resume_on_error {
RecoveryAction::Resume
} else {
RecoveryAction::Fail
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ResponseStreamId(String);
@@ -70,10 +104,23 @@ pub struct ResponseStream {
/// Whether we should attempt to resume the conversation after the stream finishes.
///
/// This is set when we receive a retryable error after client actions have been received
/// and `can_attempt_resume_on_error` is true.
/// This is set when a transient network/server failure occurs after client actions
/// have been received (so an in-request retry is unsafe) and
/// `can_attempt_resume_on_error` is true.
should_resume_conversation_after_stream_finished: bool,
/// Whether a `StreamFinished` event was received for the current request. A
/// stream that completes without one was truncated in transit.
stream_finished_received: bool,
/// Whether a terminal error event has already been emitted for the current
/// request, so stream completion doesn't synthesize a second failure for it.
error_event_emitted: bool,
/// Whether a retry is parked waiting for connectivity. While set, completion of
/// the failed attempt's underlying stream is ignored.
deferred_retry_pending: bool,
/// Unique, internal id for the current request.
///
/// This ensures that the model never emits events for a request that was already cancelled (or
@@ -85,65 +132,37 @@ pub struct ResponseStream {
}
impl ResponseStream {
fn resolve_provider_config(model_id: &str, ctx: &ModelContext<Self>) -> ProviderConfig {
let settings = AISettings::as_ref(ctx);
// Check if OpenAI/LiteLLM provider is enabled
if *settings.openai_enabled.value() {
// First, check if this specific model has a per-provider routing entry
// (from the multi-provider `ai.providers[]` config or legacy `ai.openai.models`).
use crate::ai::llms::LLMPreferences;
let prefs = LLMPreferences::as_ref(ctx);
if let Some(config) = prefs.openai_client_config_for_model(model_id) {
return ProviderConfig::OpenAI(OpenAIClientConfig {
base_url: config.base_url.clone(),
api_key: config.api_key.clone(),
model: Some(model_id.to_string()),
});
}
// Fall back to the legacy single-provider config
let base_url = settings.openai_base_url.value().clone();
let api_key = {
let key = settings.openai_api_key.value().clone();
if key.is_empty() {
None
} else {
Some(key)
}
};
let model = {
let m = settings.openai_model.value().clone();
if m.is_empty() {
Some(model_id.to_string())
} else {
Some(m)
}
};
return ProviderConfig::OpenAI(OpenAIClientConfig {
base_url,
api_key,
model,
});
/// Emits a synthetic successful response event through the normal controller subscription.
#[cfg(test)]
pub fn emit_response_event_for_test(
&mut self,
event: warp_multi_agent_api::ResponseEvent,
ctx: &mut ModelContext<Self>,
) {
ctx.emit(ResponseStreamEvent::ReceivedEvent(Consumable::new(Ok(
event,
))));
}
#[cfg(test)]
pub fn new_for_test(id: ResponseStreamId) -> Self {
let (cancellation_tx, _rx) = oneshot::channel();
Self {
id,
params: api::RequestParams::new_for_test(),
retry_count: 0,
start_time: Local::now(),
time_to_latest_event: TimeDelta::seconds(0),
cancellation_tx: Some(cancellation_tx),
original_error: None,
has_received_client_actions: false,
ai_identifiers: AIIdentifiers::default(),
can_attempt_resume_on_error: false,
should_resume_conversation_after_stream_finished: false,
stream_finished_received: false,
error_event_emitted: false,
deferred_retry_pending: false,
current_request_id: Some(Uuid::new_v4()),
}
// Fall back to Bedrock
if *settings.bedrock_enabled.value() {
let auth_method = *settings.bedrock_auth_method.value();
return ProviderConfig::Bedrock(
BedrockClientConfig {
auth_method,
profile: settings.bedrock_profile.value().clone(),
region: settings.bedrock_region.value().clone(),
access_key_id: settings.bedrock_access_key_id.value().clone(),
secret_access_key: settings.bedrock_secret_access_key.value().clone(),
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
}
.with_external_fallbacks(),
);
}
ProviderConfig::None
}
pub fn new(
@@ -178,6 +197,9 @@ impl ResponseStream {
ai_identifiers,
can_attempt_resume_on_error,
should_resume_conversation_after_stream_finished: false,
stream_finished_received: false,
error_event_emitted: false,
deferred_retry_pending: false,
current_request_id: Some(request_id),
}
}
@@ -222,7 +244,11 @@ impl ResponseStream {
fn retry(&mut self, ctx: &mut ModelContext<Self>) {
self.retry_count += 1;
self.has_received_client_actions = false; // Reset for the new attempt
// Reset per-attempt state for the new attempt.
self.has_received_client_actions = false;
self.stream_finished_received = false;
self.error_event_emitted = false;
self.deferred_retry_pending = false;
let (cancellation_tx, cancellation_rx) = oneshot::channel();
if let Some(old_cancellation_tx) = self.cancellation_tx.take() {
@@ -285,6 +311,21 @@ impl ResponseStream {
}
Err(e) => {
log::error!("Failed to send request to multi-agent API: {e:?}");
if self.current_request_id.is_none_or(|id| id != request_id) {
return;
}
// A request-conversion failure is a deterministic client-side error and
// no stream was ever created: retrying would fail identically, and
// letting completion synthesize `UnexpectedEof` would misreport it as
// a transient network failure. Surface the original error and finish
// terminally. (HTTP send failures don't take this path — they arrive as
// in-stream error events.)
let error = Arc::new(AIApiError::Other(anyhow!(e)));
self.error_event_emitted = true;
self.report_request_failure(&error, NetworkStatus::as_ref(ctx).is_online());
ctx.emit(ResponseStreamEvent::ReceivedEvent(Consumable::new(Err(
error,
))));
self.on_response_stream_complete(request_id, ctx);
}
}
@@ -342,6 +383,7 @@ impl ResponseStream {
self.has_received_client_actions = true;
}
warp_multi_agent_api::response_event::Type::Finished(finished_event) => {
self.stream_finished_received = true;
// Emit retry success telemetry on successful completion
if matches!(
finished_event.reason,
@@ -373,72 +415,53 @@ impl ResponseStream {
self.original_error = Some(format!("{e:?}"));
}
// Only retry if:
// 1. We haven't received any client actions yet (this is the first event or only init events)
// 2. The error is retryable
// 3. We haven't exceeded max retries
// 4. We're online
const MAX_RETRIES: usize = 3;
let network_status = NetworkStatus::as_ref(ctx);
let is_online = network_status.is_online();
let is_retryable = e.is_retryable();
let should_retry = !self.has_received_client_actions
&& is_retryable
&& self.retry_count < MAX_RETRIES
&& is_online;
if should_retry {
log::warn!(
"MultiAgent request failed, retrying (attempt {}/{}) - Error: {e:?}",
self.retry_count + 1,
MAX_RETRIES
);
// Only emit error telemetry here if we're retrying.
// Final errors that aren't being retried are emitted elsewhere.
self.emit_retryable_agent_mode_error_telemetry(format!("{e:?}"), ctx);
self.retry(ctx);
// Don't emit the error event, we're retrying
// TODO: emit a separate event if controller needs to know about failures that are being retried
return;
}
// If we can't retry (because client actions were received) but the error is
// retryable and we're allowed to attempt a resume, signal that the controller
// should resume the conversation after the stream completes.
let should_attempt_resume = self.has_received_client_actions
&& is_retryable
&& self.can_attempt_resume_on_error;
if should_attempt_resume {
self.should_resume_conversation_after_stream_finished = true;
}
#[cfg(feature = "crash_reporting")]
sentry::with_scope(
|scope| {
scope.set_tag(
"has_received_client_actions",
self.has_received_client_actions,
let is_online = NetworkStatus::as_ref(ctx).is_online();
match recovery_action(
self.has_received_client_actions,
e.is_recoverable(),
self.retry_count < MAX_RETRIES,
self.can_attempt_resume_on_error,
is_online,
) {
RecoveryAction::RetryNow => {
log::warn!(
"MultiAgent request failed, retrying (attempt {}/{}) - Error: {e:?}",
self.retry_count + 1,
MAX_RETRIES
);
scope.set_tag("error", format!("{e:?}"));
scope.set_tag("is_retryable", e.is_retryable());
scope.set_tag("is_online", is_online);
scope.set_tag("retry_count", self.retry_count);
},
|| {
report_error!(anyhow!(e.clone()).context(format!(
"MultiAgent request failed after {} retries",
self.retry_count
)));
},
);
#[cfg(not(feature = "crash_reporting"))]
{
report_error!(anyhow!(e.clone()).context(format!(
"MultiAgent request failed after {} retries",
self.retry_count
)));
// Only emit error telemetry here if we're retrying.
// Final errors that aren't being retried are emitted elsewhere.
self.emit_retryable_agent_mode_error_telemetry(format!("{e:?}"), ctx);
self.retry(ctx);
// Don't emit the error event, we're retrying
return;
}
RecoveryAction::RetryWhenOnline => {
log::warn!(
"MultiAgent request failed while offline; retrying (attempt {}/{}) once connectivity returns - Error: {e:?}",
self.retry_count + 1,
MAX_RETRIES
);
self.emit_retryable_agent_mode_error_telemetry(format!("{e:?}"), ctx);
self.defer_retry_until_online(ctx);
return;
}
RecoveryAction::Resume => {
// Recoverable failure after client actions: we'll resume the
// conversation once the stream finishes rather than surface the
// error, so the UI suppresses the banner. Log it so the
// auto-recovery isn't completely silent.
log::warn!(
"MultiAgent request failed after client actions; resuming conversation after stream finishes - Error: {e:?}"
);
// The resume spawn itself waits for connectivity.
self.should_resume_conversation_after_stream_finished = true;
}
RecoveryAction::Fail => {}
}
self.error_event_emitted = true;
self.report_request_failure(e, is_online);
ctx.emit(ResponseStreamEvent::ReceivedEvent(Consumable::new(event)));
}
@@ -451,9 +474,135 @@ impl ResponseStream {
log::info!("[bedrock-debug] on_response_stream_complete: stale request_id, ignoring");
return;
}
// A retry is parked waiting for connectivity; the request is logically still
// active, so don't complete the stream for the failed attempt.
if self.deferred_retry_pending {
return;
}
// The server always sends a StreamFinished event before ending the response,
// but a transport cut between chunks surfaces as a clean EOF. Synthesize the
// failure and recover like any transient error.
if !self.stream_finished_received && !self.error_event_emitted {
log::warn!(
"generate_multi_agent_output stream ended without emitting StreamFinished event."
);
let unexpected_eof = Arc::new(AIApiError::UnexpectedEof);
let is_online = NetworkStatus::as_ref(ctx).is_online();
match recovery_action(
self.has_received_client_actions,
unexpected_eof.is_recoverable(),
self.retry_count < MAX_RETRIES,
self.can_attempt_resume_on_error,
is_online,
) {
RecoveryAction::RetryNow => {
log::warn!(
"MultiAgent request failed, retrying (attempt {}/{}) - Error: {unexpected_eof:?}",
self.retry_count + 1,
MAX_RETRIES
);
self.emit_retryable_agent_mode_error_telemetry(
format!("{unexpected_eof:?}"),
ctx,
);
self.retry(ctx);
return;
}
RecoveryAction::RetryWhenOnline => {
log::warn!(
"MultiAgent request failed while offline; retrying (attempt {}/{}) once connectivity returns - Error: {unexpected_eof:?}",
self.retry_count + 1,
MAX_RETRIES
);
self.emit_retryable_agent_mode_error_telemetry(
format!("{unexpected_eof:?}"),
ctx,
);
self.defer_retry_until_online(ctx);
return;
}
RecoveryAction::Resume => {
// Recoverable truncation after client actions: we'll resume the
// conversation once the stream finishes rather than surface the
// error, so the UI suppresses the banner. Log it so the
// auto-recovery isn't completely silent.
log::warn!(
"MultiAgent request truncated after client actions; resuming conversation after stream finishes - Error: {unexpected_eof:?}"
);
self.should_resume_conversation_after_stream_finished = true;
self.error_event_emitted = true;
self.report_request_failure(&unexpected_eof, is_online);
ctx.emit(ResponseStreamEvent::ReceivedEvent(Consumable::new(Err(
unexpected_eof,
))));
}
RecoveryAction::Fail => {
self.error_event_emitted = true;
self.report_request_failure(&unexpected_eof, is_online);
ctx.emit(ResponseStreamEvent::ReceivedEvent(Consumable::new(Err(
unexpected_eof,
))));
}
}
}
ctx.emit(ResponseStreamEvent::AfterStreamFinished { cancellation: None });
self.cancellation_tx = None;
}
/// Reports a non-retried request failure to crash reporting with classification
/// tags.
#[cfg_attr(not(feature = "crash_reporting"), expect(unused_variables))]
fn report_request_failure(&self, error: &Arc<AIApiError>, is_online: bool) {
#[cfg(feature = "crash_reporting")]
sentry::with_scope(
|scope| {
scope.set_tag(
"has_received_client_actions",
self.has_received_client_actions,
);
scope.set_tag("error", format!("{error:?}"));
scope.set_tag("is_recoverable", error.is_recoverable());
scope.set_tag(
"will_attempt_resume",
self.should_resume_conversation_after_stream_finished,
);
scope.set_tag("is_online", is_online);
scope.set_tag("retry_count", self.retry_count);
},
|| {
report_error!(anyhow!(error.clone()).context(format!(
"MultiAgent request failed after {} retries",
self.retry_count
)));
},
);
#[cfg(not(feature = "crash_reporting"))]
{
report_error!(anyhow!(error.clone()).context(format!(
"MultiAgent request failed after {} retries",
self.retry_count
)));
}
}
/// Parks a retry until connectivity returns; cancellation invalidates the parked
/// retry through `current_request_id`.
fn defer_retry_until_online(&mut self, ctx: &mut ModelContext<Self>) {
self.deferred_retry_pending = true;
ctx.emit(ResponseStreamEvent::WaitingForNetwork { waiting: true });
let request_id_at_defer = self.current_request_id;
let wait_for_online = NetworkStatus::as_ref(ctx).wait_until_online();
let _ = ctx.spawn(wait_for_online, move |me, _, ctx| {
// Cancelled or superseded while waiting — drop the parked retry.
if request_id_at_defer.is_none() || me.current_request_id != request_id_at_defer {
return;
}
ctx.emit(ResponseStreamEvent::WaitingForNetwork { waiting: false });
me.retry(ctx);
});
}
}
#[derive(Debug)]
@@ -492,6 +641,16 @@ pub struct StreamCancellation {
#[derive(Debug, Clone)]
pub enum ResponseStreamEvent {
ReceivedEvent(Consumable<api::Event>),
/// A retry is parked until connectivity returns (`waiting: true`) or has just
/// fired (`waiting: false`). The controller mirrors this on the conversation
/// status (`TransientError` ↔ `InProgress`).
///
/// Only emitted from `defer_retry_until_online`, i.e. always after a recoverable
/// request failure while offline — never speculatively before an attempt. Consumers
/// can therefore treat `waiting: true` as a transient-error (reconnecting) state.
WaitingForNetwork {
waiting: bool,
},
AfterStreamFinished {
/// Some for cancellation (with context), None for natural completion (uses dynamic lookup).
cancellation: Option<StreamCancellation>,
@@ -501,3 +660,7 @@ pub enum ResponseStreamEvent {
impl Entity for ResponseStream {
type Event = ResponseStreamEvent;
}
#[cfg(test)]
#[path = "response_stream_tests.rs"]
mod tests;
@@ -0,0 +1,84 @@
use super::{recovery_action, RecoveryAction};
// Argument order: has_received_client_actions, is_recoverable, has_retry_budget,
// can_attempt_resume_on_error, is_online.
#[test]
fn pre_action_failures_retry() {
assert_eq!(
recovery_action(false, true, true, true, true),
RecoveryAction::RetryNow
);
// Resume eligibility is irrelevant pre-actions.
assert_eq!(
recovery_action(false, true, true, false, true),
RecoveryAction::RetryNow
);
}
#[test]
fn pre_action_failures_wait_for_connectivity_when_offline() {
assert_eq!(
recovery_action(false, true, true, true, false),
RecoveryAction::RetryWhenOnline
);
}
#[test]
fn pre_action_budget_exhaustion_is_terminal() {
// The request has already been retried MAX_RETRIES times; stop.
assert_eq!(
recovery_action(false, true, false, true, true),
RecoveryAction::Fail
);
assert_eq!(
recovery_action(false, true, false, true, false),
RecoveryAction::Fail
);
}
#[test]
fn non_recoverable_pre_action_failure_is_terminal() {
assert_eq!(
recovery_action(false, false, true, true, true),
RecoveryAction::Fail
);
}
#[test]
fn post_action_recoverable_failures_resume() {
assert_eq!(
recovery_action(true, true, true, true, true),
RecoveryAction::Resume
);
// Offline doesn't change the decision; the resume spawn waits for connectivity.
assert_eq!(
recovery_action(true, true, true, true, false),
RecoveryAction::Resume
);
// The in-request retry budget is irrelevant once actions have executed.
assert_eq!(
recovery_action(true, true, false, true, true),
RecoveryAction::Resume
);
}
#[test]
fn post_action_failures_without_resume_eligibility_are_terminal() {
// Resume requests themselves run with can_attempt_resume_on_error=false,
// bounding recovery to a single resume.
assert_eq!(
recovery_action(true, true, true, false, true),
RecoveryAction::Fail
);
}
#[test]
fn non_recoverable_post_action_failure_is_terminal() {
// A non-recoverable error (e.g. a client error) ends the conversation even
// after actions have executed.
assert_eq!(
recovery_action(true, false, true, true, true),
RecoveryAction::Fail
);
}
+135 -20
View File
@@ -6,11 +6,13 @@ use std::collections::HashMap;
use galaxy_core::features::FeatureFlag;
use itertools::Itertools;
use session_sharing_protocol::common::{AgentAttachment, ParticipantId, ServerConversationToken};
use warp_multi_agent_api::client_action::Action;
use warp_multi_agent_api::message::Message;
use warp_multi_agent_api::response_event::{stream_finished, ClientActions};
use warp_multi_agent_api::{client_action::Action, message::Message};
use warpui::{AppContext, ModelContext, SingletonEntity};
use super::response_stream::ResponseStreamId;
use super::{BlocklistAIController, RequestInput};
use super::{BlocklistAIController, RequestInput, SessionContext};
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::agent::{AIAgentActionId, AIAgentAttachment, EntrypointType};
use crate::ai::attachment_utils::{
@@ -20,12 +22,18 @@ use crate::ai::blocklist::agent_view::AgentViewEntryOrigin;
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
use crate::server::server_api::ServerApiProvider;
use crate::terminal::model::block::BlockId;
use galaxyui::{AppContext, ModelContext, SingletonEntity};
#[derive(Default)]
pub(super) struct SharedSessionState {
// The current active request id for the shared session (used if subsequent events do not provide a request id)
current_response_id: Option<ResponseStreamId>,
/// `true` if shared response streams should be ignored, used to skip processing MAA response streams that
/// originate from session sharing replay when joining a new shared session for a follow-up on an existing
/// ambient agent conversation.
should_suppress_replayed_response_for_existing_conversation: bool,
/// Per-stream latch derived from the gate above once we decide the current replay should be
/// ignored.
should_skip_current_replayed_response: bool,
// The participant who initiated the current response stream
current_response_initiator: Option<ParticipantId>,
// The sharer's participant ID (set when session sharing starts)
@@ -33,6 +41,13 @@ pub(super) struct SharedSessionState {
}
impl BlocklistAIController {
/// Controls whether replayed events for an already-restored shared-session conversation should
/// be ignored to avoid duplicating content appended from follow-up scrollback.
pub fn set_should_suppress_existing_agent_conversation_replay(&mut self, value: bool) {
self.shared_session_state
.should_suppress_replayed_response_for_existing_conversation = value;
}
/// Returns the current conversation ID for the active shared session stream.
/// Returns None if there's no active shared session conversation.
pub(crate) fn get_current_shared_session_conversation_id(
@@ -98,16 +113,19 @@ impl BlocklistAIController {
ctx: &mut ModelContext<Self>,
) {
let stream_id = ResponseStreamId::for_shared_session(&init_event);
self.shared_session_state.current_response_id = Some(stream_id.clone());
let terminal_view_id = self.terminal_view_id;
self.shared_session_state.current_response_id = None;
self.shared_session_state
.should_skip_current_replayed_response = false;
let terminal_surface_id = self.terminal_surface_id;
let history = BlocklistAIHistoryModel::handle(ctx);
// If the server conversation already exists locally (matched by server_conversation_token), reuse it.
// Otherwise, if we're currently in an empty agent view conversation, reuse that
// local conversation ID and bind the incoming server token to it.
// This preserves block visibility for terminal blocks created in the given agent view.
let conversation_id = self
.find_existing_conversation_by_server_token(&init_event.conversation_id, ctx)
let existing_conversation_id =
self.find_existing_conversation_by_server_token(&init_event.conversation_id, ctx);
let conversation_id = existing_conversation_id
.or_else(|| {
let selected_conversation_id = self
.context_model
@@ -143,9 +161,27 @@ impl BlocklistAIController {
})
.unwrap_or_else(|| {
history.update(ctx, |h, ctx| {
h.start_new_conversation(terminal_view_id, false, true, ctx)
h.start_new_conversation(terminal_surface_id, false, true, false, ctx)
})
});
if self.should_skip_replayed_response_for_existing_conversation(
existing_conversation_id,
&init_event.request_id,
ctx,
) {
self.shared_session_state.current_response_id = Some(stream_id);
self.shared_session_state
.should_skip_current_replayed_response = true;
return;
}
self.shared_session_state.current_response_id = Some(stream_id.clone());
if existing_conversation_id.is_some() {
history.update(ctx, |history, ctx| {
history.set_viewing_shared_session_for_conversation(conversation_id, true);
ctx.notify();
});
}
let Some(conversation) = history.as_ref(ctx).conversation(&conversation_id) else {
log::error!(
@@ -169,30 +205,34 @@ impl BlocklistAIController {
&self.active_session,
self.get_current_response_initiator(),
conversation_id,
self.terminal_view_id,
self.terminal_surface_id,
ctx,
),
stream_id.clone(),
self.terminal_view_id,
self.terminal_surface_id,
ctx,
);
history_model.initialize_output_for_response_stream(
&stream_id,
conversation_id,
self.terminal_view_id,
self.terminal_surface_id,
init_event.clone(),
ctx,
);
// Mark conversation as in progress and active/selected
history_model.update_conversation_status(
self.terminal_view_id,
self.terminal_surface_id,
conversation_id,
ConversationStatus::InProgress,
ctx,
);
history_model.set_active_conversation_id(conversation_id, self.terminal_view_id, ctx);
history_model.set_active_conversation_id(
conversation_id,
self.terminal_surface_id,
ctx,
);
});
self.context_model.update(ctx, |context_model, ctx| {
context_model.set_pending_query_state_for_existing_conversation(
@@ -203,11 +243,64 @@ impl BlocklistAIController {
});
}
/// Returns whether replayed events for an already-populated shared-session conversation should
/// be ignored to avoid duplicating content that was restored from scrollback.
fn should_skip_replayed_response_for_existing_conversation(
&self,
existing_conversation_id: Option<AIConversationId>,
init_request_id: &str,
ctx: &mut ModelContext<Self>,
) -> bool {
let Some(conversation_id) = existing_conversation_id else {
log::info!(
"should_skip_replayed_response: no existing conversation id, not skipping \
(request_id={init_request_id})"
);
return false;
};
let is_receiving_replay = self
.terminal_model
.lock()
.is_receiving_agent_conversation_replay();
let suppress_enabled = self
.shared_session_state
.should_suppress_replayed_response_for_existing_conversation;
if !is_receiving_replay || !suppress_enabled {
log::info!(
"should_skip_replayed_response: not skipping \
(request_id={init_request_id}, conversation_id={conversation_id:?}, \
is_receiving_replay={is_receiving_replay}, suppress_enabled={suppress_enabled})"
);
return false;
}
// Only skip the replayed response when our local task already has the given request_id.
// New exchanges (e.g. the user's first post-handoff prompt) carry unseen request_ids and must flow through normally.
let history = BlocklistAIHistoryModel::as_ref(ctx);
let found = history.conversation(&conversation_id).is_some_and(|conv| {
conv.all_tasks()
.any(|task| task.messages().any(|msg| msg.request_id == init_request_id))
});
if !found {
log::info!(
"should_skip_replayed_response: not skipping, request_id not found in local \
conversation (request_id={init_request_id}, conversation_id={conversation_id:?})"
);
}
found
}
fn on_shared_client_actions(
&mut self,
actions: warp_multi_agent_api::response_event::ClientActions,
ctx: &mut ModelContext<Self>,
) {
if self
.shared_session_state
.should_skip_current_replayed_response
{
return;
}
let Some(stream_id) = self.shared_session_state.current_response_id.clone() else {
log::warn!("Received shared session client actions with no active response stream id.");
return;
@@ -223,13 +316,16 @@ impl BlocklistAIController {
};
self.update_directory_context_from_client_actions(&actions, ctx);
let skill_path_origin =
SessionContext::from_session(self.active_session.as_ref(ctx), ctx).skill_path_origin();
let history_model = BlocklistAIHistoryModel::handle(ctx);
history_model.update(ctx, |history_model, ctx| {
if let Err(e) = history_model.apply_client_actions(
&stream_id,
actions.actions,
conversation_id,
self.terminal_view_id,
self.terminal_surface_id,
&skill_path_origin,
ctx,
) {
log::error!(
@@ -315,6 +411,15 @@ impl BlocklistAIController {
finished: warp_multi_agent_api::response_event::StreamFinished,
ctx: &mut ModelContext<Self>,
) {
if self
.shared_session_state
.should_skip_current_replayed_response
{
self.shared_session_state.current_response_id.take();
self.shared_session_state
.should_skip_current_replayed_response = false;
return;
}
let Some(stream_id) = self.shared_session_state.current_response_id.take() else {
log::warn!("Shared Finished missing request_id");
return;
@@ -366,7 +471,7 @@ impl BlocklistAIController {
}
/// Finds an existing client conversation whose server_conversation_token matches `server_token`.
/// Searches only live conversations for this terminal view. Returns None if no match is found.
/// Searches only live conversations for this terminal surface. Returns None if no match is found.
pub fn find_existing_conversation_by_server_token(
&self,
server_token: &str,
@@ -375,7 +480,7 @@ impl BlocklistAIController {
let history = BlocklistAIHistoryModel::handle(ctx);
history
.as_ref(ctx)
.all_live_conversations_for_terminal_view(self.terminal_view_id)
.all_live_conversations_for_terminal_surface(self.terminal_surface_id)
.find_map(|conv| {
conv.server_conversation_token()
.and_then(|t| (t.as_str() == server_token).then_some(conv.id()))
@@ -401,8 +506,10 @@ impl BlocklistAIController {
.conversation(&conv_id)
.map(|conversation| stream_finished::ConversationUsageMetadata {
context_window_usage: conversation.context_window_usage(),
credits_spent: conversation.credits_spent(),
credits_spent: conversation.inference_credits_spent(),
platform_credits_spent: conversation.platform_credits_spent(),
summarized: conversation.was_summarized(),
total_input_tokens: 0,
#[allow(deprecated)]
token_usage: conversation
.token_usage()
@@ -420,6 +527,16 @@ impl BlocklistAIController {
.iter()
.filter_map(|u| u.to_proto_byok_usage())
.collect(),
custom_endpoint_token_usage: conversation
.token_usage()
.iter()
.filter_map(|u| u.to_proto_custom_endpoint_usage())
.collect(),
context_window_segments: conversation
.context_window_segments()
.iter()
.map(Into::into)
.collect(),
})
});
@@ -741,9 +858,7 @@ impl BlocklistAIController {
})
.or_else(|| {
self.context_model.update(ctx, |context_model, ctx| {
context_model
.try_enter_agent_view_for_new_conversation(origin, ctx)
.ok()
context_model.try_start_new_conversation(origin, ctx).ok()
})
})
else {
@@ -3,22 +3,23 @@ use std::sync::Arc;
use galaxy_core::features::FeatureFlag;
use galaxyui::{AppContext, ModelContext, SingletonEntity};
use crate::{
ai::{
agent::{
conversation::AIConversationId, AIAgentContext, AIAgentInput, CloneRepositoryURL,
EntrypointType, RequestMetadata,
},
blocklist::agent_view::AgentViewEntryOrigin,
},
terminal::input::slash_commands::SlashCommandTrigger,
BlocklistAIHistoryModel,
};
use super::{
input_context_for_request, parse_context_attachments, BlocklistAIController,
BlocklistAIControllerEvent, RequestInput,
add_pending_file_attachments, input_context_for_request, parse_context_attachments,
BlocklistAIController, BlocklistAIControllerEvent, RequestInput,
};
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{
AIAgentContext, AIAgentInput, CancellationReason, CloneRepositoryURL, EntrypointType,
InvokeSkillUserQuery, RequestMetadata,
};
use crate::ai::blocklist::agent_view::AgentViewEntryOrigin;
use crate::ai::blocklist::context_model::{
BlocklistAIContextModel, PendingAttachment, PendingFile,
};
use crate::ai::blocklist::queued_query::{QueuedQueryId, QueuedQueryModel};
use crate::search::slash_command_menu::static_commands::commands;
use crate::terminal::input::slash_commands::SlashCommandTrigger;
use crate::BlocklistAIHistoryModel;
pub enum SlashCommandRequest {
CreateNewProject {
@@ -57,27 +58,66 @@ impl SlashCommandRequest {
pub(super) fn send_request(
self,
controller: &mut BlocklistAIController,
is_queued_prompt: bool,
queued_query_id: Option<QueuedQueryId>,
conversation_id_override: Option<AIConversationId>,
ctx: &mut ModelContext<BlocklistAIController>,
) {
let conversation_id = self.conversation_id(controller, ctx);
let is_queued_prompt = queued_query_id.is_some();
// A fired queued prompt carries the conversation it was queued on; use it directly
// instead of re-deriving from the current UI selection (which may point at a different
// conversation the user navigated to). Falls back to the selection for direct sends.
let conversation_id =
conversation_id_override.or_else(|| self.conversation_id(controller, ctx));
// For skill invocations, include user-attached context (images, blocks, and selected
// text) so the skill's agent sees the same attachments a non-slash-command user query
// would. Other slash commands continue to pass `false` to preserve existing behavior.
let is_invoke_skill = matches!(self, Self::InvokeSkill { .. });
let prompt_attachments = if is_invoke_skill {
match (queued_query_id, conversation_id) {
(Some(query_id), Some(conversation_id)) => QueuedQueryModel::as_ref(ctx)
.attachments_for(conversation_id, query_id)
.to_vec(),
(Some(_), None) => vec![],
(None, _) => controller
.context_model
.as_ref(ctx)
.pending_attachments()
.to_vec(),
}
} else {
vec![]
};
let mut image_context = Vec::new();
let mut prompt_files = Vec::new();
for attachment in prompt_attachments {
match attachment {
PendingAttachment::Image(image) => {
image_context.push(AIAgentContext::Image(image));
}
PendingAttachment::File(file) => prompt_files.push(file),
}
}
let context = input_context_for_request(
is_invoke_skill,
controller.context_model.as_ref(ctx),
controller.active_session.as_ref(ctx),
conversation_id,
vec![],
image_context,
ctx,
);
let entrypoint = self.entrypoint();
let inputs = self.input(context, controller.context_model.as_ref(ctx), ctx);
let is_summarize = matches!(self, Self::Summarize { .. });
let inputs = self.input(
context,
prompt_files,
controller.context_model.as_ref(ctx),
ctx,
);
if inputs.is_empty() {
return;
}
let active_conversation_id = BlocklistAIHistoryModel::as_ref(ctx)
.active_conversation_id(controller.terminal_surface_id);
// If no existing conversation, create a new one.
// When AgentView is enabled, enter agent view which creates the conversation
@@ -86,7 +126,7 @@ impl SlashCommandRequest {
if FeatureFlag::AgentView.is_enabled() {
controller.context_model.update(ctx, |context_model, ctx| {
context_model
.try_enter_agent_view_for_new_conversation(
.try_start_new_conversation(
AgentViewEntryOrigin::SlashCommand {
trigger: SlashCommandTrigger::input(),
},
@@ -102,19 +142,32 @@ impl SlashCommandRequest {
return;
};
let cancellation_reason = CancellationReason::FollowUpSubmitted {
is_for_same_conversation: active_conversation_id
.is_some_and(|id| id == conversation_id),
};
if let Some(active_conversation_id) = active_conversation_id {
controller.cancel_conversation_progress(
active_conversation_id,
cancellation_reason,
ctx,
);
}
let Some(conversation) =
BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id)
else {
return;
};
let task_id = conversation.get_root_task_id().clone();
let request_input = RequestInput::for_task(
inputs,
conversation.get_root_task_id().clone(),
task_id,
&controller.active_session,
controller.get_current_response_initiator(),
conversation_id,
controller.terminal_view_id,
controller.terminal_surface_id,
ctx,
);
let model_id = request_input.model_id.clone();
@@ -126,18 +179,14 @@ impl SlashCommandRequest {
entrypoint,
is_auto_resume_after_error: false,
}),
/*default_to_follow_up_on_success*/ true,
/*can_attempt_resume_on_error*/ true,
is_queued_prompt,
ctx,
) {
Ok((_, stream_id)) => {
// Skill invocations now consume user-attached context (images, blocks, and
// selected text) the same way regular user queries do. `send_request_input`
// only clears that context for `AIAgentInput::UserQuery`, so we mirror its
// reset here for `InvokeSkill` to avoid pending attachments sticking around
// and getting re-sent on subsequent messages.
if is_invoke_skill {
// Direct skills consume live pending context; queued skills consume row-owned
// context and must not clear a new draft's staged attachments.
if is_invoke_skill && !is_queued_prompt {
controller.context_model.update(ctx, |context_model, ctx| {
context_model.reset_context_to_default(ctx);
});
@@ -173,7 +222,8 @@ impl SlashCommandRequest {
fn input(
self,
context: Arc<[AIAgentContext]>,
context_model: &crate::ai::blocklist::BlocklistAIContextModel,
prompt_files: Vec<PendingFile>,
context_model: &BlocklistAIContextModel,
app: &AppContext,
) -> Vec<AIAgentInput> {
match self {
@@ -211,22 +261,26 @@ impl SlashCommandRequest {
repo_paths: repos,
}]
}
SlashCommandRequest::Summarize { prompt, .. } => {
vec![AIAgentInput::SummarizeConversation { prompt, context }]
}
SlashCommandRequest::FetchReviewComments { repo_path } => {
vec![AIAgentInput::FetchReviewComments { repo_path, context }]
}
SlashCommandRequest::InvokeSkill { skill, user_query } => {
let user_query = if FeatureFlag::SkillArguments.is_enabled() {
user_query
let query = user_query
.map(|query| query.trim().to_string())
.filter(|query| !query.is_empty())
.map(|query| crate::ai::agent::InvokeSkillUserQuery {
referenced_attachments: parse_context_attachments(
&query,
context_model,
app,
),
.unwrap_or_default();
(!query.is_empty() || !prompt_files.is_empty()).then(|| {
let mut referenced_attachments =
parse_context_attachments(&query, context_model, app);
add_pending_file_attachments(&mut referenced_attachments, prompt_files);
InvokeSkillUserQuery {
referenced_attachments,
query,
})
}
})
} else {
None
};