1270 lines
49 KiB
Rust
1270 lines
49 KiB
Rust
#![allow(dead_code)]
|
|
|
|
use std::cell::RefCell;
|
|
use std::rc::Rc;
|
|
use std::sync::Arc;
|
|
#[cfg(not(target_family = "wasm"))]
|
|
use std::sync::Mutex;
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
use ::local_control::remote_command::is_potential_remote_ssh_command;
|
|
use anyhow::anyhow;
|
|
use chrono::{DateTime, Local, TimeDelta};
|
|
use futures::channel::oneshot;
|
|
use galaxy_agent_core::RuntimeCapabilities;
|
|
#[cfg(not(target_family = "wasm"))]
|
|
use galaxy_agent_core::TurnCommand;
|
|
#[cfg(not(target_family = "wasm"))]
|
|
use galaxy_core::features::FeatureFlag;
|
|
use galaxyui::{AppContext, Entity, ModelContext, SingletonEntity};
|
|
use settings::Setting;
|
|
use uuid::Uuid;
|
|
use warp_multi_agent_api::response_event;
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
use crate::ai::acp::{
|
|
acp_output_stream, acp_startup_error_stream, galaxy_mcp_server, resolve_acp_launch,
|
|
resolve_acp_permissions, validate_acp_dispatch, validate_acp_launch_identity, AcpRuntimeModel,
|
|
AcpSessionMetadata, AcpTurnControlSlot, GalaxyMcpTarget,
|
|
};
|
|
use crate::ai::agent::api::{self, ConvertToAPITypeError};
|
|
use crate::ai::agent::conversation::AIConversationId;
|
|
#[cfg(not(target_family = "wasm"))]
|
|
use crate::ai::agent::AIAgentInput;
|
|
use crate::ai::agent::{AIIdentifiers, CancellationReason};
|
|
#[cfg(not(target_family = "wasm"))]
|
|
use crate::ai::blocklist::BlocklistAIPermissions;
|
|
use crate::ai::llms::{LLMId, LLMPreferences};
|
|
use crate::ai::openai::client::OpenAIClientConfig;
|
|
use crate::ai::provider::client::BedrockClientConfig;
|
|
use crate::ai::provider::ProviderConfig;
|
|
#[cfg(not(target_family = "wasm"))]
|
|
use crate::ai::remote_logging::{self, RemoteLogLevel, RemoteLogRecord};
|
|
#[cfg(not(target_family = "wasm"))]
|
|
use crate::pane_group::PaneGroup;
|
|
use crate::persistence::model::AgentBackend;
|
|
use crate::server::server_api::AIApiError;
|
|
#[cfg(not(target_family = "wasm"))]
|
|
use crate::settings::LocalControlSettings;
|
|
use crate::{report_error, AISettings, BlocklistAIHistoryModel};
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
pub struct ResponseStreamId(String);
|
|
|
|
impl ResponseStreamId {
|
|
pub fn as_str(&self) -> &str {
|
|
&self.0
|
|
}
|
|
|
|
pub(crate) fn from_persisted(value: String) -> Self {
|
|
Self(value)
|
|
}
|
|
|
|
pub fn for_shared_session(init_event: &response_event::StreamInit) -> Self {
|
|
// Make the stream ID unique per viewing by appending a local UUID
|
|
// This prevents collisions when replaying the same conversation multiple times
|
|
// (either on close-and-reopen or when viewing the same shared session from multiple terminals)
|
|
Self(format!("{}-{}", init_event.request_id, Uuid::new_v4()))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn new_for_test() -> Self {
|
|
Self(Uuid::new_v4().to_string())
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
struct AcpRequestControl {
|
|
cancellation_rx: oneshot::Receiver<()>,
|
|
session_metadata: Arc<Mutex<AcpSessionMetadata>>,
|
|
turn_control: AcpTurnControlSlot,
|
|
}
|
|
|
|
/// Projects an ACP response stream or durable provider-run output into conversation history.
|
|
///
|
|
/// Direct-provider lifecycle and retry ownership lives in `ProviderRun`; this model owns only ACP
|
|
/// transport plus the common UI/history projection boundary.
|
|
pub struct ResponseStream {
|
|
id: ResponseStreamId,
|
|
runtime_capabilities: RuntimeCapabilities,
|
|
#[cfg(not(target_family = "wasm"))]
|
|
acp_session_metadata: Arc<Mutex<AcpSessionMetadata>>,
|
|
#[cfg(not(target_family = "wasm"))]
|
|
acp_turn_control: AcpTurnControlSlot,
|
|
params: Option<api::RequestParams>,
|
|
projection_model: LLMId,
|
|
projection_messages_sent:
|
|
Arc<std::sync::Mutex<Vec<crate::ai::provider::types::ConversationMessage>>>,
|
|
start_time: DateTime<Local>,
|
|
time_to_latest_event: TimeDelta,
|
|
cancellation_tx: Option<oneshot::Sender<()>>,
|
|
/// Whether the ACP stream emitted client actions, retained for failure diagnostics.
|
|
has_received_client_actions: bool,
|
|
/// AI identifiers for telemetry emission
|
|
ai_identifiers: AIIdentifiers,
|
|
#[cfg(not(target_family = "wasm"))]
|
|
remote_log_backend: String,
|
|
#[cfg(not(target_family = "wasm"))]
|
|
remote_log_provider: String,
|
|
|
|
/// 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,
|
|
|
|
/// Unique internal ID for the active ACP request. Clearing it on cancellation causes late
|
|
/// transport events to be discarded while the stable response-stream ID remains a projection ID.
|
|
current_request_id: Option<Uuid>,
|
|
}
|
|
|
|
impl ResponseStream {
|
|
/// 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(
|
|
api::StreamEvent::Response(event),
|
|
))));
|
|
}
|
|
#[cfg(test)]
|
|
pub fn new_for_test(id: ResponseStreamId) -> Self {
|
|
let (cancellation_tx, _rx) = oneshot::channel();
|
|
Self {
|
|
id,
|
|
runtime_capabilities: RuntimeCapabilities::provider(),
|
|
#[cfg(not(target_family = "wasm"))]
|
|
acp_session_metadata: Arc::new(Mutex::new(AcpSessionMetadata::default())),
|
|
#[cfg(not(target_family = "wasm"))]
|
|
acp_turn_control: Arc::new(Mutex::new(None)),
|
|
params: Some(api::RequestParams::new_for_test()),
|
|
projection_model: LLMId::from("test-model"),
|
|
projection_messages_sent: Arc::new(std::sync::Mutex::new(Vec::new())),
|
|
start_time: Local::now(),
|
|
time_to_latest_event: TimeDelta::seconds(0),
|
|
cancellation_tx: Some(cancellation_tx),
|
|
has_received_client_actions: false,
|
|
ai_identifiers: AIIdentifiers::default(),
|
|
#[cfg(not(target_family = "wasm"))]
|
|
remote_log_backend: "provider".to_string(),
|
|
#[cfg(not(target_family = "wasm"))]
|
|
remote_log_provider: "test".to_string(),
|
|
stream_finished_received: false,
|
|
error_event_emitted: false,
|
|
current_request_id: Some(Uuid::new_v4()),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn resolve_provider_config(model_id: &str, ctx: &AppContext) -> ProviderConfig {
|
|
let settings = AISettings::as_ref(ctx);
|
|
|
|
// Check if this specific model has an OpenAI-compatible routing entry.
|
|
// This allows OpenAI/LiteLLM models to coexist with Bedrock models —
|
|
// only models fetched from the OpenAI endpoint route through it.
|
|
let llm_prefs = LLMPreferences::as_ref(ctx);
|
|
if let Some(client_config) = llm_prefs.openai_client_config_for_model(model_id) {
|
|
return ProviderConfig::OpenAI(OpenAIClientConfig {
|
|
kind: client_config.kind,
|
|
base_url: client_config.base_url.clone(),
|
|
api_key: client_config.api_key.clone(),
|
|
project_id: client_config.project_id.clone(),
|
|
location: client_config.location.clone(),
|
|
model: client_config
|
|
.model
|
|
.clone()
|
|
.or_else(|| Some(model_id.to_string())),
|
|
reasoning_effort: client_config.reasoning_effort.clone(),
|
|
max_input_tokens: client_config.max_input_tokens,
|
|
max_output_tokens: client_config.max_output_tokens,
|
|
supports_system_messages: client_config.supports_system_messages,
|
|
});
|
|
}
|
|
|
|
// Fall back to Bedrock
|
|
if *settings.bedrock_enabled.value() {
|
|
let api_key_manager = ::ai::api_keys::ApiKeyManager::as_ref(ctx);
|
|
let config = BedrockClientConfig::from_settings(settings)
|
|
.with_model_route(model_id, settings.bedrock_models.value())
|
|
.with_managed_credentials(
|
|
&api_key_manager.aws_credentials_refresh_strategy(),
|
|
api_key_manager.aws_credentials_state(),
|
|
);
|
|
return ProviderConfig::Bedrock(config);
|
|
}
|
|
|
|
ProviderConfig::None
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn remote_log_provider_for_config(provider_config: &ProviderConfig) -> String {
|
|
match provider_config {
|
|
ProviderConfig::Bedrock(config) => {
|
|
let region = if config.region.trim().is_empty() {
|
|
"auto"
|
|
} else {
|
|
config.region.as_str()
|
|
};
|
|
format!("bedrock:{:?}:region={region}", config.auth_method)
|
|
}
|
|
ProviderConfig::OpenAI(config) => {
|
|
format!("openai:{:?}", config.kind)
|
|
}
|
|
ProviderConfig::None => "none".to_string(),
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn common_remote_log_context(
|
|
&self,
|
|
event: &str,
|
|
request_id: Uuid,
|
|
) -> serde_json::Map<String, serde_json::Value> {
|
|
let mut context = serde_json::Map::new();
|
|
context.insert("event".to_string(), serde_json::json!(event));
|
|
context.insert("stream_id".to_string(), serde_json::json!(self.id.0));
|
|
context.insert(
|
|
"request_id".to_string(),
|
|
serde_json::json!(request_id.to_string()),
|
|
);
|
|
context.insert(
|
|
"model_id".to_string(),
|
|
serde_json::json!(self.projection_model.as_str()),
|
|
);
|
|
context.insert(
|
|
"backend".to_string(),
|
|
serde_json::json!(self.remote_log_backend),
|
|
);
|
|
context.insert(
|
|
"provider".to_string(),
|
|
serde_json::json!(self.remote_log_provider),
|
|
);
|
|
context.insert(
|
|
"has_received_client_actions".to_string(),
|
|
serde_json::json!(self.has_received_client_actions),
|
|
);
|
|
context.insert(
|
|
"identifiers".to_string(),
|
|
serde_json::to_value(&self.ai_identifiers).unwrap_or_else(|_| serde_json::json!({})),
|
|
);
|
|
context
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn log_llm_request_started(
|
|
ctx: &mut ModelContext<Self>,
|
|
stream_id: &ResponseStreamId,
|
|
request_id: Uuid,
|
|
params: &api::RequestParams,
|
|
ai_identifiers: &AIIdentifiers,
|
|
backend: &str,
|
|
provider: &str,
|
|
) {
|
|
remote_logging::log_model_event(
|
|
ctx,
|
|
RemoteLogRecord {
|
|
level: RemoteLogLevel::Info,
|
|
message: "LLM request started".to_string(),
|
|
context: serde_json::json!({
|
|
"event": "llm_request_started",
|
|
"stream_id": stream_id.0,
|
|
"request_id": request_id.to_string(),
|
|
"model_id": params.model.as_str(),
|
|
"coding_model_id": params.coding_model.as_str(),
|
|
"backend": backend,
|
|
"provider": provider,
|
|
"input_count": params.input.len(),
|
|
"tool_result_count": params.tool_results.len(),
|
|
"task_count": params.tasks.len(),
|
|
"message_history_count": params.message_history.len(),
|
|
"has_progressive_summary": params.progressive_summary.is_some(),
|
|
"memory_enabled": params.is_memory_enabled,
|
|
"warp_drive_context_enabled": params.warp_drive_context_enabled,
|
|
"planning_enabled": params.planning_enabled,
|
|
"web_search_enabled": params.web_search_enabled,
|
|
"computer_use_enabled": params.computer_use_enabled,
|
|
"ask_user_question_enabled": params.ask_user_question_enabled,
|
|
"orchestration_enabled": params.orchestration_enabled,
|
|
"is_remote_session": params.session_context.is_remote(),
|
|
"llm_finished": false,
|
|
"response_stream_terminal": false,
|
|
"identifiers": serde_json::to_value(ai_identifiers).unwrap_or_else(|_| serde_json::json!({})),
|
|
}),
|
|
},
|
|
);
|
|
if let Some(raw_payload) = remote_logging::raw_model_payload_context(ctx, |max_chars| {
|
|
raw_model_request_payload(params, max_chars)
|
|
}) {
|
|
remote_logging::log_model_event(
|
|
ctx,
|
|
RemoteLogRecord {
|
|
level: RemoteLogLevel::Info,
|
|
message: "Raw model request".to_string(),
|
|
context: serde_json::json!({
|
|
"event": "raw_model_request",
|
|
"stream_id": stream_id.0,
|
|
"request_id": request_id.to_string(),
|
|
"model_id": params.model.as_str(),
|
|
"backend": backend,
|
|
"provider": provider,
|
|
"raw_payload": raw_payload,
|
|
}),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn log_galaxy_decision(
|
|
&self,
|
|
request_id: Uuid,
|
|
decision: &str,
|
|
details: serde_json::Value,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let mut context = self.common_remote_log_context("galaxy_decision", request_id);
|
|
context.insert("decision".to_string(), serde_json::json!(decision));
|
|
context.insert("details".to_string(), details);
|
|
remote_logging::log_model_event(
|
|
ctx,
|
|
RemoteLogRecord {
|
|
level: RemoteLogLevel::Info,
|
|
message: format!("Galaxy decision: {decision}"),
|
|
context: serde_json::Value::Object(context),
|
|
},
|
|
);
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn log_llm_response_finished(
|
|
&self,
|
|
request_id: Uuid,
|
|
finished_event: &warp_multi_agent_api::response_event::StreamFinished,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let mut context = self.common_remote_log_context("llm_response_finished", request_id);
|
|
context.insert(
|
|
"reason".to_string(),
|
|
serde_json::json!(stream_finished_reason_name(&finished_event.reason)),
|
|
);
|
|
context.insert(
|
|
"llm_finished".to_string(),
|
|
serde_json::json!(stream_finished_llm_finished(&finished_event.reason)),
|
|
);
|
|
context.insert(
|
|
"response_stream_terminal".to_string(),
|
|
serde_json::json!(true),
|
|
);
|
|
context.insert(
|
|
"elapsed_ms".to_string(),
|
|
serde_json::json!(self.time_to_latest_event.num_milliseconds()),
|
|
);
|
|
context.insert(
|
|
"should_refresh_model_config".to_string(),
|
|
serde_json::json!(finished_event.should_refresh_model_config),
|
|
);
|
|
context.insert(
|
|
"token_usage".to_string(),
|
|
token_usage_context(&finished_event.token_usage),
|
|
);
|
|
if let Some(cost) = finished_event.request_cost.as_ref() {
|
|
context.insert(
|
|
"request_cost".to_string(),
|
|
serde_json::json!({
|
|
"exact": cost.exact,
|
|
"platform_credits": cost.platform_credits,
|
|
}),
|
|
);
|
|
}
|
|
remote_logging::log_model_event(
|
|
ctx,
|
|
RemoteLogRecord {
|
|
level: RemoteLogLevel::Info,
|
|
message: "LLM response finished".to_string(),
|
|
context: serde_json::Value::Object(context),
|
|
},
|
|
);
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn log_llm_request_error(
|
|
&self,
|
|
request_id: Uuid,
|
|
error: impl std::fmt::Display,
|
|
recovery: &str,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let mut context = self.common_remote_log_context("llm_request_error", request_id);
|
|
context.insert(
|
|
"elapsed_ms".to_string(),
|
|
serde_json::json!(self.time_to_latest_event.num_milliseconds()),
|
|
);
|
|
context.insert("recovery".to_string(), serde_json::json!(recovery));
|
|
context.insert("llm_finished".to_string(), serde_json::json!(false));
|
|
context.insert(
|
|
"response_stream_terminal".to_string(),
|
|
serde_json::json!(true),
|
|
);
|
|
context.insert(
|
|
"error".to_string(),
|
|
serde_json::json!(remote_logging::sanitize_error(error)),
|
|
);
|
|
remote_logging::log_model_event(
|
|
ctx,
|
|
RemoteLogRecord {
|
|
level: RemoteLogLevel::Warn,
|
|
message: format!("LLM request error: {recovery}"),
|
|
context: serde_json::Value::Object(context),
|
|
},
|
|
);
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn log_raw_model_response(
|
|
&self,
|
|
request_id: Uuid,
|
|
payload_kind: &str,
|
|
raw_payload: impl AsRef<str>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let Some(raw_payload) = remote_logging::raw_model_payload_context(ctx, |max_chars| {
|
|
remote_logging::tail_limited_payload_context(raw_payload.as_ref(), max_chars)
|
|
}) else {
|
|
return;
|
|
};
|
|
let mut context = self.common_remote_log_context("raw_model_response", request_id);
|
|
context.insert("payload_kind".to_string(), serde_json::json!(payload_kind));
|
|
context.insert("raw_payload".to_string(), raw_payload);
|
|
remote_logging::log_model_event(
|
|
ctx,
|
|
RemoteLogRecord {
|
|
level: RemoteLogLevel::Info,
|
|
message: "Raw model response".to_string(),
|
|
context: serde_json::Value::Object(context),
|
|
},
|
|
);
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn resolve_acp_manager(
|
|
backend: &crate::persistence::model::AcpConversationData,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> Result<galaxy_acp::AcpSessionManager, String> {
|
|
use galaxy_acp::AcpManagerConfig;
|
|
|
|
let settings = AISettings::as_ref(ctx);
|
|
let provider = if backend.provider_id.is_empty() {
|
|
settings.legacy_acp_provider()
|
|
} else {
|
|
settings.enabled_acp_provider_by_id(&backend.provider_id)
|
|
}
|
|
.ok_or_else(|| {
|
|
"The ACP connection for this conversation is no longer configured in Settings. Add it again or start a new ACP conversation."
|
|
.to_string()
|
|
})?;
|
|
let configured_agent_id = provider.agent_id.trim();
|
|
let configured_agent_id = if configured_agent_id.is_empty() {
|
|
"codex"
|
|
} else {
|
|
configured_agent_id
|
|
};
|
|
let launch = resolve_acp_launch(configured_agent_id, &provider.command, &provider.args)?;
|
|
validate_acp_launch_identity(backend, configured_agent_id, &provider.command, &launch)?;
|
|
let config = AcpManagerConfig::new(launch);
|
|
AcpRuntimeModel::handle(ctx).update(ctx, |runtime, _| runtime.manager(config))
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn spawn_acp_request(
|
|
backend: crate::persistence::model::AcpConversationData,
|
|
params: api::RequestParams,
|
|
conversation_key: String,
|
|
request_id: Uuid,
|
|
control: AcpRequestControl,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let AcpRequestControl {
|
|
cancellation_rx,
|
|
session_metadata,
|
|
turn_control,
|
|
} = control;
|
|
let profile = BlocklistAIPermissions::as_ref(ctx)
|
|
.active_permissions_profile(ctx, params.terminal_view_id);
|
|
let permissions = resolve_acp_permissions(&profile);
|
|
let targets_remote_terminal = params.session_context.is_remote()
|
|
|| params.input.iter().any(|input| {
|
|
let AIAgentInput::UserQuery {
|
|
running_command: Some(command),
|
|
..
|
|
} = input
|
|
else {
|
|
return false;
|
|
};
|
|
is_interactive_remote_command(&command.command)
|
|
});
|
|
let manager = validate_acp_dispatch(
|
|
FeatureFlag::AgentClientProtocol.is_enabled(),
|
|
*AISettings::as_ref(ctx).acp_enabled.value(),
|
|
targets_remote_terminal,
|
|
)
|
|
.and_then(|()| Self::resolve_acp_manager(&backend, ctx));
|
|
let galaxy_mcp_server = if cfg!(unix)
|
|
&& manager.is_ok()
|
|
&& permissions.expose_galaxy_tools
|
|
&& FeatureFlag::GalaxyControlCli.is_enabled()
|
|
&& LocalControlSettings::as_ref(ctx).is_enabled()
|
|
{
|
|
match params
|
|
.terminal_view_id
|
|
.and_then(|terminal_view_id| terminal_pane_id(terminal_view_id, ctx))
|
|
{
|
|
Some(target) => match galaxy_mcp_server(
|
|
&target,
|
|
permissions.allow_terminal_execute,
|
|
permissions.allow_terminal_interrupt,
|
|
) {
|
|
Ok(server) => Some(server),
|
|
Err(error) => {
|
|
log::warn!("Galaxy MCP tools are unavailable for ACP: {error}");
|
|
None
|
|
}
|
|
},
|
|
None => {
|
|
log::warn!(
|
|
"Galaxy MCP tools are unavailable for ACP because the originating terminal pane could not be resolved"
|
|
);
|
|
None
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
let galaxy_terminal_interrupt_available =
|
|
galaxy_mcp_server.is_some() && permissions.allow_terminal_interrupt;
|
|
let _ = ctx.spawn(
|
|
async move {
|
|
let stream = match manager {
|
|
Ok(manager) => {
|
|
acp_output_stream(
|
|
manager,
|
|
params,
|
|
conversation_key,
|
|
backend,
|
|
galaxy_mcp_server,
|
|
galaxy_terminal_interrupt_available,
|
|
permissions.policy,
|
|
permissions.auto_approve_protocol_requests,
|
|
session_metadata,
|
|
turn_control,
|
|
cancellation_rx,
|
|
)
|
|
.await
|
|
}
|
|
Err(message) => acp_startup_error_stream(¶ms, &backend, &message),
|
|
};
|
|
Ok::<_, ConvertToAPITypeError>(stream)
|
|
},
|
|
move |me, stream, ctx| {
|
|
me.handle_response_stream_result(request_id, stream, ctx);
|
|
},
|
|
);
|
|
}
|
|
|
|
pub(super) fn new_provider_projection(
|
|
params: api::RequestParams,
|
|
ai_identifiers: AIIdentifiers,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> Self {
|
|
let start_time = Local::now();
|
|
let request_id = Uuid::new_v4();
|
|
let response_stream_id = ResponseStreamId(Uuid::new_v4().to_string());
|
|
#[cfg(not(target_family = "wasm"))]
|
|
let provider_config = Self::resolve_provider_config(params.model.as_str(), ctx);
|
|
#[cfg(not(target_family = "wasm"))]
|
|
let remote_log_backend = "provider".to_string();
|
|
#[cfg(not(target_family = "wasm"))]
|
|
let remote_log_provider = Self::remote_log_provider_for_config(&provider_config);
|
|
#[cfg(not(target_family = "wasm"))]
|
|
Self::log_llm_request_started(
|
|
ctx,
|
|
&response_stream_id,
|
|
request_id,
|
|
¶ms,
|
|
&ai_identifiers,
|
|
&remote_log_backend,
|
|
&remote_log_provider,
|
|
);
|
|
|
|
let projection_model = params.model.clone();
|
|
let projection_messages_sent = params.messages_sent.clone();
|
|
// For direct-provider projections, drop the heavy history fields from params
|
|
// after logging. Only `tool_results` is needed post-init (for `has_error_tool_results`
|
|
// and `tool_result_count`). This prevents retaining the full conversation history
|
|
// for the lifetime of the response stream.
|
|
let mut params = params;
|
|
params.message_history = Vec::new();
|
|
params.tool_result_archive = Vec::new();
|
|
params.input = Vec::new();
|
|
params.global_rules = Vec::new();
|
|
params.mcp_context = None;
|
|
Self {
|
|
id: response_stream_id,
|
|
runtime_capabilities: RuntimeCapabilities::provider(),
|
|
#[cfg(not(target_family = "wasm"))]
|
|
acp_session_metadata: Arc::new(Mutex::new(AcpSessionMetadata::default())),
|
|
#[cfg(not(target_family = "wasm"))]
|
|
acp_turn_control: Arc::new(Mutex::new(None)),
|
|
params: Some(params),
|
|
projection_model,
|
|
projection_messages_sent,
|
|
start_time,
|
|
time_to_latest_event: TimeDelta::seconds(0),
|
|
cancellation_tx: None,
|
|
has_received_client_actions: false,
|
|
ai_identifiers,
|
|
#[cfg(not(target_family = "wasm"))]
|
|
remote_log_backend,
|
|
#[cfg(not(target_family = "wasm"))]
|
|
remote_log_provider,
|
|
stream_finished_received: false,
|
|
error_event_emitted: false,
|
|
current_request_id: None,
|
|
}
|
|
}
|
|
|
|
pub(super) fn new_restored_provider_projection(
|
|
model: LLMId,
|
|
messages_sent: Arc<std::sync::Mutex<Vec<crate::ai::provider::types::ConversationMessage>>>,
|
|
ai_identifiers: AIIdentifiers,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> Self {
|
|
#[cfg(not(target_family = "wasm"))]
|
|
let provider_config = Self::resolve_provider_config(model.as_str(), ctx);
|
|
Self {
|
|
id: ResponseStreamId(Uuid::new_v4().to_string()),
|
|
runtime_capabilities: RuntimeCapabilities::provider(),
|
|
#[cfg(not(target_family = "wasm"))]
|
|
acp_session_metadata: Arc::new(Mutex::new(AcpSessionMetadata::default())),
|
|
#[cfg(not(target_family = "wasm"))]
|
|
acp_turn_control: Arc::new(Mutex::new(None)),
|
|
params: None,
|
|
projection_model: model,
|
|
projection_messages_sent: messages_sent,
|
|
start_time: Local::now(),
|
|
time_to_latest_event: TimeDelta::seconds(0),
|
|
cancellation_tx: None,
|
|
has_received_client_actions: false,
|
|
ai_identifiers,
|
|
#[cfg(not(target_family = "wasm"))]
|
|
remote_log_backend: "provider".to_string(),
|
|
#[cfg(not(target_family = "wasm"))]
|
|
remote_log_provider: Self::remote_log_provider_for_config(&provider_config),
|
|
stream_finished_received: false,
|
|
error_event_emitted: false,
|
|
current_request_id: None,
|
|
}
|
|
}
|
|
|
|
pub(super) fn projected_event(
|
|
event: warp_multi_agent_api::ResponseEvent,
|
|
) -> ResponseStreamEvent {
|
|
ResponseStreamEvent::ReceivedEvent(Consumable::new(Ok(api::StreamEvent::Response(event))))
|
|
}
|
|
|
|
pub fn new(
|
|
params: api::RequestParams,
|
|
ai_identifiers: AIIdentifiers,
|
|
agent_backend: AgentBackend,
|
|
ctx: &mut ModelContext<Self>,
|
|
) -> Self {
|
|
let AgentBackend::Acp(backend) = agent_backend else {
|
|
unreachable!("direct providers must use the durable provider-run projection");
|
|
};
|
|
let (cancellation_tx, cancellation_rx) = oneshot::channel();
|
|
let start_time = Local::now();
|
|
let request_id = Uuid::new_v4();
|
|
let response_stream_id = ResponseStreamId(Uuid::new_v4().to_string());
|
|
let runtime_capabilities = RuntimeCapabilities::session_runtime();
|
|
#[cfg(not(target_family = "wasm"))]
|
|
let acp_session_metadata = Arc::new(Mutex::new(AcpSessionMetadata::default()));
|
|
#[cfg(not(target_family = "wasm"))]
|
|
let acp_turn_control = Arc::new(Mutex::new(None));
|
|
#[cfg(not(target_family = "wasm"))]
|
|
let remote_log_backend = "acp".to_string();
|
|
#[cfg(not(target_family = "wasm"))]
|
|
let remote_log_provider = if backend.agent_id.is_empty() {
|
|
"acp".to_string()
|
|
} else {
|
|
format!("acp:{}", backend.agent_id)
|
|
};
|
|
#[cfg(not(target_family = "wasm"))]
|
|
{
|
|
Self::log_llm_request_started(
|
|
ctx,
|
|
&response_stream_id,
|
|
request_id,
|
|
¶ms,
|
|
&ai_identifiers,
|
|
&remote_log_backend,
|
|
&remote_log_provider,
|
|
);
|
|
Self::spawn_acp_request(
|
|
backend.clone(),
|
|
params.clone(),
|
|
ai_identifiers
|
|
.client_conversation_id
|
|
.map(|id| format!("{id:?}"))
|
|
.unwrap_or_else(|| Uuid::new_v4().to_string()),
|
|
request_id,
|
|
AcpRequestControl {
|
|
cancellation_rx,
|
|
session_metadata: acp_session_metadata.clone(),
|
|
turn_control: acp_turn_control.clone(),
|
|
},
|
|
ctx,
|
|
);
|
|
}
|
|
#[cfg(target_family = "wasm")]
|
|
{
|
|
let error = Arc::new(AIApiError::Stream {
|
|
stream_type: "acp",
|
|
source: anyhow!("ACP is unavailable in the web client"),
|
|
});
|
|
let stream = Box::pin(futures::stream::once(async move { Err(error) }));
|
|
let _ = ctx.spawn(
|
|
async move { Ok::<_, ConvertToAPITypeError>(stream) },
|
|
move |me, stream, ctx| {
|
|
me.handle_response_stream_result(request_id, stream, ctx);
|
|
},
|
|
);
|
|
}
|
|
let projection_model = params.model.clone();
|
|
let projection_messages_sent = params.messages_sent.clone();
|
|
Self {
|
|
id: response_stream_id,
|
|
runtime_capabilities,
|
|
#[cfg(not(target_family = "wasm"))]
|
|
acp_session_metadata,
|
|
#[cfg(not(target_family = "wasm"))]
|
|
acp_turn_control,
|
|
params: Some(params),
|
|
projection_model,
|
|
projection_messages_sent,
|
|
start_time,
|
|
time_to_latest_event: TimeDelta::seconds(0),
|
|
cancellation_tx: Some(cancellation_tx),
|
|
has_received_client_actions: false,
|
|
ai_identifiers,
|
|
#[cfg(not(target_family = "wasm"))]
|
|
remote_log_backend,
|
|
#[cfg(not(target_family = "wasm"))]
|
|
remote_log_provider,
|
|
stream_finished_received: false,
|
|
error_event_emitted: false,
|
|
current_request_id: Some(request_id),
|
|
}
|
|
}
|
|
|
|
pub fn id(&self) -> &ResponseStreamId {
|
|
&self.id
|
|
}
|
|
|
|
pub fn supports_shared_session_sync(&self) -> bool {
|
|
self.runtime_capabilities.shared_session_sync
|
|
}
|
|
|
|
pub fn host_manages_history(&self) -> bool {
|
|
self.runtime_capabilities.host_managed_history
|
|
}
|
|
|
|
pub(super) fn has_error_tool_results(&self) -> bool {
|
|
self.params.as_ref().is_some_and(|params| {
|
|
params
|
|
.tool_results
|
|
.iter()
|
|
.any(galaxy_agent_core::ToolResult::is_error)
|
|
})
|
|
}
|
|
|
|
pub(super) fn tool_result_count(&self) -> usize {
|
|
self.params
|
|
.as_ref()
|
|
.map_or(0, |params| params.tool_results.len())
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
pub(crate) fn acp_session_metadata(&self) -> Option<AcpSessionMetadata> {
|
|
self.runtime_capabilities
|
|
.session_resume
|
|
.then(|| {
|
|
self.acp_session_metadata
|
|
.lock()
|
|
.ok()
|
|
.map(|state| state.clone())
|
|
})
|
|
.flatten()
|
|
}
|
|
|
|
pub(super) fn try_steer_runtime(&self, display_text: String) -> bool {
|
|
#[cfg(not(target_family = "wasm"))]
|
|
{
|
|
if !self.runtime_capabilities.steering
|
|
|| self.current_request_id.is_none()
|
|
|| !self
|
|
.acp_session_metadata()
|
|
.is_some_and(|metadata| metadata.can_steer)
|
|
{
|
|
return false;
|
|
}
|
|
let Some(params) = self.params.as_ref() else {
|
|
return false;
|
|
};
|
|
let mut model_text = display_text.clone();
|
|
params.redact_text_for_model(&mut model_text);
|
|
self.acp_turn_control
|
|
.lock()
|
|
.ok()
|
|
.and_then(|control| control.clone())
|
|
.is_some_and(|control| {
|
|
control
|
|
.try_send(TurnCommand::Steer {
|
|
display_text,
|
|
model_text,
|
|
})
|
|
.is_ok()
|
|
})
|
|
}
|
|
#[cfg(target_family = "wasm")]
|
|
{
|
|
let _ = display_text;
|
|
false
|
|
}
|
|
}
|
|
|
|
pub fn messages_sent(
|
|
&self,
|
|
) -> &std::sync::Arc<std::sync::Mutex<Vec<crate::ai::provider::types::ConversationMessage>>>
|
|
{
|
|
&self.projection_messages_sent
|
|
}
|
|
|
|
/// Returns the model ID associated with this response stream's request.
|
|
pub fn model_id(&self) -> &str {
|
|
self.projection_model.as_str()
|
|
}
|
|
|
|
pub(super) fn llm_id(&self) -> &LLMId {
|
|
&self.projection_model
|
|
}
|
|
|
|
/// Cancels the stream. The conversation_id is preserved in the emitted event for async handling.
|
|
pub(super) fn cancel(
|
|
&mut self,
|
|
reason: CancellationReason,
|
|
conversation_id: AIConversationId,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
self.current_request_id = None;
|
|
let Some(cancellation_tx) = self.cancellation_tx.take() else {
|
|
return;
|
|
};
|
|
let _ = cancellation_tx.send(());
|
|
ctx.emit(ResponseStreamEvent::AfterStreamFinished {
|
|
cancellation: Some(StreamCancellation {
|
|
reason,
|
|
conversation_id,
|
|
}),
|
|
});
|
|
}
|
|
|
|
fn handle_response_stream_result(
|
|
&mut self,
|
|
request_id: Uuid,
|
|
stream_result: Result<api::ResponseStream, ConvertToAPITypeError>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
match stream_result {
|
|
Ok(stream) => {
|
|
ctx.spawn_stream_local(
|
|
stream,
|
|
move |me, event, ctx| {
|
|
me.handle_response_stream_event(request_id, event, ctx);
|
|
},
|
|
move |me, ctx| {
|
|
me.on_response_stream_complete(request_id, ctx);
|
|
},
|
|
);
|
|
}
|
|
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)));
|
|
#[cfg(not(target_family = "wasm"))]
|
|
self.log_llm_request_error(
|
|
request_id,
|
|
error.as_ref(),
|
|
"stream_creation_failed",
|
|
ctx,
|
|
);
|
|
self.error_event_emitted = true;
|
|
self.report_request_failure(&error);
|
|
ctx.emit(ResponseStreamEvent::ReceivedEvent(Consumable::new(Err(
|
|
error,
|
|
))));
|
|
self.on_response_stream_complete(request_id, ctx);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_response_stream_event(
|
|
&mut self,
|
|
request_id: Uuid,
|
|
event: api::Event,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
if self.current_request_id.is_none_or(|id| id != request_id) {
|
|
log::debug!("Dropping event for stale ACP request {request_id}");
|
|
return;
|
|
}
|
|
self.time_to_latest_event = Local::now().signed_duration_since(self.start_time);
|
|
|
|
match &event {
|
|
Ok(api::StreamEvent::Response(response_event)) => {
|
|
let event_type_name = match &response_event.r#type {
|
|
Some(warp_multi_agent_api::response_event::Type::Init(_)) => "Init",
|
|
Some(warp_multi_agent_api::response_event::Type::ClientActions(_)) => {
|
|
"ClientActions"
|
|
}
|
|
Some(warp_multi_agent_api::response_event::Type::Finished(_)) => "Finished",
|
|
None => "None",
|
|
};
|
|
#[cfg(not(target_family = "wasm"))]
|
|
self.log_raw_model_response(
|
|
request_id,
|
|
event_type_name,
|
|
format!("{response_event:#?}"),
|
|
ctx,
|
|
);
|
|
if let Some(event_type) = &response_event.r#type {
|
|
match event_type {
|
|
warp_multi_agent_api::response_event::Type::Init(init_event) => {
|
|
// Capture server_output_id from StreamInit event
|
|
self.ai_identifiers.server_output_id =
|
|
Some(crate::ai::agent::ServerOutputId::new(
|
|
init_event.request_id.clone(),
|
|
));
|
|
}
|
|
warp_multi_agent_api::response_event::Type::ClientActions(
|
|
client_actions,
|
|
) => {
|
|
// Mark that we've received client actions
|
|
self.has_received_client_actions = true;
|
|
#[cfg(not(target_family = "wasm"))]
|
|
{
|
|
let mut context = self.common_remote_log_context(
|
|
"llm_client_actions_received",
|
|
request_id,
|
|
);
|
|
context.insert(
|
|
"action_count".to_string(),
|
|
serde_json::json!(client_actions.actions.len()),
|
|
);
|
|
remote_logging::log_model_event(
|
|
ctx,
|
|
RemoteLogRecord {
|
|
level: RemoteLogLevel::Info,
|
|
message: "LLM client actions received".to_string(),
|
|
context: serde_json::Value::Object(context),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
warp_multi_agent_api::response_event::Type::Finished(finished_event) => {
|
|
self.stream_finished_received = true;
|
|
#[cfg(not(target_family = "wasm"))]
|
|
self.log_llm_response_finished(request_id, finished_event, ctx);
|
|
}
|
|
}
|
|
}
|
|
ctx.emit(ResponseStreamEvent::ReceivedEvent(Consumable::new(event)));
|
|
}
|
|
Err(e) => {
|
|
log::info!("ResponseStream received error: {e:?}");
|
|
#[cfg(not(target_family = "wasm"))]
|
|
self.log_llm_request_error(request_id, e.as_ref(), "fail", ctx);
|
|
self.error_event_emitted = true;
|
|
self.report_request_failure(e);
|
|
ctx.emit(ResponseStreamEvent::ReceivedEvent(Consumable::new(event)));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn on_response_stream_complete(&mut self, request_id: Uuid, ctx: &mut ModelContext<Self>) {
|
|
if self.current_request_id.is_none_or(|id| id != request_id) {
|
|
log::debug!("Ignoring completion for stale ACP request {request_id}");
|
|
return;
|
|
}
|
|
// ACP sends StreamFinished before closing. A clean EOF without it is a
|
|
// truncated protocol response and cannot be retried safely by this projection.
|
|
if !self.stream_finished_received && !self.error_event_emitted {
|
|
log::warn!("ACP response stream ended without emitting StreamFinished");
|
|
let unexpected_eof = Arc::new(AIApiError::UnexpectedEof);
|
|
#[cfg(not(target_family = "wasm"))]
|
|
self.log_llm_request_error(request_id, unexpected_eof.as_ref(), "fail", ctx);
|
|
self.error_event_emitted = true;
|
|
self.report_request_failure(&unexpected_eof);
|
|
ctx.emit(ResponseStreamEvent::ReceivedEvent(Consumable::new(Err(
|
|
unexpected_eof,
|
|
))));
|
|
}
|
|
|
|
ctx.emit(ResponseStreamEvent::AfterStreamFinished { cancellation: None });
|
|
self.cancellation_tx = None;
|
|
}
|
|
|
|
/// Reports a terminal response-stream failure with classification tags.
|
|
fn report_request_failure(&self, error: &Arc<AIApiError>) {
|
|
#[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());
|
|
},
|
|
|| {
|
|
report_error!(anyhow!(error.clone()).context("ACP response stream failed"));
|
|
},
|
|
);
|
|
#[cfg(not(feature = "crash_reporting"))]
|
|
report_error!(anyhow!(error.clone()).context("ACP response stream failed"));
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn raw_model_request_payload(params: &api::RequestParams, max_chars: usize) -> serde_json::Value {
|
|
use serde::ser::{SerializeMap, Serializer};
|
|
|
|
// collect_str writes debug output directly through serde_json's escaping
|
|
// writer, without format! allocating another copy of potentially large input.
|
|
struct DebugPayload<T>(T);
|
|
impl<T: std::fmt::Debug> serde::Serialize for DebugPayload<T> {
|
|
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
|
serializer.collect_str(&format_args!("{:#?}", self.0))
|
|
}
|
|
}
|
|
|
|
remote_logging::bounded_payload_context(max_chars, |writer| {
|
|
let mut serializer = serde_json::Serializer::pretty(writer);
|
|
let mut map = serializer.serialize_map(None)?;
|
|
map.serialize_entry("model_id", ¶ms.model.as_str())?;
|
|
map.serialize_entry("coding_model_id", ¶ms.coding_model.as_str())?;
|
|
map.serialize_entry("cli_agent_model_id", ¶ms.cli_agent_model.as_str())?;
|
|
map.serialize_entry("computer_use_model_id", ¶ms.computer_use_model.as_str())?;
|
|
map.serialize_entry("input", &DebugPayload(¶ms.input))?;
|
|
map.serialize_entry("tool_results", &DebugPayload(¶ms.tool_results))?;
|
|
map.serialize_entry("tasks", &DebugPayload(¶ms.tasks))?;
|
|
map.serialize_entry("message_history", ¶ms.message_history)?;
|
|
map.serialize_entry("progressive_summary", ¶ms.progressive_summary)?;
|
|
map.serialize_entry("tool_result_archive", ¶ms.tool_result_archive)?;
|
|
map.serialize_entry("global_rules", ¶ms.global_rules)?;
|
|
map.serialize_entry("mcp_context", &DebugPayload(¶ms.mcp_context))?;
|
|
map.serialize_entry("autonomy_level", &DebugPayload(¶ms.autonomy_level))?;
|
|
map.serialize_entry("isolation_level", &DebugPayload(¶ms.isolation_level))?;
|
|
map.serialize_entry(
|
|
"supported_tools_override",
|
|
&DebugPayload(¶ms.supported_tools_override),
|
|
)?;
|
|
map.serialize_entry("context_window_limit", ¶ms.context_window_limit)?;
|
|
map.serialize_entry(
|
|
"session",
|
|
&serde_json::json!({
|
|
"is_remote": params.session_context.is_remote(),
|
|
}),
|
|
)?;
|
|
map.serialize_entry(
|
|
"features",
|
|
&serde_json::json!({
|
|
"memory_enabled": params.is_memory_enabled,
|
|
"warp_drive_context_enabled": params.warp_drive_context_enabled,
|
|
"planning_enabled": params.planning_enabled,
|
|
"web_search_enabled": params.web_search_enabled,
|
|
"computer_use_enabled": params.computer_use_enabled,
|
|
"ask_user_question_enabled": params.ask_user_question_enabled,
|
|
"research_agent_enabled": params.research_agent_enabled,
|
|
"orchestration_enabled": params.orchestration_enabled,
|
|
}),
|
|
)?;
|
|
map.serialize_entry(
|
|
"omitted_sensitive_fields",
|
|
&["api_keys", "custom_model_providers", "custom_model_routers"],
|
|
)?;
|
|
map.end()
|
|
})
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn stream_finished_reason_name(
|
|
reason: &Option<response_event::stream_finished::Reason>,
|
|
) -> &'static str {
|
|
match reason {
|
|
None => "done",
|
|
Some(response_event::stream_finished::Reason::Done(_)) => "done",
|
|
Some(response_event::stream_finished::Reason::MaxTokenLimit(_)) => "max_token_limit",
|
|
Some(response_event::stream_finished::Reason::Other(_)) => "other",
|
|
Some(response_event::stream_finished::Reason::ContextWindowExceeded(_)) => {
|
|
"context_window_exceeded"
|
|
}
|
|
Some(response_event::stream_finished::Reason::QuotaLimit(_)) => "quota_limit",
|
|
Some(response_event::stream_finished::Reason::LlmUnavailable(_)) => "llm_unavailable",
|
|
Some(response_event::stream_finished::Reason::InvalidApiKey(_)) => "invalid_api_key",
|
|
Some(response_event::stream_finished::Reason::InternalError(_)) => "internal_error",
|
|
}
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn stream_finished_llm_finished(reason: &Option<response_event::stream_finished::Reason>) -> bool {
|
|
matches!(
|
|
reason,
|
|
None | Some(response_event::stream_finished::Reason::Done(_))
|
|
| Some(response_event::stream_finished::Reason::MaxTokenLimit(_))
|
|
)
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn token_usage_context(
|
|
token_usage: &[response_event::stream_finished::TokenUsage],
|
|
) -> serde_json::Value {
|
|
let total_input: u64 = token_usage
|
|
.iter()
|
|
.map(|usage| u64::from(usage.total_input))
|
|
.sum();
|
|
let output: u64 = token_usage
|
|
.iter()
|
|
.map(|usage| u64::from(usage.output))
|
|
.sum();
|
|
let input_cache_read: u64 = token_usage
|
|
.iter()
|
|
.map(|usage| u64::from(usage.input_cache_read))
|
|
.sum();
|
|
let input_cache_write: u64 = token_usage
|
|
.iter()
|
|
.map(|usage| u64::from(usage.input_cache_write))
|
|
.sum();
|
|
let cost_in_cents: f32 = token_usage.iter().map(|usage| usage.cost_in_cents).sum();
|
|
serde_json::json!({
|
|
"total_input": total_input,
|
|
"output": output,
|
|
"input_cache_read": input_cache_read,
|
|
"input_cache_write": input_cache_write,
|
|
"cost_in_cents": cost_in_cents,
|
|
"models": token_usage.iter().map(|usage| {
|
|
serde_json::json!({
|
|
"model_id": usage.model_id,
|
|
"total_input": usage.total_input,
|
|
"output": usage.output,
|
|
"input_cache_read": usage.input_cache_read,
|
|
"input_cache_write": usage.input_cache_write,
|
|
"cost_in_cents": usage.cost_in_cents,
|
|
})
|
|
}).collect::<Vec<_>>(),
|
|
})
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn is_interactive_remote_command(command: &str) -> bool {
|
|
is_potential_remote_ssh_command(command)
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn terminal_pane_id(
|
|
terminal_view_id: galaxyui::EntityId,
|
|
ctx: &ModelContext<ResponseStream>,
|
|
) -> Option<GalaxyMcpTarget> {
|
|
let window_ids = ctx.window_ids().collect::<Vec<_>>();
|
|
for window_id in window_ids {
|
|
let Some(pane_groups) = ctx.views_of_type::<PaneGroup>(window_id) else {
|
|
continue;
|
|
};
|
|
for pane_group in pane_groups {
|
|
let tab_id = pane_group.id().to_string();
|
|
if let Some(pane_id) = pane_group
|
|
.as_ref(ctx)
|
|
.find_pane_id_for_terminal_view(terminal_view_id, ctx)
|
|
{
|
|
return Some(GalaxyMcpTarget {
|
|
window_id: window_id.to_string(),
|
|
tab_id,
|
|
pane_id: pane_id.to_string(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct Consumable<T> {
|
|
value: Rc<RefCell<Option<T>>>,
|
|
}
|
|
|
|
impl<T> Consumable<T> {
|
|
fn new(value: T) -> Self {
|
|
Consumable {
|
|
value: Rc::new(RefCell::new(Some(value))),
|
|
}
|
|
}
|
|
|
|
pub(super) fn consume(&self) -> Option<T> {
|
|
self.value.borrow_mut().take()
|
|
}
|
|
}
|
|
|
|
impl<T> Clone for Consumable<T> {
|
|
fn clone(&self) -> Self {
|
|
Consumable {
|
|
value: Rc::clone(&self.value),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Cancellation context preserved for async event handling.
|
|
/// Includes conversation_id because truncation can remove exchange mappings before the event is processed.
|
|
#[derive(Debug, Clone)]
|
|
pub struct StreamCancellation {
|
|
pub reason: CancellationReason,
|
|
pub conversation_id: AIConversationId,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum ResponseStreamEvent {
|
|
ReceivedEvent(Consumable<api::Event>),
|
|
AfterStreamFinished {
|
|
/// Some for cancellation (with context), None for natural completion (uses dynamic lookup).
|
|
cancellation: Option<StreamCancellation>,
|
|
},
|
|
}
|
|
|
|
impl Entity for ResponseStream {
|
|
type Event = ResponseStreamEvent;
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "response_stream_tests.rs"]
|
|
mod tests;
|