Files
galaxy/app/src/server/server_api/ai.rs
T

2559 lines
92 KiB
Rust

use anyhow::anyhow;
use async_trait::async_trait;
use base64::Engine;
use chrono::{DateTime, Utc};
use cynic::{MutationBuilder, QueryBuilder};
use galaxy_core::channel::ChannelState;
use galaxy_core::{features::FeatureFlag, report_error};
use itertools::Itertools;
#[cfg(test)]
use mockall::automock;
use prost::Message;
use std::{
collections::{HashMap, HashSet},
time::Duration,
};
use warp_multi_agent_api::ConversationData;
use super::auth::AuthClient;
use super::ServerApi;
use crate::ai::agent::api::ServerConversationToken;
use crate::ai::agent::conversation::{
AIAgentConversationFormat, AIAgentHarness, AIAgentSerializedBlockFormat,
ServerAIConversationMetadata,
};
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::artifacts::Artifact;
use crate::ai::generate_code_review_content::api::{
GenerateCodeReviewContentRequest, GenerateCodeReviewContentResponse,
};
#[cfg(feature = "agent_mode_evals")]
use crate::ai::request_usage_model::RequestLimitInfo;
#[cfg(not(feature = "agent_mode_evals"))]
use crate::ai::BonusGrant;
use crate::persistence::model::ConversationUsageMetadata;
use crate::terminal::model::block::SerializedBlock;
#[cfg(not(feature = "agent_mode_evals"))]
use crate::{
ai::request_usage_model::BonusGrantScope,
server::ids::ServerId,
workspaces::{gql_convert::PLACEHOLDER_WORKSPACE_UID, workspace::WorkspaceUid},
};
use crate::{
ai::{
llms::{
AvailableLLMs, DisableReason, LLMInfo, LLMModelHost, LLMProvider, LLMSpec,
LLMUsageMetadata, ModelsByFeature, RoutingHostConfig,
},
RequestUsageInfo,
},
ai_assistant::{
execution_context::WarpAiExecutionContext, requests::GenerateDialogueResult,
utils::TranscriptPart, AIGeneratedCommand, GenerateCommandsFromNaturalLanguageError,
},
drive::workflows::ai_assist::{GeneratedCommandMetadata, GeneratedCommandMetadataError},
server::graphql::{
default_request_options, get_request_context, get_user_facing_error_message,
},
};
use ai::index::full_source_code_embedding::{
self,
store_client::{IntermediateNode, StoreClient},
CodebaseContextConfig, ContentHash, EmbeddingConfig, NodeHash, RepoMetadata,
};
use galaxy_graphql::client::Operation;
#[cfg(not(feature = "agent_mode_evals"))]
use galaxy_graphql::queries::get_request_limit_info::{
GetRequestLimitInfo, GetRequestLimitInfoVariables,
};
use galaxy_graphql::{
ai::{AgentTaskState, PlatformErrorCode},
mutations::{
confirm_file_artifact_upload::{
ConfirmFileArtifactUpload, ConfirmFileArtifactUploadInput,
ConfirmFileArtifactUploadResult, ConfirmFileArtifactUploadVariables,
},
create_agent_task::{
CreateAgentTask, CreateAgentTaskInput, CreateAgentTaskResult, CreateAgentTaskVariables,
},
create_file_artifact_upload_target::{
CreateFileArtifactUploadTarget, CreateFileArtifactUploadTargetInput,
CreateFileArtifactUploadTargetResult, CreateFileArtifactUploadTargetVariables,
},
delete_ai_conversation::{
DeleteAIConversation, DeleteAIConversationVariables, DeleteConversationInput,
DeleteConversationResult,
},
generate_code_embeddings::{
GenerateCodeEmbeddings, GenerateCodeEmbeddingsInput, GenerateCodeEmbeddingsResult,
GenerateCodeEmbeddingsVariables,
},
generate_commands::{
GenerateCommands, GenerateCommandsInput, GenerateCommandsResult,
GenerateCommandsStatus, GenerateCommandsVariables,
},
generate_dialogue::{
GenerateDialogue, GenerateDialogueInput,
GenerateDialogueResult as GenerateDialogueResultGraphql, GenerateDialogueStatus,
GenerateDialogueVariables, TranscriptPart as TranscriptPartGraphql,
},
generate_metadata_for_command::{
GenerateMetadataForCommand, GenerateMetadataForCommandInput,
GenerateMetadataForCommandResult, GenerateMetadataForCommandStatus,
GenerateMetadataForCommandVariables,
},
populate_merkle_tree_cache::{
PopulateMerkleTreeCache, PopulateMerkleTreeCacheResult,
PopulateMerkleTreeCacheVariables,
},
request_bonus::{
ProvideNegativeFeedbackResponseForAiConversation,
ProvideNegativeFeedbackResponseForAiConversationInput,
ProvideNegativeFeedbackResponseForAiConversationVariables, RequestsRefundedResult,
},
update_agent_task::{
AgentTaskStatusMessageInput, UpdateAgentTask, UpdateAgentTaskInput,
UpdateAgentTaskResult, UpdateAgentTaskVariables,
},
update_merkle_tree::{
MerkleTreeNode, UpdateMerkleTree, UpdateMerkleTreeInput, UpdateMerkleTreeResult,
UpdateMerkleTreeVariables,
},
},
queries::{
codebase_context_config::{
CodebaseContextConfigQuery, CodebaseContextConfigResult, CodebaseContextConfigVariables,
},
free_available_models::{
FreeAvailableModels, FreeAvailableModelsInput, FreeAvailableModelsResult,
FreeAvailableModelsVariables,
},
get_feature_model_choices::{GetFeatureModelChoices, GetFeatureModelChoicesVariables},
get_relevant_fragments::{
GetRelevantFragmentsQuery, GetRelevantFragmentsResult, GetRelevantFragmentsVariables,
},
get_scheduled_agent_history::{
GetScheduledAgentHistory, GetScheduledAgentHistoryVariables, ScheduledAgentHistory,
ScheduledAgentHistoryInput, ScheduledAgentHistoryResult,
},
rerank_fragments::{RerankFragments, RerankFragmentsResult, RerankFragmentsVariables},
sync_merkle_tree::{
SyncMerkleTree, SyncMerkleTreeInput, SyncMerkleTreeResult, SyncMerkleTreeVariables,
},
task_attachments::{Task as TaskAttachmentsQuery, TaskInput, TaskResult, TaskVariables},
},
};
// Re-export ambient agent types for backwards compatibility
pub use crate::ai::ambient_agents::{
task::{AttachmentInput, TaskAttachment},
AgentConfigSnapshot, AgentSource, AmbientAgentTask, AmbientAgentTaskState, TaskStatusMessage,
};
const AI_ASSISTANT_REQUEST_TIMEOUT_SECONDS: u64 = 30;
/// A status update for a task, optionally including a platform error code.
pub struct TaskStatusUpdate {
pub message: String,
pub error_code: Option<PlatformErrorCode>,
}
impl TaskStatusUpdate {
/// Create a status update with just a message (no error code).
pub fn message(message: impl Into<String>) -> Self {
Self {
message: message.into(),
error_code: None,
}
}
/// Create a status update with a message and error code.
pub fn with_error_code(message: impl Into<String>, error_code: PlatformErrorCode) -> Self {
Self {
message: message.into(),
error_code: Some(error_code),
}
}
}
/// JSON payload sent to the public `POST /agent/run` API.
#[derive(Debug, Clone, serde::Serialize)]
pub struct SpawnAgentRequest {
pub prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub config: Option<AgentConfigSnapshot>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub team: Option<bool>,
/// Use a Claude-compatible skill as the base prompt.
/// Format: "repo:skill_name" or just "skill_name".
/// The skill is resolved at runtime in the agent environment.
#[serde(skip_serializing_if = "Option::is_none")]
pub skill: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub attachments: Vec<AttachmentInput>,
#[serde(skip_serializing_if = "Option::is_none")]
pub interactive: Option<bool>,
/// Populated when a cloud agent spawns a child run via the public API.
/// Not yet wired through the local start_agent flow.
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_run_id: Option<String>,
/// Base64-encoded `warp.multi_agent.v1.Skill` payloads to restore as runtime skills.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub runtime_skills: Vec<String>,
/// Base64-encoded `warp.multi_agent.v1.Attachment` payloads to restore as referenced attachments.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub referenced_attachments: Vec<String>,
}
// --- Orchestrations V2 messaging types ---
#[derive(Debug, Clone, serde::Serialize)]
pub struct SendAgentMessageRequest {
pub to: Vec<String>,
pub subject: String,
pub body: String,
pub sender_run_id: String,
}
#[derive(Debug, Clone)]
pub struct ListAgentMessagesRequest {
pub unread_only: bool,
pub since: Option<String>,
pub limit: i32,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SendAgentMessageResponse {
pub message_ids: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AgentMessageHeader {
pub message_id: String,
pub sender_run_id: String,
pub subject: String,
pub sent_at: String,
pub delivered_at: Option<String>,
pub read_at: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AgentRunEvent {
pub event_type: String,
pub run_id: String,
pub ref_id: Option<String>,
pub execution_id: Option<String>,
pub occurred_at: String,
pub sequence: i64,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ReportAgentEventRequest {
pub event_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub execution_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ref_id: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ReportAgentEventResponse {
pub sequence: i64,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ReadAgentMessageResponse {
pub message_id: String,
pub sender_run_id: String,
pub subject: String,
pub body: String,
pub sent_at: String,
pub delivered_at: Option<String>,
pub read_at: Option<String>,
}
#[derive(serde::Deserialize)]
pub struct SpawnAgentResponse {
pub task_id: AmbientAgentTaskId,
pub run_id: String,
#[serde(default)]
pub at_capacity: bool,
}
/// Response from the artifact endpoint.
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
#[serde(tag = "artifact_type")]
pub enum ArtifactDownloadResponse {
#[serde(rename = "SCREENSHOT")]
Screenshot {
#[serde(flatten)]
common: ArtifactDownloadCommonFields,
data: ScreenshotArtifactResponseData,
},
#[serde(rename = "FILE")]
File {
#[serde(flatten)]
common: ArtifactDownloadCommonFields,
data: FileArtifactResponseData,
},
}
impl ArtifactDownloadResponse {
fn common(&self) -> &ArtifactDownloadCommonFields {
match self {
ArtifactDownloadResponse::Screenshot { common, .. }
| ArtifactDownloadResponse::File { common, .. } => common,
}
}
pub fn artifact_uid(&self) -> &str {
&self.common().artifact_uid
}
pub fn artifact_type(&self) -> &'static str {
match self {
ArtifactDownloadResponse::Screenshot { .. } => "SCREENSHOT",
ArtifactDownloadResponse::File { .. } => "FILE",
}
}
pub fn created_at(&self) -> DateTime<Utc> {
self.common().created_at
}
pub fn download_url(&self) -> &str {
match self {
ArtifactDownloadResponse::Screenshot { data, .. } => &data.download_url,
ArtifactDownloadResponse::File { data, .. } => &data.download_url,
}
}
pub fn expires_at(&self) -> DateTime<Utc> {
match self {
ArtifactDownloadResponse::Screenshot { data, .. } => data.expires_at,
ArtifactDownloadResponse::File { data, .. } => data.expires_at,
}
}
pub fn content_type(&self) -> &str {
match self {
ArtifactDownloadResponse::Screenshot { data, .. } => &data.content_type,
ArtifactDownloadResponse::File { data, .. } => &data.content_type,
}
}
pub fn filepath(&self) -> Option<&str> {
match self {
ArtifactDownloadResponse::Screenshot { .. } => None,
ArtifactDownloadResponse::File { data, .. } => Some(&data.filepath),
}
}
pub fn filename(&self) -> Option<&str> {
match self {
ArtifactDownloadResponse::Screenshot { .. } => None,
ArtifactDownloadResponse::File { data, .. } => Some(&data.filename),
}
}
pub fn description(&self) -> Option<&str> {
match self {
ArtifactDownloadResponse::Screenshot { data, .. } => data.description.as_deref(),
ArtifactDownloadResponse::File { data, .. } => data.description.as_deref(),
}
}
pub fn size_bytes(&self) -> Option<i64> {
match self {
ArtifactDownloadResponse::Screenshot { .. } => None,
ArtifactDownloadResponse::File { data, .. } => data.size_bytes,
}
}
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct ArtifactDownloadCommonFields {
pub artifact_uid: String,
pub created_at: DateTime<Utc>,
}
/// Screenshot-specific data from the artifact endpoint.
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct ScreenshotArtifactResponseData {
pub download_url: String,
pub expires_at: DateTime<Utc>,
pub content_type: String,
pub description: Option<String>,
}
/// File-specific data from the artifact endpoint.
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct FileArtifactResponseData {
pub download_url: String,
pub expires_at: DateTime<Utc>,
pub content_type: String,
pub filepath: String,
pub filename: String,
pub description: Option<String>,
pub size_bytes: Option<i64>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct AttachmentFileInfo {
pub filename: String,
pub mime_type: String,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct PrepareAttachmentUploadsRequest {
pub files: Vec<AttachmentFileInfo>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DownloadAttachmentsRequest {
pub attachment_ids: Vec<String>,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct AttachmentDownloadInfo {
pub attachment_id: String,
pub download_url: String,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct DownloadAttachmentsResponse {
pub attachments: Vec<AttachmentDownloadInfo>,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct HandoffSnapshotAttachmentInfo {
pub attachment_id: String,
pub filename: String,
pub download_url: String,
pub mime_type: Option<String>,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct ListHandoffSnapshotAttachmentsResponse {
pub attachments: Vec<HandoffSnapshotAttachmentInfo>,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct AttachmentUploadInfo {
pub attachment_id: String,
pub upload_url: String,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct PrepareAttachmentUploadsResponse {
pub attachments: Vec<AttachmentUploadInfo>,
}
#[derive(Debug, Clone)]
pub struct CreateFileArtifactUploadRequest {
pub conversation_id: Option<String>,
pub run_id: Option<String>,
pub filepath: String,
pub description: Option<String>,
pub mime_type: Option<String>,
pub size_bytes: Option<i32>,
}
#[derive(Debug, Clone)]
pub struct FileArtifactRecord {
pub artifact_uid: String,
pub filepath: String,
pub description: Option<String>,
pub mime_type: String,
pub size_bytes: Option<i32>,
}
#[derive(Debug, Clone)]
pub struct FileArtifactUploadHeaderInfo {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone)]
pub struct FileArtifactUploadTargetInfo {
pub url: String,
pub method: String,
pub headers: Vec<FileArtifactUploadHeaderInfo>,
}
#[derive(Debug, Clone)]
pub struct CreateFileArtifactUploadResponse {
pub artifact: FileArtifactRecord,
pub upload_target: FileArtifactUploadTargetInfo,
}
/// Filter parameters for listing ambient agent tasks.
#[derive(Clone, Debug, Default)]
pub struct TaskListFilter {
pub creator_uid: Option<String>,
pub updated_after: Option<DateTime<Utc>>,
pub created_after: Option<DateTime<Utc>>,
pub created_before: Option<DateTime<Utc>>,
pub states: Option<Vec<AmbientAgentTaskState>>,
pub source: Option<AgentSource>,
pub execution_location: Option<ExecutionLocation>,
pub environment_id: Option<String>,
pub skill_spec: Option<String>,
pub schedule_id: Option<String>,
pub ancestor_run_id: Option<String>,
pub config_name: Option<String>,
pub model_id: Option<String>,
pub artifact_type: Option<ArtifactType>,
pub search_query: Option<String>,
pub sort_by: Option<RunSortBy>,
pub sort_order: Option<RunSortOrder>,
pub cursor: Option<String>,
}
/// Execution location filter values accepted by the public API.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ExecutionLocation {
Local,
Remote,
}
impl ExecutionLocation {
pub fn as_query_param(&self) -> &'static str {
match self {
ExecutionLocation::Local => "LOCAL",
ExecutionLocation::Remote => "REMOTE",
}
}
}
/// Artifact type filter values accepted by the public API.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ArtifactType {
Plan,
PullRequest,
Screenshot,
File,
}
impl ArtifactType {
pub fn as_query_param(&self) -> &'static str {
match self {
ArtifactType::Plan => "PLAN",
ArtifactType::PullRequest => "PULL_REQUEST",
ArtifactType::Screenshot => "SCREENSHOT",
ArtifactType::File => "FILE",
}
}
}
/// Sort-by values accepted by the public API.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RunSortBy {
UpdatedAt,
CreatedAt,
Title,
Agent,
}
impl RunSortBy {
pub fn as_query_param(&self) -> &'static str {
match self {
RunSortBy::UpdatedAt => "updated_at",
RunSortBy::CreatedAt => "created_at",
RunSortBy::Title => "title",
RunSortBy::Agent => "agent",
}
}
}
/// Sort-order values accepted by the public API.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RunSortOrder {
Asc,
Desc,
}
impl RunSortOrder {
pub fn as_query_param(&self) -> &'static str {
match self {
RunSortOrder::Asc => "asc",
RunSortOrder::Desc => "desc",
}
}
}
/// Build the path + query string for `GET /api/v1/agent/runs` from a filter.
pub(crate) fn build_list_agent_runs_url(limit: i32, filter: &TaskListFilter) -> String {
let mut url = format!("agent/runs?limit={limit}");
let mut push = |key: &str, value: &str| {
url.push('&');
url.push_str(key);
url.push('=');
url.push_str(urlencoding::encode(value).as_ref());
};
if let Some(creator_uid) = filter.creator_uid.as_deref() {
push("creator", creator_uid);
}
if let Some(updated_after) = filter.updated_after {
push("updated_after", &updated_after.to_rfc3339());
}
if let Some(created_after) = filter.created_after {
push("created_after", &created_after.to_rfc3339());
}
if let Some(created_before) = filter.created_before {
push("created_before", &created_before.to_rfc3339());
}
if let Some(states) = filter.states.as_ref() {
for state in states {
if let Some(value) = state.as_query_param() {
push("state", value);
}
}
}
if let Some(source) = filter.source.as_ref() {
push("source", source.as_str());
}
if let Some(execution_location) = filter.execution_location {
push("execution_location", execution_location.as_query_param());
}
if let Some(environment_id) = filter.environment_id.as_deref() {
push("environment_id", environment_id);
}
if let Some(skill_spec) = filter.skill_spec.as_deref() {
push("skill_spec", skill_spec);
}
if let Some(schedule_id) = filter.schedule_id.as_deref() {
push("schedule_id", schedule_id);
}
if let Some(ancestor_run_id) = filter.ancestor_run_id.as_deref() {
push("ancestor_run_id", ancestor_run_id);
}
if let Some(config_name) = filter.config_name.as_deref() {
push("name", config_name);
}
if let Some(model_id) = filter.model_id.as_deref() {
push("model_id", model_id);
}
if let Some(artifact_type) = filter.artifact_type {
push("artifact_type", artifact_type.as_query_param());
}
if let Some(search_query) = filter.search_query.as_deref() {
push("q", search_query);
}
if let Some(sort_by) = filter.sort_by {
push("sort_by", sort_by.as_query_param());
}
if let Some(sort_order) = filter.sort_order {
push("sort_order", sort_order.as_query_param());
}
if let Some(cursor) = filter.cursor.as_deref() {
push("cursor", cursor);
}
url
}
struct ListRunsResponse {
runs: Vec<AmbientAgentTask>,
}
impl<'de> serde::Deserialize<'de> for ListRunsResponse {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(serde::Deserialize)]
struct RawResponse {
runs: Vec<serde_json::Value>,
}
let raw = RawResponse::deserialize(deserializer)?;
let mut runs = Vec::with_capacity(raw.runs.len());
for task_value in raw.runs.into_iter() {
match serde_json::from_value::<AmbientAgentTask>(task_value) {
Ok(task) => runs.push(task),
Err(e) => {
// Log the error and skip this task instead of failing the entire request
report_error!(anyhow!("Failed to deserialize ambient agent task: {}", e));
}
}
}
Ok(ListRunsResponse { runs })
}
}
/// Source information for an agent skill.
#[derive(Clone, serde::Deserialize, Debug, PartialEq)]
pub struct AgentListSource {
pub owner: String,
pub name: String,
pub skill_path: String,
}
/// Environment information for an agent skill.
#[derive(Clone, serde::Deserialize, Debug, PartialEq)]
pub struct AgentListEnvironment {
pub uid: String,
pub name: String,
}
/// A variant of an agent skill.
#[derive(Clone, serde::Deserialize, Debug, PartialEq)]
pub struct AgentListVariant {
pub id: String,
pub description: String,
pub base_prompt: String,
pub source: AgentListSource,
pub environments: Vec<AgentListEnvironment>,
}
/// An agent skill item with its variants.
#[derive(Clone, serde::Deserialize, Debug, PartialEq)]
pub struct AgentListItem {
pub name: String,
pub variants: Vec<AgentListVariant>,
}
#[derive(serde::Deserialize)]
struct ListAgentsResponse {
agents: Vec<AgentListItem>,
}
#[cfg_attr(test, automock)]
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
pub trait AIClient: 'static + Send + Sync {
async fn generate_commands_from_natural_language(
&self,
prompt: String,
ai_execution_context: Option<WarpAiExecutionContext>,
) -> Result<Vec<AIGeneratedCommand>, GenerateCommandsFromNaturalLanguageError>;
async fn generate_dialogue_answer(
&self,
transcript: Vec<TranscriptPart>,
prompt: String,
ai_execution_context: Option<WarpAiExecutionContext>,
) -> anyhow::Result<GenerateDialogueResult>;
async fn generate_metadata_for_command(
&self,
command: String,
) -> Result<GeneratedCommandMetadata, GeneratedCommandMetadataError>;
async fn get_request_limit_info(&self) -> Result<RequestUsageInfo, anyhow::Error>;
async fn get_feature_model_choices(&self) -> Result<ModelsByFeature, anyhow::Error>;
/// Fetches the free-tier available models without requiring authentication.
/// Used during pre-login onboarding so logged-out users see an accurate model list
/// instead of the hard-coded `ModelsByFeature::default()` fallback.
async fn get_free_available_models(
&self,
referrer: Option<String>,
) -> Result<ModelsByFeature, anyhow::Error>;
async fn update_merkle_tree(
&self,
embedding_config: EmbeddingConfig,
nodes: Vec<IntermediateNode>,
) -> anyhow::Result<HashMap<NodeHash, bool>>;
async fn generate_code_embeddings(
&self,
embedding_config: EmbeddingConfig,
fragments: Vec<full_source_code_embedding::Fragment>,
root_hash: NodeHash,
repo_metadata: RepoMetadata,
) -> anyhow::Result<HashMap<ContentHash, bool>>;
async fn provide_negative_feedback_response_for_ai_conversation(
&self,
conversation_id: String,
request_ids: Vec<String>,
) -> anyhow::Result<i32, anyhow::Error>;
async fn create_agent_task(
&self,
prompt: String,
environment_uid: Option<String>,
parent_run_id: Option<String>,
config: Option<AgentConfigSnapshot>,
) -> anyhow::Result<AmbientAgentTaskId, anyhow::Error>;
async fn update_agent_task(
&self,
task_id: AmbientAgentTaskId,
task_state: Option<AgentTaskState>,
session_id: Option<session_sharing_protocol::common::SessionId>,
conversation_id: Option<String>,
status_message: Option<TaskStatusUpdate>,
) -> anyhow::Result<(), anyhow::Error>;
async fn spawn_agent(
&self,
request: SpawnAgentRequest,
) -> anyhow::Result<SpawnAgentResponse, anyhow::Error>;
async fn list_ambient_agent_tasks(
&self,
limit: i32,
filter: TaskListFilter,
) -> anyhow::Result<Vec<AmbientAgentTask>, anyhow::Error>;
/// List agent runs and return the raw server JSON response.
async fn list_agent_runs_raw(
&self,
limit: i32,
filter: TaskListFilter,
) -> anyhow::Result<serde_json::Value, anyhow::Error>;
async fn get_ambient_agent_task(
&self,
task_id: &AmbientAgentTaskId,
) -> anyhow::Result<AmbientAgentTask, anyhow::Error>;
/// Fetch a single agent run and return the raw server JSON response.
async fn get_agent_run_raw(
&self,
task_id: &AmbientAgentTaskId,
) -> anyhow::Result<serde_json::Value, anyhow::Error>;
async fn get_scheduled_agent_history(
&self,
schedule_id: &str,
) -> anyhow::Result<ScheduledAgentHistory, anyhow::Error>;
async fn get_ai_conversation(
&self,
server_conversation_token: ServerConversationToken,
) -> anyhow::Result<(ConversationData, ServerAIConversationMetadata), anyhow::Error>;
async fn list_ai_conversation_metadata(
&self,
conversation_ids: Option<Vec<String>>,
) -> anyhow::Result<Vec<ServerAIConversationMetadata>>;
async fn get_ai_conversation_format(
&self,
server_conversation_token: ServerConversationToken,
) -> anyhow::Result<AIAgentConversationFormat, anyhow::Error>;
async fn get_block_snapshot(
&self,
server_conversation_token: ServerConversationToken,
) -> anyhow::Result<SerializedBlock, anyhow::Error>;
async fn delete_ai_conversation(
&self,
server_conversation_token: String,
) -> anyhow::Result<(), anyhow::Error>;
async fn list_agents(
&self,
repo: Option<String>,
) -> anyhow::Result<Vec<AgentListItem>, anyhow::Error>;
async fn cancel_ambient_agent_task(
&self,
task_id: &AmbientAgentTaskId,
) -> anyhow::Result<(), anyhow::Error>;
async fn get_task_attachments(
&self,
task_id: String,
) -> anyhow::Result<Vec<TaskAttachment>, anyhow::Error>;
async fn create_file_artifact_upload_target(
&self,
request: CreateFileArtifactUploadRequest,
) -> anyhow::Result<CreateFileArtifactUploadResponse, anyhow::Error>;
async fn confirm_file_artifact_upload(
&self,
artifact_uid: String,
checksum: String,
) -> anyhow::Result<FileArtifactRecord, anyhow::Error>;
async fn get_artifact_download(
&self,
artifact_uid: &str,
) -> anyhow::Result<ArtifactDownloadResponse, anyhow::Error>;
async fn prepare_attachments_for_upload(
&self,
task_id: &AmbientAgentTaskId,
files: &[AttachmentFileInfo],
) -> anyhow::Result<PrepareAttachmentUploadsResponse, anyhow::Error>;
async fn download_task_attachments(
&self,
task_id: &AmbientAgentTaskId,
attachment_ids: &[String],
) -> anyhow::Result<DownloadAttachmentsResponse, anyhow::Error>;
async fn get_handoff_snapshot_attachments(
&self,
task_id: &AmbientAgentTaskId,
) -> anyhow::Result<Vec<TaskAttachment>, anyhow::Error>;
// --- Orchestrations V2 messaging ---
async fn send_agent_message(
&self,
request: SendAgentMessageRequest,
) -> anyhow::Result<SendAgentMessageResponse, anyhow::Error>;
async fn list_agent_messages(
&self,
run_id: &str,
request: ListAgentMessagesRequest,
) -> anyhow::Result<Vec<AgentMessageHeader>, anyhow::Error>;
/// Persists the latest observed event sequence number for a run on the
/// server. Used to keep the server-side cursor in sync with the client so
/// that driver/cloud restores can resume without replaying events the
/// parent has already acted on.
async fn update_event_sequence_on_server(
&self,
run_id: &str,
sequence: i64,
) -> anyhow::Result<(), anyhow::Error>;
async fn report_agent_event(
&self,
run_id: &str,
request: ReportAgentEventRequest,
) -> anyhow::Result<ReportAgentEventResponse, anyhow::Error>;
async fn mark_message_delivered(&self, message_id: &str) -> anyhow::Result<(), anyhow::Error>;
async fn read_agent_message(
&self,
message_id: &str,
) -> anyhow::Result<ReadAgentMessageResponse, anyhow::Error>;
/// Fetch a normalized conversation by conversation ID.
async fn get_public_conversation(
&self,
conversation_id: &str,
) -> anyhow::Result<serde_json::Value, anyhow::Error>;
/// Fetch a normalized conversation by run ID.
async fn get_run_conversation(
&self,
run_id: &str,
) -> anyhow::Result<serde_json::Value, anyhow::Error>;
/// Generates AI copy for code-review flows: commit messages at dialog-open
/// time and PR titles / bodies at confirm time. `output_type` in the
/// request picks which of the three the server returns.
async fn generate_code_review_content(
&self,
request: GenerateCodeReviewContentRequest,
) -> Result<GenerateCodeReviewContentResponse, anyhow::Error>;
}
fn into_file_artifact_record(
artifact: galaxy_graphql::mutations::create_file_artifact_upload_target::FileArtifact,
) -> FileArtifactRecord {
FileArtifactRecord {
artifact_uid: artifact.artifact_uid.into_inner(),
filepath: artifact.filepath,
description: artifact.description,
mime_type: artifact.mime_type,
size_bytes: artifact.size_bytes,
}
}
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
impl AIClient for ServerApi {
async fn generate_commands_from_natural_language(
&self,
prompt: String,
// TODO: use relevant context from RequestContext and deprecate usage of ai_execution_context
_ai_execution_context: Option<WarpAiExecutionContext>,
) -> Result<Vec<AIGeneratedCommand>, GenerateCommandsFromNaturalLanguageError> {
let default_err = GenerateCommandsFromNaturalLanguageError::Other;
let variables = GenerateCommandsVariables {
input: GenerateCommandsInput { prompt },
request_context: get_request_context(),
};
let operation = GenerateCommands::build(variables);
let response = self
.send_graphql_request(
operation,
Some(Duration::from_secs(AI_ASSISTANT_REQUEST_TIMEOUT_SECONDS)),
)
.await
.map_err(|_| default_err)?;
match response.generate_commands {
GenerateCommandsResult::GenerateCommandsOutput(output) => match output.status {
GenerateCommandsStatus::GenerateCommandsSuccess(success) => {
Ok(success.commands.into_iter().map(Into::into).collect_vec())
}
GenerateCommandsStatus::GenerateCommandsFailure(failure) => {
Err(failure.type_.into())
}
GenerateCommandsStatus::Unknown => {
Err(GenerateCommandsFromNaturalLanguageError::Other)
}
},
_ => Err(GenerateCommandsFromNaturalLanguageError::Other),
}
}
async fn generate_dialogue_answer(
&self,
transcript: Vec<TranscriptPart>,
prompt: String,
// TODO: use relevant context from RequestContext and deprecate usage of ai_execution_context
_ai_execution_context: Option<WarpAiExecutionContext>,
) -> anyhow::Result<GenerateDialogueResult> {
let graphql_transcript: Vec<TranscriptPartGraphql> = transcript
.into_iter()
.map(|part| TranscriptPartGraphql {
user: part.raw_user_prompt().to_string(),
assistant: part.raw_assistant_answer().to_string(),
})
.collect();
let variables = GenerateDialogueVariables {
input: GenerateDialogueInput {
transcript: graphql_transcript,
prompt,
},
request_context: get_request_context(),
};
let operation = GenerateDialogue::build(variables);
let response = self
.send_graphql_request(
operation,
Some(Duration::from_secs(AI_ASSISTANT_REQUEST_TIMEOUT_SECONDS)),
)
.await?;
match response.generate_dialogue {
GenerateDialogueResultGraphql::GenerateDialogueOutput(output) => match output.status {
GenerateDialogueStatus::GenerateDialogueSuccess(success) => {
Ok(GenerateDialogueResult::Success {
answer: success.answer,
truncated: success.truncated,
request_limit_info: success.request_limit_info.into(),
transcript_summarized: success.transcript_summarized,
})
}
GenerateDialogueStatus::GenerateDialogueFailure(failure) => {
Ok(GenerateDialogueResult::Failure {
request_limit_info: failure.request_limit_info.into(),
})
}
GenerateDialogueStatus::Unknown => Err(anyhow!("failed to generate AI dialogue")),
},
GenerateDialogueResultGraphql::UserFacingError(e) => {
Err(anyhow!(get_user_facing_error_message(e)))
}
GenerateDialogueResultGraphql::Unknown => {
Err(anyhow!("failed to generate AI dialogue"))
}
}
}
async fn generate_metadata_for_command(
&self,
command: String,
) -> Result<GeneratedCommandMetadata, GeneratedCommandMetadataError> {
let default_err = GeneratedCommandMetadataError::Other;
let variables = GenerateMetadataForCommandVariables {
input: GenerateMetadataForCommandInput { command },
request_context: get_request_context(),
};
let operation = GenerateMetadataForCommand::build(variables);
let response = self
.send_graphql_request(
operation,
Some(Duration::from_secs(AI_ASSISTANT_REQUEST_TIMEOUT_SECONDS)),
)
.await
.map_err(|_| default_err)?;
match response.generate_metadata_for_command {
GenerateMetadataForCommandResult::GenerateMetadataForCommandOutput(output) => {
match output.status {
GenerateMetadataForCommandStatus::GenerateMetadataForCommandSuccess(
success,
) => Ok(success.into()),
GenerateMetadataForCommandStatus::GenerateMetadataForCommandFailure(
failure,
) => Err(failure.type_.into()),
GenerateMetadataForCommandStatus::Unknown => {
Err(GeneratedCommandMetadataError::Other)
}
}
}
_ => Err(GeneratedCommandMetadataError::Other),
}
}
#[cfg(feature = "agent_mode_evals")]
async fn get_request_limit_info(&self) -> Result<RequestUsageInfo, anyhow::Error> {
Ok(RequestUsageInfo {
request_limit_info: RequestLimitInfo::new_for_evals(),
bonus_grants: vec![],
})
}
#[cfg(not(feature = "agent_mode_evals"))]
async fn get_request_limit_info(&self) -> Result<RequestUsageInfo, anyhow::Error> {
let variables = GetRequestLimitInfoVariables {
request_context: get_request_context(),
};
let operation = GetRequestLimitInfo::build(variables);
let response = self.send_graphql_request(operation, None).await?;
match response.user {
galaxy_graphql::queries::get_request_limit_info::UserResult::UserOutput(
user_output,
) => {
let request_limit_info = user_output.user.request_limit_info.into();
let workspace_bonus_grants = user_output
.user
.workspaces
.into_iter()
.filter(|workspace| workspace.uid != PLACEHOLDER_WORKSPACE_UID.into())
.flat_map(|workspace| {
let workspace_uid =
WorkspaceUid::from(ServerId::from_string_lossy(workspace.uid.inner()));
workspace
.bonus_grants_info
.grants
.into_iter()
.map(move |grant| {
BonusGrant::from_gql_bonus_grant(
grant,
BonusGrantScope::Workspace(workspace_uid),
)
})
});
let bonus_grants: Vec<BonusGrant> = user_output
.user
.bonus_grants
.into_iter()
.map(|grant| BonusGrant::from_gql_bonus_grant(grant, BonusGrantScope::User))
.chain(workspace_bonus_grants)
.collect();
Ok(RequestUsageInfo {
request_limit_info,
bonus_grants,
})
}
galaxy_graphql::queries::get_request_limit_info::UserResult::UserFacingError(e) => {
Err(anyhow!(get_user_facing_error_message(e)))
}
galaxy_graphql::queries::get_request_limit_info::UserResult::Unknown => {
Err(anyhow!("failed to get request limit info"))
}
}
}
async fn get_feature_model_choices(&self) -> Result<ModelsByFeature, anyhow::Error> {
let variables = GetFeatureModelChoicesVariables {
request_context: get_request_context(),
};
let operation = GetFeatureModelChoices::build(variables);
let response = self.send_graphql_request(operation, None).await?;
match response.user {
galaxy_graphql::queries::get_feature_model_choices::UserResult::UserOutput(
galaxy_graphql::queries::get_feature_model_choices::UserOutput {
user:
galaxy_graphql::queries::get_feature_model_choices::User { mut workspaces },
},
) if !workspaces.is_empty() => {
// This is safe (`remove()` can panic) because we ensure workspaces is non-empty
// above.
workspaces.remove(0).feature_model_choice.try_into()
}
_ => Err(anyhow!("Failed to get available feature model choices")),
}
}
async fn get_free_available_models(
&self,
referrer: Option<String>,
) -> Result<ModelsByFeature, anyhow::Error> {
// This resolver is public; it does not require an auth token. We must NOT go through
// `send_graphql_request`, which awaits `get_or_refresh_access_token()`
let variables = FreeAvailableModelsVariables {
input: FreeAvailableModelsInput { referrer },
request_context: get_request_context(),
};
let operation = FreeAvailableModels::build(variables);
// Best-effort: if the user has a valid token (e.g. anonymous Firebase), include it;
// otherwise send unauthenticated. Either is acceptable for this resolver.
let auth_token = self
.get_or_refresh_access_token()
.await
.ok()
.and_then(|token| token.bearer_token());
let response = operation
.send_request(
self.client.clone(),
galaxy_graphql::client::RequestOptions {
auth_token,
..default_request_options()
},
)
.await?
.data
.ok_or_else(|| anyhow!("Missing data in freeAvailableModels response"))?;
match response.free_available_models {
FreeAvailableModelsResult::FreeAvailableModelsOutput(output) => {
output.feature_model_choice.try_into()
}
FreeAvailableModelsResult::Unknown => {
Err(anyhow!("Unexpected freeAvailableModels response variant"))
}
}
}
async fn update_merkle_tree(
&self,
embedding_config: EmbeddingConfig,
nodes: Vec<IntermediateNode>,
) -> anyhow::Result<HashMap<NodeHash, bool>> {
let nodes = nodes
.into_iter()
.map(|node| MerkleTreeNode {
hash: node.hash.into(),
children: node.children.into_iter().map(Into::into).collect(),
})
.collect_vec();
let variables = UpdateMerkleTreeVariables {
input: UpdateMerkleTreeInput {
embedding_config: embedding_config.into(),
nodes,
},
request_context: get_request_context(),
};
let operation = UpdateMerkleTree::build(variables);
let response = self.send_graphql_request(operation, None).await?;
match response.update_merkle_tree {
UpdateMerkleTreeResult::UpdateMerkleTreeOutput(output) => {
let mut node_results = HashMap::with_capacity(output.results.len());
for result in output.results {
node_results.insert(result.hash.try_into()?, result.success);
}
Ok(node_results)
}
UpdateMerkleTreeResult::UpdateMerkleTreeError(e) => Err(anyhow!(e.error)),
UpdateMerkleTreeResult::UserFacingError(e) => {
Err(anyhow!(get_user_facing_error_message(e)))
}
UpdateMerkleTreeResult::Unknown => Err(anyhow!("failed to update merkle tree")),
}
}
async fn generate_code_embeddings(
&self,
embedding_config: EmbeddingConfig,
fragments: Vec<full_source_code_embedding::Fragment>,
root_hash: NodeHash,
repo_metadata: RepoMetadata,
) -> anyhow::Result<HashMap<ContentHash, bool>> {
let variables = GenerateCodeEmbeddingsVariables {
input: GenerateCodeEmbeddingsInput {
embedding_config: embedding_config.into(),
fragments: fragments.into_iter().map(Into::into).collect(),
repo_metadata: repo_metadata.into(),
root_hash: root_hash.into(),
},
request_context: get_request_context(),
};
let operation = GenerateCodeEmbeddings::build(variables);
let response = self.send_graphql_request(operation, None).await?;
match response.generate_code_embeddings {
GenerateCodeEmbeddingsResult::GenerateCodeEmbeddingsOutput(output) => {
let mut results = HashMap::with_capacity(output.embedding_results.len());
for result in output.embedding_results {
results.insert(result.hash.try_into()?, result.success);
}
Ok(results)
}
GenerateCodeEmbeddingsResult::GenerateCodeEmbeddingsError(e) => Err(anyhow!(e.error)),
GenerateCodeEmbeddingsResult::UserFacingError(e) => {
Err(anyhow!(get_user_facing_error_message(e)))
}
GenerateCodeEmbeddingsResult::Unknown => {
Err(anyhow!("failed to generate code embeddings"))
}
}
}
async fn provide_negative_feedback_response_for_ai_conversation(
&self,
conversation_id: String,
request_ids: Vec<String>,
) -> anyhow::Result<i32, anyhow::Error> {
let variables = ProvideNegativeFeedbackResponseForAiConversationVariables {
input: ProvideNegativeFeedbackResponseForAiConversationInput {
conversation_id: conversation_id.into(),
request_ids: request_ids.into_iter().map(Into::into).collect(),
},
request_context: get_request_context(),
};
let operation = ProvideNegativeFeedbackResponseForAiConversation::build(variables);
let response = self.send_graphql_request(operation, None).await?;
match response.provide_negative_feedback_response_for_ai_conversation {
RequestsRefundedResult::RequestsRefundedOutput(output) => Ok(output.requests_refunded),
RequestsRefundedResult::UserFacingError(e) => {
Err(anyhow!(get_user_facing_error_message(e)))
}
RequestsRefundedResult::Unknown => Err(anyhow!(
"failed to provide negative feedback response for ai conversation"
)),
}
}
async fn create_agent_task(
&self,
prompt: String,
environment_uid: Option<String>,
parent_run_id: Option<String>,
config: Option<AgentConfigSnapshot>,
) -> anyhow::Result<AmbientAgentTaskId, anyhow::Error> {
// Serialize the config to JSON if provided
let agent_config_snapshot = config
.map(|c| serde_json::to_string(&c))
.transpose()
.map_err(|e| anyhow!("Failed to serialize agent config: {e}"))?;
let variables = CreateAgentTaskVariables {
input: CreateAgentTaskInput {
prompt,
environment_uid: environment_uid.map(|uid| uid.into()),
parent_run_id: parent_run_id.map(|run_id| run_id.into()),
agent_config_snapshot,
},
request_context: get_request_context(),
};
let operation = CreateAgentTask::build(variables);
let response = self.send_graphql_request(operation, None).await?;
match response.create_agent_task {
CreateAgentTaskResult::CreateAgentTaskOutput(output) => output
.task_id
.into_inner()
.parse()
.map_err(|e| anyhow!("Failed to parse task ID from server: {e}")),
CreateAgentTaskResult::UserFacingError(e) => {
Err(anyhow!(get_user_facing_error_message(e)))
}
CreateAgentTaskResult::Unknown => Err(anyhow!("failed to create agent task")),
}
}
async fn update_agent_task(
&self,
task_id: AmbientAgentTaskId,
task_state: Option<AgentTaskState>,
session_id: Option<session_sharing_protocol::common::SessionId>,
conversation_id: Option<String>,
status_message: Option<TaskStatusUpdate>,
) -> anyhow::Result<(), anyhow::Error> {
let variables = UpdateAgentTaskVariables {
input: UpdateAgentTaskInput {
task_id: task_id.into(),
task_state,
session_id: session_id.map(|id| id.to_string().into()),
conversation_id: conversation_id.map(|id| id.into()),
status_message: status_message.map(|update| AgentTaskStatusMessageInput {
message: update.message,
error_code: update.error_code,
}),
},
request_context: get_request_context(),
};
let operation = UpdateAgentTask::build(variables);
let response = self.send_graphql_request(operation, None).await?;
match response.update_agent_task {
UpdateAgentTaskResult::UpdateAgentTaskOutput(_) => Ok(()),
UpdateAgentTaskResult::UserFacingError(e) => {
Err(anyhow!(get_user_facing_error_message(e)))
}
UpdateAgentTaskResult::Unknown => Err(anyhow!("failed to update agent task")),
}
}
async fn spawn_agent(
&self,
request: SpawnAgentRequest,
) -> anyhow::Result<SpawnAgentResponse, anyhow::Error> {
let response: SpawnAgentResponse = self.post_public_api("agent/run", &request).await?;
Ok(response)
}
async fn list_ambient_agent_tasks(
&self,
limit: i32,
filter: TaskListFilter,
) -> anyhow::Result<Vec<AmbientAgentTask>, anyhow::Error> {
let url = build_list_agent_runs_url(limit, &filter);
let response: ListRunsResponse = self.get_public_api(&url).await?;
Ok(response.runs)
}
async fn list_agent_runs_raw(
&self,
limit: i32,
filter: TaskListFilter,
) -> anyhow::Result<serde_json::Value, anyhow::Error> {
let url = build_list_agent_runs_url(limit, &filter);
let response: serde_json::Value = self.get_public_api(&url).await?;
Ok(response)
}
async fn get_ambient_agent_task(
&self,
task_id: &AmbientAgentTaskId,
) -> anyhow::Result<AmbientAgentTask, anyhow::Error> {
let response: AmbientAgentTask = self
.get_public_api(&format!("agent/runs/{task_id}"))
.await?;
Ok(response)
}
async fn get_agent_run_raw(
&self,
task_id: &AmbientAgentTaskId,
) -> anyhow::Result<serde_json::Value, anyhow::Error> {
let response: serde_json::Value = self
.get_public_api(&format!("agent/runs/{task_id}"))
.await?;
Ok(response)
}
async fn get_scheduled_agent_history(
&self,
schedule_id: &str,
) -> anyhow::Result<ScheduledAgentHistory, anyhow::Error> {
let variables = GetScheduledAgentHistoryVariables {
request_context: get_request_context(),
input: ScheduledAgentHistoryInput {
schedule_id: schedule_id.to_string().into(),
},
};
let operation = GetScheduledAgentHistory::build(variables);
let response = self.send_graphql_request(operation, None).await?;
match response.scheduled_agent_history {
ScheduledAgentHistoryResult::ScheduledAgentHistoryOutput(output) => Ok(output.history),
ScheduledAgentHistoryResult::UserFacingError(e) => {
Err(anyhow!(get_user_facing_error_message(e)))
}
ScheduledAgentHistoryResult::Unknown => {
Err(anyhow!("failed to get scheduled agent history"))
}
}
}
async fn get_ai_conversation(
&self,
server_conversation_token: ServerConversationToken,
) -> anyhow::Result<(ConversationData, ServerAIConversationMetadata), anyhow::Error> {
use galaxy_graphql::queries::list_ai_conversations::{
ListAIConversations, ListAIConversationsInput, ListAIConversationsResult,
ListAIConversationsVariables,
};
let conversation_id = server_conversation_token.as_str().to_string();
let operation = ListAIConversations::build(ListAIConversationsVariables {
input: ListAIConversationsInput {
conversation_ids: Some(vec![cynic::Id::new(conversation_id)]),
},
request_context: get_request_context(),
});
let response = self.send_graphql_request(operation, None).await?;
let gql_conversation = match response.list_ai_conversations {
ListAIConversationsResult::ListAIConversationsOutput(output) => output
.conversations
.into_iter()
.next()
.ok_or_else(|| anyhow!("Conversation not found"))?,
ListAIConversationsResult::UserFacingError(e) => {
return Err(anyhow!(get_user_facing_error_message(e)));
}
ListAIConversationsResult::Unknown => {
return Err(anyhow!("Failed to get AI conversation"));
}
};
let conversation_data_bytes = base64::engine::general_purpose::STANDARD
.decode(&gql_conversation.final_task_list)
.map_err(|e| anyhow!("Failed to decode base64 conversation data: {e}"))?;
let conversation_data = ConversationData::decode(conversation_data_bytes.as_slice())
.map_err(|e| anyhow!("Failed to decode proto ConversationData: {e}"))?;
// Build AIConversationMetadata from GraphQL response
let metadata = gql_conversation.try_into()?;
Ok((conversation_data, metadata))
}
async fn list_ai_conversation_metadata(
&self,
conversation_ids: Option<Vec<String>>,
) -> anyhow::Result<Vec<ServerAIConversationMetadata>> {
if !FeatureFlag::CloudConversations.is_enabled() {
return Ok(vec![]);
}
use galaxy_graphql::queries::list_ai_conversations::{
ListAIConversationMetadata, ListAIConversationMetadataResult,
ListAIConversationMetadataVariables, ListAIConversationsInput,
};
let input = ListAIConversationsInput {
conversation_ids: conversation_ids
.map(|ids| ids.into_iter().map(cynic::Id::new).collect()),
};
let variables = ListAIConversationMetadataVariables {
input,
request_context: get_request_context(),
};
let operation = ListAIConversationMetadata::build(variables);
let response = self.send_graphql_request(operation, None).await?;
match response.list_ai_conversations {
ListAIConversationMetadataResult::ListAIConversationsOutput(output) => {
let metadata_vec: Result<Vec<_>, _> = output
.conversations
.into_iter()
.map(|conv| conv.try_into())
.collect();
metadata_vec
}
ListAIConversationMetadataResult::UserFacingError(e) => {
Err(anyhow!(get_user_facing_error_message(e)))
}
ListAIConversationMetadataResult::Unknown => {
Err(anyhow!("Failed to list AI conversations metadata"))
}
}
}
async fn get_ai_conversation_format(
&self,
server_conversation_token: ServerConversationToken,
) -> anyhow::Result<AIAgentConversationFormat, anyhow::Error> {
use galaxy_graphql::queries::get_ai_conversation_format::{
GetAIConversationFormat, GetAIConversationFormatResult,
GetAIConversationFormatVariables,
};
use galaxy_graphql::queries::list_ai_conversations::ListAIConversationsInput;
let conversation_id = server_conversation_token.as_str().to_string();
let operation = GetAIConversationFormat::build(GetAIConversationFormatVariables {
input: ListAIConversationsInput {
conversation_ids: Some(vec![cynic::Id::new(conversation_id)]),
},
request_context: get_request_context(),
});
let response = self.send_graphql_request(operation, None).await?;
match response.list_ai_conversations {
GetAIConversationFormatResult::ListAIConversationsOutput(output) => {
let conversation = output
.conversations
.into_iter()
.next()
.ok_or_else(|| anyhow!("Conversation not found"))?;
Ok(convert_conversation_format(conversation.format))
}
GetAIConversationFormatResult::UserFacingError(e) => {
Err(anyhow!(get_user_facing_error_message(e)))
}
GetAIConversationFormatResult::Unknown => {
Err(anyhow!("Failed to get AI conversation format"))
}
}
}
async fn get_block_snapshot(
&self,
server_conversation_token: ServerConversationToken,
) -> anyhow::Result<SerializedBlock, anyhow::Error> {
let conversation_id = server_conversation_token.as_str();
// Make sure to use `SerializedBlock::from_json` to correctly handle the serialized
// command and output grid contents.
let response = self
.get_public_api_response(&format!(
"agent/conversations/{conversation_id}/block-snapshot"
))
.await?;
let json_bytes = response
.bytes()
.await
.map_err(|e| anyhow!("Failed to read block snapshot for {conversation_id}: {e}"))?;
SerializedBlock::from_json(&json_bytes)
}
async fn delete_ai_conversation(
&self,
server_conversation_token: String,
) -> anyhow::Result<(), anyhow::Error> {
let variables = DeleteAIConversationVariables {
input: DeleteConversationInput {
conversation_id: server_conversation_token.into(),
},
request_context: get_request_context(),
};
let operation = DeleteAIConversation::build(variables);
let response = self.send_graphql_request(operation, None).await?;
match response.delete_conversation {
DeleteConversationResult::DeleteConversationOutput(_) => Ok(()),
DeleteConversationResult::UserFacingError(e) => {
Err(anyhow!(get_user_facing_error_message(e)))
}
DeleteConversationResult::Unknown => Err(anyhow!("Failed to delete AI conversation")),
}
}
async fn list_agents(
&self,
repo: Option<String>,
) -> anyhow::Result<Vec<AgentListItem>, anyhow::Error> {
let path = match repo {
Some(repo) => format!("agent?repo={}", urlencoding::encode(&repo)),
None => "agent".to_string(),
};
let response: ListAgentsResponse = self.get_public_api(&path).await?;
Ok(response.agents)
}
async fn cancel_ambient_agent_task(
&self,
task_id: &AmbientAgentTaskId,
) -> anyhow::Result<(), anyhow::Error> {
let _: String = self
.post_public_api(&format!("agent/tasks/{task_id}/cancel"), &())
.await?;
Ok(())
}
async fn get_task_attachments(
&self,
task_id: String,
) -> anyhow::Result<Vec<TaskAttachment>, anyhow::Error> {
let variables = TaskVariables {
input: TaskInput {
task_id: cynic::Id::new(task_id),
},
request_context: get_request_context(),
};
let operation = TaskAttachmentsQuery::build(variables);
let response = self.send_graphql_request(operation, None).await?;
match response.task {
TaskResult::TaskOutput(output) => {
let attachments = output
.task
.attachments
.into_iter()
.map(|att| TaskAttachment {
file_id: att.file_id.into_inner(),
filename: att.filename,
download_url: att.download_url,
mime_type: att.mime_type,
})
.collect();
Ok(attachments)
}
TaskResult::UserFacingError(error) => {
Err(anyhow!(get_user_facing_error_message(error)))
}
TaskResult::Unknown => Err(anyhow!("Failed to fetch task attachments")),
}
}
async fn create_file_artifact_upload_target(
&self,
request: CreateFileArtifactUploadRequest,
) -> anyhow::Result<CreateFileArtifactUploadResponse, anyhow::Error> {
let variables = CreateFileArtifactUploadTargetVariables {
input: CreateFileArtifactUploadTargetInput {
conversation_id: request.conversation_id.map(cynic::Id::new),
run_id: request.run_id.map(cynic::Id::new),
filepath: request.filepath,
description: request.description,
mime_type: request.mime_type,
size_bytes: request.size_bytes,
},
request_context: get_request_context(),
};
let operation = CreateFileArtifactUploadTarget::build(variables);
let response = self.send_graphql_request(operation, None).await?;
match response.create_file_artifact_upload_target {
CreateFileArtifactUploadTargetResult::CreateFileArtifactUploadTargetOutput(output) => {
Ok(CreateFileArtifactUploadResponse {
artifact: into_file_artifact_record(output.artifact),
upload_target: FileArtifactUploadTargetInfo {
url: output.upload_target.url,
method: output.upload_target.method,
headers: output
.upload_target
.headers
.into_iter()
.map(|header| FileArtifactUploadHeaderInfo {
name: header.name,
value: header.value,
})
.collect(),
},
})
}
CreateFileArtifactUploadTargetResult::UserFacingError(error) => {
Err(anyhow!(get_user_facing_error_message(error)))
}
CreateFileArtifactUploadTargetResult::Unknown => {
Err(anyhow!("Failed to create file artifact upload target"))
}
}
}
async fn confirm_file_artifact_upload(
&self,
artifact_uid: String,
checksum: String,
) -> anyhow::Result<FileArtifactRecord, anyhow::Error> {
let variables = ConfirmFileArtifactUploadVariables {
input: ConfirmFileArtifactUploadInput {
artifact_uid: cynic::Id::new(artifact_uid),
checksum,
},
request_context: get_request_context(),
};
let operation = ConfirmFileArtifactUpload::build(variables);
let response = self.send_graphql_request(operation, None).await?;
match response.confirm_file_artifact_upload {
ConfirmFileArtifactUploadResult::ConfirmFileArtifactUploadOutput(output) => {
Ok(into_file_artifact_record(output.artifact))
}
ConfirmFileArtifactUploadResult::UserFacingError(error) => {
Err(anyhow!(get_user_facing_error_message(error)))
}
ConfirmFileArtifactUploadResult::Unknown => {
Err(anyhow!("Failed to confirm file artifact upload"))
}
}
}
async fn get_artifact_download(
&self,
artifact_uid: &str,
) -> anyhow::Result<ArtifactDownloadResponse, anyhow::Error> {
let response: ArtifactDownloadResponse = self
.get_public_api(&format!("agent/artifacts/{artifact_uid}"))
.await?;
Ok(response)
}
async fn prepare_attachments_for_upload(
&self,
task_id: &AmbientAgentTaskId,
files: &[AttachmentFileInfo],
) -> anyhow::Result<PrepareAttachmentUploadsResponse, anyhow::Error> {
let request = PrepareAttachmentUploadsRequest {
files: files.to_vec(),
};
let response: PrepareAttachmentUploadsResponse = self
.post_public_api(
&format!("agent/runs/{task_id}/attachments/prepare"),
&request,
)
.await?;
Ok(response)
}
async fn download_task_attachments(
&self,
task_id: &AmbientAgentTaskId,
attachment_ids: &[String],
) -> anyhow::Result<DownloadAttachmentsResponse, anyhow::Error> {
let request = DownloadAttachmentsRequest {
attachment_ids: attachment_ids.to_vec(),
};
let response: DownloadAttachmentsResponse = self
.post_public_api(
&format!("agent/runs/{task_id}/attachments/download"),
&request,
)
.await?;
Ok(response)
}
async fn get_handoff_snapshot_attachments(
&self,
task_id: &AmbientAgentTaskId,
) -> anyhow::Result<Vec<TaskAttachment>, anyhow::Error> {
let response: ListHandoffSnapshotAttachmentsResponse = self
.get_public_api(&format!("agent/runs/{task_id}/handoff/attachments"))
.await?;
Ok(response
.attachments
.into_iter()
.map(|attachment| TaskAttachment {
file_id: attachment.attachment_id,
filename: attachment.filename,
download_url: attachment.download_url,
mime_type: attachment
.mime_type
.unwrap_or_else(|| "application/octet-stream".to_string()),
})
.collect())
}
// --- Orchestrations V2 messaging ---
async fn send_agent_message(
&self,
request: SendAgentMessageRequest,
) -> anyhow::Result<SendAgentMessageResponse, anyhow::Error> {
let response: SendAgentMessageResponse =
self.post_public_api("agent/messages", &request).await?;
Ok(response)
}
async fn list_agent_messages(
&self,
run_id: &str,
request: ListAgentMessagesRequest,
) -> anyhow::Result<Vec<AgentMessageHeader>, anyhow::Error> {
let mut params = vec![format!("limit={}", request.limit)];
if request.unread_only {
params.push("unread=true".to_string());
}
if let Some(since) = request.since {
params.push(format!("since={}", urlencoding::encode(&since)));
}
let path = format!("agent/messages/{run_id}?{}", params.join("&"));
let response: Vec<AgentMessageHeader> = self.get_public_api(&path).await?;
Ok(response)
}
async fn update_event_sequence_on_server(
&self,
run_id: &str,
sequence: i64,
) -> anyhow::Result<(), anyhow::Error> {
#[derive(serde::Serialize)]
struct UpdateBody {
sequence: i64,
}
self.patch_public_api_unit(
&format!("agent/runs/{run_id}/event-sequence"),
&UpdateBody { sequence },
)
.await
}
async fn report_agent_event(
&self,
run_id: &str,
request: ReportAgentEventRequest,
) -> anyhow::Result<ReportAgentEventResponse, anyhow::Error> {
let response: ReportAgentEventResponse = self
.post_public_api(&format!("agent/events/{run_id}"), &request)
.await?;
Ok(response)
}
async fn mark_message_delivered(&self, message_id: &str) -> anyhow::Result<(), anyhow::Error> {
self.post_public_api_unit(&format!("agent/messages/{message_id}/delivered"), &())
.await
}
async fn read_agent_message(
&self,
message_id: &str,
) -> anyhow::Result<ReadAgentMessageResponse, anyhow::Error> {
let response: ReadAgentMessageResponse = self
.post_public_api(&format!("agent/messages/{message_id}/read"), &())
.await?;
Ok(response)
}
async fn get_public_conversation(
&self,
conversation_id: &str,
) -> anyhow::Result<serde_json::Value, anyhow::Error> {
let response: serde_json::Value = self
.get_public_api(&format!("agent/conversations/{conversation_id}"))
.await?;
Ok(response)
}
async fn get_run_conversation(
&self,
run_id: &str,
) -> anyhow::Result<serde_json::Value, anyhow::Error> {
let response: serde_json::Value = self
.get_public_api(&format!("agent/runs/{run_id}/conversation"))
.await?;
Ok(response)
}
async fn generate_code_review_content(
&self,
request: GenerateCodeReviewContentRequest,
) -> Result<GenerateCodeReviewContentResponse, anyhow::Error> {
let auth_token = self.get_or_refresh_access_token().await?;
let request_builder = self.client.post(format!(
"{}/ai/generate_code_review_content",
ChannelState::server_root_url()
));
let response = if let Some(token) = auth_token.as_bearer_token() {
request_builder.bearer_auth(token)
} else {
request_builder
}
.json(&request)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(response)
}
}
impl TryFrom<galaxy_graphql::queries::get_feature_model_choices::FeatureModelChoice>
for ModelsByFeature
{
type Error = anyhow::Error;
fn try_from(
value: galaxy_graphql::queries::get_feature_model_choices::FeatureModelChoice,
) -> Result<Self, Self::Error> {
Ok(Self {
agent_mode: value.agent_mode.try_into()?,
coding: value.coding.try_into()?,
cli_agent: Some(value.cli_agent.try_into()?),
computer_use: Some(value.computer_use_agent.try_into()?),
})
}
}
impl TryFrom<galaxy_graphql::workspace::FeatureModelChoice> for ModelsByFeature {
type Error = anyhow::Error;
fn try_from(value: galaxy_graphql::workspace::FeatureModelChoice) -> Result<Self, Self::Error> {
Ok(Self {
agent_mode: value.agent_mode.try_into()?,
coding: value.coding.try_into()?,
cli_agent: Some(value.cli_agent.try_into()?),
computer_use: Some(value.computer_use_agent.try_into()?),
})
}
}
impl TryFrom<galaxy_graphql::queries::get_feature_model_choices::AvailableLlms> for AvailableLLMs {
type Error = anyhow::Error;
fn try_from(
value: galaxy_graphql::queries::get_feature_model_choices::AvailableLlms,
) -> Result<Self, Self::Error> {
Self::new(
value.default_id.into(),
value.choices.into_iter().map(LLMInfo::from),
value.preferred_codex_model_id.map(Into::into),
)
}
}
impl TryFrom<galaxy_graphql::workspace::AvailableLlms> for AvailableLLMs {
type Error = anyhow::Error;
fn try_from(value: galaxy_graphql::workspace::AvailableLlms) -> Result<Self, Self::Error> {
Self::new(
value.default_id.into(),
value.choices.into_iter().map(LLMInfo::from),
value.preferred_codex_model_id.map(Into::into),
)
}
}
impl From<galaxy_graphql::queries::get_feature_model_choices::LlmInfo> for LLMInfo {
fn from(value: galaxy_graphql::queries::get_feature_model_choices::LlmInfo) -> Self {
let host_configs = {
let mut map = std::collections::HashMap::new();
for config in value.host_configs {
let config: RoutingHostConfig = config.into();
let host = config.model_routing_host.clone();
if map.insert(host.clone(), config).is_some() {
log::warn!(
"Duplicate LlmModelHost entry for {:?}, using latest value",
host
);
}
}
map
};
Self {
id: value.id.into(),
display_name: value.display_name,
base_model_name: value.base_model_name,
reasoning_level: value.reasoning_level,
usage_metadata: value.usage_metadata.into(),
description: value.description,
disable_reason: value.disable_reason.map(DisableReason::from),
vision_supported: value.vision_supported,
spec: value.spec.map(Into::into),
provider: value.provider.into(),
host_configs,
discount_percentage: value.pricing.discount_percentage.map(|v| v as f32),
}
}
}
impl From<galaxy_graphql::workspace::LlmInfo> for LLMInfo {
fn from(value: galaxy_graphql::workspace::LlmInfo) -> Self {
let host_configs = {
let mut map = std::collections::HashMap::new();
for config in value.host_configs {
let config: RoutingHostConfig = config.into();
let host = config.model_routing_host.clone();
if map.insert(host.clone(), config).is_some() {
log::warn!(
"Duplicate LlmModelHost entry for {:?}, using latest value",
host
);
}
}
map
};
Self {
id: value.id.into(),
display_name: value.display_name,
base_model_name: value.base_model_name,
reasoning_level: value.reasoning_level,
usage_metadata: value.usage_metadata.into(),
description: value.description,
disable_reason: value.disable_reason.map(DisableReason::from),
vision_supported: value.vision_supported,
spec: value.spec.map(Into::into),
provider: value.provider.into(),
host_configs,
discount_percentage: value.pricing.discount_percentage.map(|v| v as f32),
}
}
}
impl From<galaxy_graphql::queries::get_feature_model_choices::RoutingHostConfig>
for RoutingHostConfig
{
fn from(value: galaxy_graphql::queries::get_feature_model_choices::RoutingHostConfig) -> Self {
Self {
enabled: value.enabled,
model_routing_host: value.model_routing_host.into(),
}
}
}
impl From<galaxy_graphql::workspace::RoutingHostConfig> for RoutingHostConfig {
fn from(value: galaxy_graphql::workspace::RoutingHostConfig) -> Self {
Self {
enabled: value.enabled,
model_routing_host: value.model_routing_host.into(),
}
}
}
impl From<galaxy_graphql::queries::get_feature_model_choices::LlmModelHost> for LLMModelHost {
fn from(value: galaxy_graphql::queries::get_feature_model_choices::LlmModelHost) -> Self {
match value {
galaxy_graphql::queries::get_feature_model_choices::LlmModelHost::DirectApi => {
LLMModelHost::DirectApi
}
galaxy_graphql::queries::get_feature_model_choices::LlmModelHost::AwsBedrock => {
LLMModelHost::AwsBedrock
}
galaxy_graphql::queries::get_feature_model_choices::LlmModelHost::Other(value) => {
report_error!(anyhow!(
"Unknown LlmModelHost '{value}'. Make sure to update client GraphQL types!"
));
LLMModelHost::Unknown
}
}
}
}
impl From<galaxy_graphql::queries::get_feature_model_choices::LlmProvider> for LLMProvider {
fn from(value: galaxy_graphql::queries::get_feature_model_choices::LlmProvider) -> Self {
match value {
galaxy_graphql::queries::get_feature_model_choices::LlmProvider::Openai => {
LLMProvider::OpenAI
}
galaxy_graphql::queries::get_feature_model_choices::LlmProvider::Anthropic => {
LLMProvider::Anthropic
}
galaxy_graphql::queries::get_feature_model_choices::LlmProvider::Google => {
LLMProvider::Google
}
galaxy_graphql::queries::get_feature_model_choices::LlmProvider::Xai => {
LLMProvider::Xai
}
galaxy_graphql::queries::get_feature_model_choices::LlmProvider::Unknown => {
LLMProvider::Unknown
}
galaxy_graphql::queries::get_feature_model_choices::LlmProvider::Other(value) => {
report_error!(anyhow!(
"Invalid LlmProvider '{value}'. Make sure to update client GraphQL types!"
));
LLMProvider::Unknown
}
}
}
}
impl From<galaxy_graphql::workspace::LlmProvider> for LLMProvider {
fn from(value: galaxy_graphql::workspace::LlmProvider) -> Self {
match value {
galaxy_graphql::workspace::LlmProvider::Openai => LLMProvider::OpenAI,
galaxy_graphql::workspace::LlmProvider::Anthropic => LLMProvider::Anthropic,
galaxy_graphql::workspace::LlmProvider::Google => LLMProvider::Google,
galaxy_graphql::workspace::LlmProvider::Xai => LLMProvider::Xai,
galaxy_graphql::workspace::LlmProvider::Unknown => LLMProvider::Unknown,
galaxy_graphql::workspace::LlmProvider::Other(value) => {
report_error!(anyhow!(
"Invalid LlmProvider '{value}'. Make sure to update client GraphQL types!"
));
LLMProvider::Unknown
}
}
}
}
impl From<galaxy_graphql::queries::get_feature_model_choices::LlmSpec> for LLMSpec {
fn from(value: galaxy_graphql::queries::get_feature_model_choices::LlmSpec) -> Self {
Self {
cost: value.cost as f32,
quality: value.quality as f32,
speed: value.speed as f32,
}
}
}
impl From<galaxy_graphql::workspace::LlmSpec> for LLMSpec {
fn from(value: galaxy_graphql::workspace::LlmSpec) -> Self {
Self {
cost: value.cost as f32,
quality: value.quality as f32,
speed: value.speed as f32,
}
}
}
impl From<galaxy_graphql::queries::get_feature_model_choices::LlmUsageMetadata>
for LLMUsageMetadata
{
fn from(value: galaxy_graphql::queries::get_feature_model_choices::LlmUsageMetadata) -> Self {
Self {
request_multiplier: value.request_multiplier.max(1) as usize,
credit_multiplier: value.credit_multiplier.map(|v| v as f32),
}
}
}
impl From<galaxy_graphql::workspace::LlmUsageMetadata> for LLMUsageMetadata {
fn from(value: galaxy_graphql::workspace::LlmUsageMetadata) -> Self {
Self {
request_multiplier: value.request_multiplier.max(1) as usize,
credit_multiplier: value.credit_multiplier.map(|v| v as f32),
}
}
}
impl From<galaxy_graphql::queries::get_feature_model_choices::DisableReason> for DisableReason {
fn from(value: galaxy_graphql::queries::get_feature_model_choices::DisableReason) -> Self {
match value {
galaxy_graphql::queries::get_feature_model_choices::DisableReason::AdminDisabled => {
DisableReason::AdminDisabled
}
galaxy_graphql::queries::get_feature_model_choices::DisableReason::OutOfRequests => {
DisableReason::OutOfRequests
}
galaxy_graphql::queries::get_feature_model_choices::DisableReason::ProviderOutage => {
DisableReason::ProviderOutage
}
galaxy_graphql::queries::get_feature_model_choices::DisableReason::RequiresUpgrade => {
DisableReason::RequiresUpgrade
}
galaxy_graphql::queries::get_feature_model_choices::DisableReason::Other(_) => {
DisableReason::Unavailable
}
}
}
}
impl From<galaxy_graphql::workspace::DisableReason> for DisableReason {
fn from(value: galaxy_graphql::workspace::DisableReason) -> Self {
match value {
galaxy_graphql::workspace::DisableReason::AdminDisabled => DisableReason::AdminDisabled,
galaxy_graphql::workspace::DisableReason::OutOfRequests => DisableReason::OutOfRequests,
galaxy_graphql::workspace::DisableReason::ProviderOutage => {
DisableReason::ProviderOutage
}
galaxy_graphql::workspace::DisableReason::RequiresUpgrade => {
DisableReason::RequiresUpgrade
}
galaxy_graphql::workspace::DisableReason::Other(_) => DisableReason::Unavailable,
}
}
}
// Conversions for AIConversationMetadata from GraphQL types
fn convert_harness(harness: galaxy_graphql::ai::AgentHarness) -> AIAgentHarness {
match harness {
galaxy_graphql::ai::AgentHarness::Oz => AIAgentHarness::Oz,
galaxy_graphql::ai::AgentHarness::ClaudeCode => AIAgentHarness::ClaudeCode,
galaxy_graphql::ai::AgentHarness::Gemini => AIAgentHarness::Gemini,
galaxy_graphql::ai::AgentHarness::Other(value) => {
report_error!(anyhow!(
"Invalid AgentHarness '{value}'. Make sure to update client GraphQL types!"
));
AIAgentHarness::Unknown
}
}
}
fn convert_block_snapshot_format(
format: galaxy_graphql::ai::SerializedBlockFormat,
) -> AIAgentSerializedBlockFormat {
match format {
galaxy_graphql::ai::SerializedBlockFormat::JsonV1 => AIAgentSerializedBlockFormat::JsonV1,
}
}
fn convert_conversation_format(
format: galaxy_graphql::ai::AIConversationFormat,
) -> AIAgentConversationFormat {
AIAgentConversationFormat {
has_task_list: format.has_task_list,
block_snapshot: format.block_snapshot.map(convert_block_snapshot_format),
}
}
// Helper function
fn convert_usage_metadata(
summarized: bool,
context_window_usage: f64,
credits_spent: f64,
) -> ConversationUsageMetadata {
ConversationUsageMetadata {
was_summarized: summarized,
context_window_usage: context_window_usage as f32,
credits_spent: credits_spent as f32,
credits_spent_for_last_block: None,
token_usage: vec![],
tool_usage_metadata: Default::default(),
}
}
impl TryFrom<galaxy_graphql::ai::AIConversation> for ServerAIConversationMetadata {
type Error = anyhow::Error;
fn try_from(value: galaxy_graphql::ai::AIConversation) -> Result<Self, Self::Error> {
let usage = convert_usage_metadata(
value.usage.usage_metadata.summarized,
value.usage.usage_metadata.context_window_usage,
value.usage.usage_metadata.credits_spent,
);
let metadata = value.metadata.try_into()?;
let permissions = value.permissions.try_into()?;
let ambient_agent_task_id = value
.ambient_agent_task_id
.map(|id| id.into_inner().parse())
.transpose()?;
let server_conversation_token =
ServerConversationToken::new(value.conversation_id.into_inner());
// If we fail to parse any artifacts, don't fail the entire conversion -- just don't include them in the list
let artifacts = value
.artifacts
.unwrap_or_default()
.into_iter()
.filter_map(|a| Artifact::try_from(a).ok())
.collect();
Ok(Self {
title: value.title,
working_directory: value.working_directory,
harness: convert_harness(value.harness),
usage,
metadata,
permissions,
ambient_agent_task_id,
server_conversation_token,
artifacts,
})
}
}
impl TryFrom<galaxy_graphql::queries::list_ai_conversations::AIConversationMetadata>
for ServerAIConversationMetadata
{
type Error = anyhow::Error;
fn try_from(
value: galaxy_graphql::queries::list_ai_conversations::AIConversationMetadata,
) -> Result<Self, Self::Error> {
let usage = convert_usage_metadata(
value.usage.usage_metadata.summarized,
value.usage.usage_metadata.context_window_usage,
value.usage.usage_metadata.credits_spent,
);
let metadata = value.metadata.try_into()?;
let permissions = value.permissions.try_into()?;
let ambient_agent_task_id = value
.ambient_agent_task_id
.map(|id| id.into_inner().parse())
.transpose()?;
let server_conversation_token =
ServerConversationToken::new(value.conversation_id.into_inner());
let artifacts = value
.artifacts
.unwrap_or_default()
.into_iter()
.filter_map(|a| Artifact::try_from(a).ok())
.collect();
Ok(Self {
title: value.title,
working_directory: value.working_directory,
harness: convert_harness(value.harness),
usage,
metadata,
permissions,
ambient_agent_task_id,
server_conversation_token,
artifacts,
})
}
}
#[cfg_attr(not(target_family = "wasm"), async_trait)]
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
impl StoreClient for ServerApi {
async fn update_intermediate_nodes(
&self,
embedding_config: EmbeddingConfig,
nodes: Vec<IntermediateNode>,
) -> Result<HashMap<NodeHash, bool>, full_source_code_embedding::Error> {
let results = self.update_merkle_tree(embedding_config, nodes).await?;
Ok(results)
}
async fn generate_embeddings(
&self,
embedding_config: EmbeddingConfig,
fragments: Vec<full_source_code_embedding::Fragment>,
root_hash: NodeHash,
repo_metadata: RepoMetadata,
) -> Result<HashMap<ContentHash, bool>, full_source_code_embedding::Error> {
let results = self
.generate_code_embeddings(embedding_config, fragments, root_hash, repo_metadata)
.await?;
Ok(results)
}
async fn populate_merkle_tree_cache(
&self,
embedding_config: EmbeddingConfig,
root_hash: NodeHash,
repo_metadata: RepoMetadata,
) -> Result<bool, full_source_code_embedding::Error> {
let variables = PopulateMerkleTreeCacheVariables {
embedding_config: embedding_config.into(),
root_hash: root_hash.into(),
repo_metadata: repo_metadata.into(),
request_context: get_request_context(),
};
let operation = PopulateMerkleTreeCache::build(variables);
let response = self.send_graphql_request(operation, None).await?;
match response.populate_merkle_tree_cache {
PopulateMerkleTreeCacheResult::PopulateMerkleTreeCacheOutput(output) => {
Ok(output.success)
}
PopulateMerkleTreeCacheResult::UserFacingError(e) => {
Err(anyhow!(get_user_facing_error_message(e)).into())
}
PopulateMerkleTreeCacheResult::Unknown => {
Err(anyhow!("failed to populate merkle tree cache").into())
}
}
}
async fn sync_merkle_tree(
&self,
nodes: Vec<NodeHash>,
embedding_config: EmbeddingConfig,
) -> Result<HashSet<NodeHash>, full_source_code_embedding::Error> {
let input = SyncMerkleTreeInput {
hashed_nodes: nodes.into_iter().map(Into::into).collect(),
embedding_config: embedding_config.into(),
};
let variables = SyncMerkleTreeVariables {
input,
request_context: get_request_context(),
};
let operation = SyncMerkleTree::build(variables);
let response = self.send_graphql_request(operation, None).await?;
match response.sync_merkle_tree {
SyncMerkleTreeResult::SyncMerkleTreeOutput(output) => {
let mut node_results = HashSet::with_capacity(output.changed_nodes.len());
for hash in output.changed_nodes {
node_results.insert(hash.try_into()?);
}
Ok(node_results)
}
SyncMerkleTreeResult::SyncMerkleTreeError(e) => Err(anyhow!(e.error).into()),
SyncMerkleTreeResult::UserFacingError(e) => {
Err(anyhow!(get_user_facing_error_message(e)).into())
}
SyncMerkleTreeResult::Unknown => Err(anyhow!("failed to sync merkle tree").into()),
}
}
async fn rerank_fragments(
&self,
query: String,
fragments: Vec<full_source_code_embedding::Fragment>,
) -> Result<Vec<full_source_code_embedding::Fragment>, full_source_code_embedding::Error> {
let variables = RerankFragmentsVariables {
query,
fragments: fragments.into_iter().map(Into::into).collect(),
request_context: get_request_context(),
};
let operation = RerankFragments::build(variables);
let response = self.send_graphql_request(operation, None).await?;
match response.rerank_fragments {
RerankFragmentsResult::RerankFragmentsOutput(output) => Ok(output
.ranked_fragments
.into_iter()
.map(|fragment| fragment.try_into())
.collect::<Result<Vec<_>, _>>()?),
RerankFragmentsResult::RerankFragmentsError(e) => Err(anyhow!(e.error).into()),
RerankFragmentsResult::UserFacingError(e) => {
Err(anyhow!(get_user_facing_error_message(e)).into())
}
RerankFragmentsResult::Unknown => Err(anyhow!("failed to rerank fragments").into()),
}
}
async fn get_relevant_fragments(
&self,
embedding_config: EmbeddingConfig,
query: String,
root_hash: NodeHash,
repo_metadata: RepoMetadata,
) -> Result<Vec<ContentHash>, full_source_code_embedding::Error> {
let variables = GetRelevantFragmentsVariables {
query,
root_hash: root_hash.into(),
embedding_config: embedding_config.into(),
request_context: get_request_context(),
repo_metadata: repo_metadata.into(),
};
let operation = GetRelevantFragmentsQuery::build(variables);
let response = self.send_graphql_request(operation, None).await?;
match response.get_relevant_fragments {
GetRelevantFragmentsResult::GetRelevantFragmentsOutput(output) => Ok(output
.candidate_hashes
.into_iter()
.map(|hash| hash.try_into())
.collect::<Result<Vec<_>, _>>()?),
GetRelevantFragmentsResult::UserFacingError(e) => {
Err(anyhow!(get_user_facing_error_message(e)).into())
}
GetRelevantFragmentsResult::GetRelevantFragmentsError(e) => {
Err(anyhow!(e.error).into())
}
GetRelevantFragmentsResult::Unknown => {
Err(anyhow!("failed to get relevant fragments").into())
}
}
}
async fn codebase_context_config(
&self,
) -> Result<CodebaseContextConfig, full_source_code_embedding::Error> {
let variables = CodebaseContextConfigVariables {
request_context: get_request_context(),
};
let operation = CodebaseContextConfigQuery::build(variables);
let response = self.send_graphql_request(operation, None).await?;
match response.codebase_context_config {
CodebaseContextConfigResult::CodebaseContextConfigOutput(output) => {
Ok(CodebaseContextConfig {
embedding_config: output.embedding_config.try_into()?,
embedding_cadence: Duration::from_secs(output.embedding_cadence as u64),
})
}
CodebaseContextConfigResult::UserFacingError(e) => {
Err(anyhow!(get_user_facing_error_message(e)).into())
}
CodebaseContextConfigResult::Unknown => {
Err(anyhow!("failed to retrieve codebase context config").into())
}
}
}
}
#[cfg(test)]
#[path = "ai_test.rs"]
mod tests;