Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ channel_versions = { path = "crates/channel_versions", default-features = false
command = { path = "crates/command" }
command-signatures-v2 = { path = "crates/command-signatures-v2" }
cloud_objects = { path = "crates/cloud_objects" }
cloud_object_client = { path = "crates/cloud_object_client" }
cloud_object_models = { path = "crates/cloud_object_models" }
computer_use = { path = "crates/computer_use" }
field_mask = { path = "crates/field_mask" }
firebase = { path = "crates/firebase" }
Expand Down
4 changes: 4 additions & 0 deletions app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ cfg-if.workspace = true
channel_versions.workspace = true
chrono.workspace = true
clap.workspace = true
cloud_object_client.workspace = true
cloud_object_models.workspace = true
cloud_objects.workspace = true
command = { workspace = true }
command-corrections.workspace = true
Expand Down Expand Up @@ -397,6 +399,7 @@ jsonschema = { workspace = true, default-features = false }
async-executor = "1.5.1"
command = { workspace = true, features = ["test-util"] }
ctor = "0.1.18"
cloud_object_client = { workspace = true, features = ["test-util"] }
http_client = { workspace = true, features = ["test-util"] }
mockall = "0.13.1"
mockito.workspace = true
Expand Down Expand Up @@ -797,6 +800,7 @@ voice_input = ["dep:voice_input"]
system_theme = []
tab_close_button_on_left = []
team_features_override = []
test-util = ["cloud_object_client/test-util"]
team_workflows = ["team_features_override"]
toggle_bootstrap_block = []
# Feature enabled only when app is compiled for integration tests.
Expand Down
1 change: 1 addition & 0 deletions app/src/ai/agent/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ use crate::ai::agent::conversation::AIConversationId;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::blocklist::{BlocklistAIPermissions, RequestInput, SessionContext};
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
use crate::ai::execution_profiles::AIExecutionProfileAppExt;
use crate::ai::llms::LLMId;
use crate::ai::mcp::templatable_manager::TemplatableMCPServerInfo;
use crate::ai::mcp::TemplatableMCPServerManager;
Expand Down
20 changes: 1 addition & 19 deletions app/src/ai/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3030,25 +3030,7 @@ pub struct RequestMetadata {
pub is_auto_resume_after_error: bool,
}

/// A globally unique ID for a suggested objects.
///
/// This is used for telemetry purposes to track and connect both:
/// - Suggested objects generated by the AI agent
/// - The corresponding objects stored in the cloud (if the suggestion was accepted)
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
pub struct SuggestedLoggingId(String);

impl Display for SuggestedLoggingId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}

impl From<String> for SuggestedLoggingId {
fn from(value: String) -> Self {
Self(value)
}
}
pub use cloud_object_models::SuggestedLoggingId;

#[derive(Debug, Clone, Eq, PartialEq)]
pub struct SuggestedRule {
Expand Down
56 changes: 7 additions & 49 deletions app/src/ai/ambient_agents/scheduled.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
use std::collections::HashMap;
use std::future::Future;

pub use cloud_object_models::{
CloudScheduledAmbientAgent, CloudScheduledAmbientAgentModel, ScheduledAmbientAgent,
};
use futures::channel::oneshot;
use futures::FutureExt;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use warp_graphql::queries::get_scheduled_agent_history::ScheduledAgentHistory;
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};

