485 lines
18 KiB
Rust
485 lines
18 KiB
Rust
//! 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<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub environment_id: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub model_id: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub base_prompt: Option<String>,
|
|
/// MCP server configuration map (unwrapped; no `mcpServers` wrapper).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub mcp_servers: Option<serde_json::Map<String, serde_json::Value>>,
|
|
/// 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<String>,
|
|
/// 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<String>,
|
|
/// 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<String>,
|
|
/// 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<bool>,
|
|
/// 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<HarnessConfig>,
|
|
/// Authentication secrets for third-party harnesses.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub harness_auth_secrets: Option<HarnessAuthSecretsConfig>,
|
|
}
|
|
|
|
/// 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<S: Serializer>(harness: &Harness, serializer: S) -> Result<S::Ok, S::Error> {
|
|
serializer.serialize_str(&harness.to_string())
|
|
}
|
|
|
|
fn deserialize_harness<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Harness, D::Error> {
|
|
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<String>,
|
|
}
|
|
|
|
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<Option<AgentSource>, D::Error>
|
|
where
|
|
D: serde::Deserializer<'de>,
|
|
{
|
|
let s: Option<String> = 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<String>,
|
|
pub title: String,
|
|
pub state: AmbientAgentTaskState,
|
|
pub prompt: String,
|
|
pub created_at: DateTime<Utc>,
|
|
pub started_at: Option<DateTime<Utc>>,
|
|
pub updated_at: DateTime<Utc>,
|
|
pub status_message: Option<TaskStatusMessage>,
|
|
#[serde(default, deserialize_with = "deserialize_ambient_agent_source")]
|
|
pub source: Option<AgentSource>,
|
|
pub session_id: Option<String>,
|
|
pub session_link: Option<String>,
|
|
pub creator: Option<TaskCreatorInfo>,
|
|
pub conversation_id: Option<String>,
|
|
pub request_usage: Option<RequestUsage>,
|
|
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<AgentConfigSnapshot>,
|
|
#[serde(default, deserialize_with = "deserialize_artifacts")]
|
|
pub artifacts: Vec<Artifact>,
|
|
|
|
/// 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<i64>,
|
|
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// 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<f32> {
|
|
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<chrono::Duration> {
|
|
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<String> {
|
|
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<String>,
|
|
}
|
|
|
|
#[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<f64>,
|
|
pub compute_cost: Option<f64>,
|
|
}
|
|
|
|
/// Cancel an ambient agent task and show a toast with the result.
|
|
pub fn cancel_task_with_toast<V: View>(task_id: AmbientAgentTaskId, ctx: &mut ViewContext<V>) {
|
|
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);
|
|
});
|
|
},
|
|
);
|
|
}
|