//! Ambient agent task types and utilities. use anyhow::anyhow; use chrono::{DateTime, Utc}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use galaxy_cli::agent::Harness; use galaxy_core::report_error; use galaxy_core::ui::theme::WarpTheme; use galaxyui::color::ColorU; use crate::ai::artifacts::{deserialize_artifacts, Artifact}; use crate::server::server_api::ServerApiProvider; use crate::ui_components::icons::Icon; use crate::view_components::DismissibleToast; use crate::workspace::ToastStack; use galaxyui::{SingletonEntity, View, ViewContext}; use super::AmbientAgentTaskId; /// Runtime configuration snapshot for agent execution. /// /// This is the merged/resolved config used when spawning or running an agent. /// It combines settings from config files and CLI args. /// Unlike `AgentConfig` (the cloud model), field names here use the runtime format /// (e.g. `model_id` instead of `base_model_id`). #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] pub struct AgentConfigSnapshot { /// Config name for searchability/traceability. #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub environment_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub base_prompt: Option, /// MCP server configuration map (unwrapped; no `mcpServers` wrapper). #[serde(default, skip_serializing_if = "Option::is_none")] pub mcp_servers: Option>, /// Profile ID for local agent runs. This configures the terminal session /// with the specified execution profile. Only used for local runs, not cloud runs. #[serde(default, skip_serializing_if = "Option::is_none")] pub profile_id: Option, /// Self-hosted worker ID that should execute this task. /// If None or Some("warp"), the task will be dispatched to Warp-hosted (Namespace) workers. /// Otherwise, the task will only be assigned to a connected self-hosted worker with matching ID. #[serde(default, skip_serializing_if = "Option::is_none")] pub worker_host: Option, /// Skill spec to use as the base prompt for the agent. /// Format: "skill_name", "repo:skill_name", or "org/repo:skill_name". /// The skill is resolved at runtime in the agent environment. #[serde(default, skip_serializing_if = "Option::is_none")] pub skill_spec: Option, /// Whether computer use is enabled for this agent run. /// If None, the default behavior is used. #[serde(default, skip_serializing_if = "Option::is_none")] pub computer_use_enabled: Option, /// Execution harness for the agent run. /// If None, we use Warp's default ("oz"). #[serde(default, skip_serializing_if = "Option::is_none")] pub harness: Option, /// Authentication secrets for third-party harnesses. #[serde(default, skip_serializing_if = "Option::is_none")] pub harness_auth_secrets: Option, } /// Configuration for a third-party execution harness. #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] pub struct HarnessConfig { /// The harness type, e.g. [`Harness::Claude`]. #[serde( rename = "type", serialize_with = "serialize_harness", deserialize_with = "deserialize_harness" )] pub harness_type: Harness, } impl HarnessConfig { /// Builds a harness config from just the harness type. pub fn from_harness_type(harness_type: Harness) -> Self { Self { harness_type } } } /// Parses a harness type name (e.g. `"claude"`) into a [`Harness`] variant. /// Unknown values fall back to [`Harness::Unknown`] so we don't /// misrepresent a future-server harness as Oz; UI surfaces should treat /// `Unknown` as a non-Oz, non-runnable harness. pub(crate) fn harness_from_name(name: &str) -> Harness { match name { "claude" => Harness::Claude, "opencode" => Harness::OpenCode, "gemini" => Harness::Gemini, "oz" => Harness::Oz, other => { log::warn!("Unknown harness config name: {other:?}; treating as Unknown"); Harness::Unknown } } } fn serialize_harness(harness: &Harness, serializer: S) -> Result { serializer.serialize_str(&harness.to_string()) } fn deserialize_harness<'de, D: Deserializer<'de>>(deserializer: D) -> Result { let name = String::deserialize(deserializer)?; Ok(harness_from_name(&name)) } /// Authentication secrets for third-party harnesses. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct HarnessAuthSecretsConfig { /// Name of a managed secret for Claude Code harness authentication. #[serde(default, skip_serializing_if = "Option::is_none")] pub claude_auth_secret_name: Option, } impl AgentConfigSnapshot { /// Returns true if this config is empty (no options are set). pub fn is_empty(&self) -> bool { let Self { name, environment_id, model_id, base_prompt, mcp_servers, profile_id, worker_host, skill_spec, computer_use_enabled, harness, harness_auth_secrets, } = self; name.is_none() && environment_id.is_none() && model_id.is_none() && base_prompt.is_none() && mcp_servers.is_none() && profile_id.is_none() && worker_host.is_none() && skill_spec.is_none() && computer_use_enabled.is_none() && harness.is_none() && harness_auth_secrets.is_none() } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum AgentSource { Linear, AgentWebhook, Slack, Cli, ScheduledAgent, Interactive, WebApp, GitHubAction, CloudMode, } impl AgentSource { pub fn as_str(&self) -> &str { match self { AgentSource::Linear => "LINEAR", AgentSource::AgentWebhook => "API", AgentSource::Slack => "SLACK", AgentSource::Cli => "CLI", AgentSource::ScheduledAgent => "SCHEDULED_AGENT", // The public API's run source for local interactive tasks is named // `LOCAL`. AgentSource::Interactive => "LOCAL", AgentSource::WebApp => "WEB_APP", AgentSource::GitHubAction => "GITHUB_ACTION", AgentSource::CloudMode => "CLOUD_MODE", } } pub fn display_name(&self) -> &str { match self { AgentSource::Linear => "Linear", AgentSource::AgentWebhook => "API", AgentSource::Slack => "Slack", AgentSource::Cli => "CLI", AgentSource::ScheduledAgent => "Scheduled", AgentSource::Interactive => "Warp (local agent)", AgentSource::WebApp => "Oz Web", AgentSource::GitHubAction => "GitHub Action", AgentSource::CloudMode => "Warp (cloud agent)", } } /// Returns true if this source represents a user-initiated conversation /// (as opposed to automated/programmatic sources like CLI or scheduled runs). pub fn is_user_initiated(&self) -> bool { match self { AgentSource::Linear | AgentSource::Slack | AgentSource::Interactive | AgentSource::WebApp | AgentSource::CloudMode => true, AgentSource::Cli | AgentSource::ScheduledAgent | AgentSource::AgentWebhook | AgentSource::GitHubAction => false, } } } fn deserialize_ambient_agent_source<'de, D>( deserializer: D, ) -> Result, D::Error> where D: serde::Deserializer<'de>, { let s: Option = serde::Deserialize::deserialize(deserializer)?; Ok(match s { Some(s) => match s.as_str() { "LINEAR" => Some(AgentSource::Linear), "AGENT_WEBHOOK" | "API" => Some(AgentSource::AgentWebhook), "SLACK" => Some(AgentSource::Slack), "LOCAL" => Some(AgentSource::Interactive), "CLI" => Some(AgentSource::Cli), "SCHEDULED_AGENT" => Some(AgentSource::ScheduledAgent), "WEB_APP" => Some(AgentSource::WebApp), "GITHUB_ACTION" => Some(AgentSource::GitHubAction), "CLOUD_MODE" => Some(AgentSource::CloudMode), _ => { report_error!(anyhow!("Unknown AmbientAgentSource: {}", s)); None } }, None => None, }) } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] pub struct AmbientAgentTask { pub task_id: AmbientAgentTaskId, #[serde(default)] pub parent_run_id: Option, pub title: String, pub state: AmbientAgentTaskState, pub prompt: String, pub created_at: DateTime, pub started_at: Option>, pub updated_at: DateTime, pub status_message: Option, #[serde(default, deserialize_with = "deserialize_ambient_agent_source")] pub source: Option, pub session_id: Option, pub session_link: Option, pub creator: Option, pub conversation_id: Option, pub request_usage: Option, pub is_sandbox_running: bool, /// Snapshot of the agent config used to create the task. #[serde(default, alias = "agent_config")] pub agent_config_snapshot: Option, #[serde(default, deserialize_with = "deserialize_artifacts")] pub artifacts: Vec, /// The last event sequence number recorded for this run by the server. /// Used by orchestration event delivery to resume from the correct /// cursor on restart. Populated by `GET /agent/runs/{run_id}` when the /// server supports it; `None` on older servers. #[serde(default)] pub last_event_sequence: Option, /// The server-recorded `run_id`s of direct children of this run. Used /// by orchestration event-delivery restore to discover children whose /// records may not exist locally (e.g. remote-worker children in the /// driver case). Empty on older servers. #[serde(default)] pub children: Vec, } /// Represents a single attachment input from the client (e.g., file upload) #[derive(Clone, Debug, Serialize)] pub struct AttachmentInput { pub file_name: String, pub mime_type: String, pub data: String, // base64-encoded data } /// Information about a task attachment retrieved from the server #[derive(Clone, Debug)] pub struct TaskAttachment { pub file_id: String, pub filename: String, pub download_url: String, pub mime_type: String, } impl AmbientAgentTask { /// Total credits used (inference + compute). pub fn credits_used(&self) -> Option { self.request_usage .as_ref() .map(|u| (u.inference_cost.unwrap_or(0.0) + u.compute_cost.unwrap_or(0.0)) as f32) } /// Duration from started_at to updated_at. pub fn run_time(&self) -> Option { let started = self.started_at?; let duration = self.updated_at.signed_duration_since(started); (duration.num_seconds() >= 0).then_some(duration) } /// Creator's display name, if available. pub fn creator_display_name(&self) -> Option { self.creator.as_ref().and_then(|c| c.display_name.clone()) } /// Returns true if the underlying session for the ambient agent is no longer running. pub fn is_no_longer_running(&self) -> bool { !self.is_sandbox_running && !self.state.is_working() } } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] #[serde(rename_all = "UPPERCASE")] pub enum AmbientAgentTaskState { Queued, Pending, Claimed, #[serde(alias = "IN_PROGRESS")] InProgress, Succeeded, Failed, Error, Blocked, Cancelled, #[serde(other)] Unknown, } impl AmbientAgentTaskState { /// Returns the query param value for the server API. pub fn as_query_param(&self) -> Option<&str> { match self { AmbientAgentTaskState::Queued => Some("QUEUED"), AmbientAgentTaskState::Pending => Some("PENDING"), AmbientAgentTaskState::Claimed => Some("CLAIMED"), AmbientAgentTaskState::InProgress => Some("INPROGRESS"), AmbientAgentTaskState::Succeeded => Some("SUCCEEDED"), AmbientAgentTaskState::Failed => Some("FAILED"), AmbientAgentTaskState::Error => Some("ERROR"), AmbientAgentTaskState::Blocked => Some("BLOCKED"), AmbientAgentTaskState::Cancelled => Some("CANCELLED"), // Unknown states are only for resilient deserialization and should not be // sent back as filter values. AmbientAgentTaskState::Unknown => None, } } pub fn is_working(&self) -> bool { match self { AmbientAgentTaskState::Queued | AmbientAgentTaskState::Pending | AmbientAgentTaskState::Claimed | AmbientAgentTaskState::InProgress => true, AmbientAgentTaskState::Succeeded | AmbientAgentTaskState::Failed | AmbientAgentTaskState::Error | AmbientAgentTaskState::Blocked | AmbientAgentTaskState::Cancelled | AmbientAgentTaskState::Unknown => false, } } pub fn is_cancellable(&self) -> bool { self.is_working() } pub fn is_failure_like(&self) -> bool { match self { AmbientAgentTaskState::Failed | AmbientAgentTaskState::Error | AmbientAgentTaskState::Blocked | AmbientAgentTaskState::Unknown => true, AmbientAgentTaskState::Queued | AmbientAgentTaskState::Pending | AmbientAgentTaskState::Claimed | AmbientAgentTaskState::InProgress | AmbientAgentTaskState::Succeeded | AmbientAgentTaskState::Cancelled => false, } } pub fn is_terminal(&self) -> bool { match self { AmbientAgentTaskState::Succeeded | AmbientAgentTaskState::Failed | AmbientAgentTaskState::Error | AmbientAgentTaskState::Blocked | AmbientAgentTaskState::Cancelled | AmbientAgentTaskState::Unknown => true, AmbientAgentTaskState::Queued | AmbientAgentTaskState::Pending | AmbientAgentTaskState::Claimed | AmbientAgentTaskState::InProgress => false, } } pub fn status_icon_and_color(&self, theme: &WarpTheme) -> (Icon, ColorU) { match self { AmbientAgentTaskState::Queued | AmbientAgentTaskState::Pending | AmbientAgentTaskState::Claimed | AmbientAgentTaskState::InProgress => (Icon::ClockLoader, theme.ansi_fg_magenta()), AmbientAgentTaskState::Succeeded => (Icon::Check, theme.ansi_fg_green()), AmbientAgentTaskState::Failed | AmbientAgentTaskState::Error | AmbientAgentTaskState::Unknown => (Icon::Triangle, theme.ansi_fg_red()), AmbientAgentTaskState::Blocked => (Icon::StopFilled, theme.ansi_fg_yellow()), AmbientAgentTaskState::Cancelled => ( Icon::Cancelled, theme.disabled_text_color(theme.background()).into_solid(), ), } } } impl std::fmt::Display for AmbientAgentTaskState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { AmbientAgentTaskState::Queued => write!(f, "Queued"), AmbientAgentTaskState::Pending => write!(f, "Pending"), AmbientAgentTaskState::Claimed => write!(f, "Claimed"), AmbientAgentTaskState::InProgress => write!(f, "In progress"), AmbientAgentTaskState::Succeeded => write!(f, "Done"), AmbientAgentTaskState::Failed => write!(f, "Failed"), AmbientAgentTaskState::Error => write!(f, "Error"), AmbientAgentTaskState::Blocked => write!(f, "Blocked"), AmbientAgentTaskState::Cancelled => write!(f, "Cancelled"), AmbientAgentTaskState::Unknown => write!(f, "Failed"), } } } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] pub struct TaskCreatorInfo { #[serde(rename = "type")] pub creator_type: String, pub uid: String, pub display_name: Option, } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] pub struct TaskStatusMessage { pub message: String, } #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] pub struct RequestUsage { pub inference_cost: Option, pub compute_cost: Option, } /// Cancel an ambient agent task and show a toast with the result. pub fn cancel_task_with_toast(task_id: AmbientAgentTaskId, ctx: &mut ViewContext) { let ai_client = ServerApiProvider::handle(ctx).as_ref(ctx).get_ai_client(); let window_id = ctx.window_id(); ctx.spawn( async move { ai_client.cancel_ambient_agent_task(&task_id).await }, move |_view, result, ctx| { let message = match result { Ok(()) => "Task cancelled".to_string(), Err(e) => { log::error!("Failed to cancel task: {e}"); format!("Failed to cancel task: {e}") } }; ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { let toast = DismissibleToast::default(message); toast_stack.add_ephemeral_toast(toast, window_id, ctx); }); }, ); }