use super::AgentConfigSnapshot;
use crate::cloud_object::model::generic_string_model::{
GenericStringModel, GenericStringObjectId, StringModel,
};
use crate::cloud_object::model::json_model::{JsonModel, JsonSerializer};
use crate::cloud_object::model::generic_string_model::StringModel;
use crate::cloud_object::model::json_model::JsonModel;
use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::{
CloudObjectLookup as _, GenericCloudObject, GenericStringObjectFormat,
GenericStringObjectUniqueKey, JsonObjectType, Owner, Revision,
CloudObjectLookup as _, GenericStringObjectFormat, GenericStringObjectUniqueKey,
JsonObjectType, Owner, Revision,
};
use crate::drive::CloudObjectTypeAndId;
use crate::server::cloud_objects::update_manager::{
Expand All @@ -26,47 +25,6 @@ use crate::server::ids::{ClientId, SyncId};
use crate::server::server_api::ServerApiProvider;
use crate::server::sync_queue::QueueItem;

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
/// A ScheduledAmbientAgent represents configuration for ambient agents that run on a cron schedule.
pub struct ScheduledAmbientAgent {
/// Agent name
#[serde(default)]
pub name: String,
/// Cron schedule expression
#[serde(default)]
pub cron_schedule: String,
/// Whether the scheduled agent is enabled
#[serde(default)]
pub enabled: bool,
/// The prompt to use for the scheduled agent
#[serde(default)]
pub prompt: String,
/// The latest failure to execute this scheduled agent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_spawn_error: Option<String>,
/// Configuration for how the ambient agent should run.
#[serde(default, skip_serializing_if = "AgentConfigSnapshot::is_empty")]
pub agent_config: AgentConfigSnapshot,
}

pub type CloudScheduledAmbientAgent =
GenericCloudObject<GenericStringObjectId, CloudScheduledAmbientAgentModel>;
pub type CloudScheduledAmbientAgentModel =
GenericStringModel<ScheduledAmbientAgent, JsonSerializer>;

impl ScheduledAmbientAgent {
pub fn new(name: String, cron_schedule: String, enabled: bool, prompt: String) -> Self {
Self {
name,
cron_schedule,
enabled,
prompt,
last_spawn_error: None,
agent_config: Default::default(),
}
}
}

impl StringModel for ScheduledAmbientAgent {
type CloudObjectType = CloudScheduledAmbientAgent;

Expand Down
154 changes: 4 additions & 150 deletions app/src/ai/ambient_agents/task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

use anyhow::anyhow;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub use cloud_object_models::{
AgentConfigSnapshot, HarnessAuthSecretsConfig, HarnessConfig, HarnessModelConfig,
};
use serde::{Deserialize, Serialize};
use session_sharing_protocol::common::SessionId;
use url::Url;
use warp_cli::agent::Harness;
use warp_core::report_error;
use warp_core::ui::theme::WarpTheme;
use warpui::color::ColorU;
Expand All @@ -18,99 +20,6 @@ use crate::ui_components::icons::Icon;
use crate::view_components::DismissibleToast;
use crate::workspace::ToastStack;

/// 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,
/// The model to use with this harness. None means use the harness default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_id: Option<String>,
/// Optional reasoning level for harnesses that support it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_level: Option<String>,
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct HarnessModelConfig {
pub model_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_level: Option<String>,
}

impl HarnessConfig {
/// Builds a harness config from just the harness type.
pub fn from_harness_type(harness_type: Harness) -> Self {
Self {
harness_type,
model_id: None,
reasoning_level: None,
}
}

pub fn model_config(&self) -> Option<HarnessModelConfig> {
self.model_id
.as_ref()
.filter(|id| !id.is_empty())
.map(|model_id| HarnessModelConfig {
model_id: model_id.clone(),
reasoning_level: self.reasoning_level.clone(),
})
}
}

fn parse_session_id_from_link(session_link: &str) -> Option<SessionId> {
Url::parse(session_link).ok().and_then(|url| {
url.path_segments()
Expand All @@ -127,61 +36,6 @@ fn parse_execution_session_id(execution: RunExecution<'_>) -> Option<SessionId>
.and_then(|id| id.parse().ok())
.or_else(|| execution.session_link.and_then(parse_session_id_from_link))
}

fn serialize_harness<S: Serializer>(harness: &Harness, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(harness.config_name())
}

fn deserialize_harness<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Harness, D::Error> {
let name = String::deserialize(deserializer)?;
Ok(Harness::from_config_name(&name).unwrap_or_else(|| {
log::warn!("Unknown harness config name: {name:?}; treating as Unknown");
Harness::Unknown
}))
}

/// 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>,
/// Name of a managed secret for Codex harness authentication.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_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,
Expand Down
Loading