1801 lines
74 KiB
Rust
1801 lines
74 KiB
Rust
//! Agent SDK entry points for invoking Agent-related functionality from the app.
|
|
//! For now this provides a simple runner that echoes the received command.
|
|
|
|
use std::fmt::Write;
|
|
use std::future::Future;
|
|
use std::path::Path;
|
|
use std::pin::Pin;
|
|
use std::sync::Arc;
|
|
|
|
use ai::api_keys::{ApiKeyManager, AwsCredentialsRefreshStrategy};
|
|
use anyhow::Context;
|
|
pub(crate) use driver::harness::{task_env_vars, validate_cli_installed, ClaudeHarness};
|
|
pub use driver::AgentDriver;
|
|
use driver::AgentDriverError;
|
|
use telemetry::CliTelemetryEvent;
|
|
use tracing::Instrument as _;
|
|
use warp_cli::agent::{
|
|
AgentCommand, AgentProfileCommand, Harness, OutputFormat, Prompt, RunAgentArgs,
|
|
};
|
|
use warp_cli::api_key::ApiKeyCommand;
|
|
use warp_cli::artifact::ArtifactCommand;
|
|
use warp_cli::environment::{EnvironmentCommand, ImageCommand};
|
|
use warp_cli::federate::FederateCommand;
|
|
use warp_cli::harness_support::{HarnessSupportCommand, ReportArtifactCommand, TaskStatus};
|
|
use warp_cli::integration::IntegrationCommand;
|
|
use warp_cli::mcp::MCPCommand;
|
|
use warp_cli::memory_store::{MemoryCommand, MemoryStoreCommand};
|
|
use warp_cli::model::ModelCommand;
|
|
use warp_cli::provider::ProviderCommand;
|
|
use warp_cli::schedule::ScheduleSubcommand;
|
|
use warp_cli::secret::SecretCommand;
|
|
use warp_cli::share::ShareRequest;
|
|
use warp_cli::task::{MessageCommand, TaskCommand};
|
|
use warp_cli::{CliCommand, GlobalOptions, OZ_HARNESS_ENV};
|
|
use galaxy_core::features::FeatureFlag;
|
|
use warp_graphql::object_permissions::OwnerType;
|
|
use warp_isolation_platform::IsolationPlatformError;
|
|
#[cfg(not(target_family = "wasm"))]
|
|
use warp_logging::log_file_path;
|
|
use warp_managed_secrets::ManagedSecretManager;
|
|
use warpui::platform::TerminationMode;
|
|
use warpui::{AppContext, ModelSpawner, SingletonEntity};
|
|
|
|
use crate::ai::agent::api::convert_conversation::{
|
|
convert_conversation_data_to_ai_conversation, RestorationMode,
|
|
};
|
|
use crate::ai::agent::api::ServerConversationToken;
|
|
use crate::ai::agent::conversation::AIConversationId;
|
|
use crate::ai::agent_sdk::driver::harness::{harness_kind, HarnessKind};
|
|
use crate::ai::agent_sdk::driver::{AgentDriverOptions, AgentRunPrompt, Task};
|
|
use crate::ai::agent_sdk::mcp_config::build_mcp_servers_from_specs;
|
|
use crate::ai::agent_sdk::setup_observability::{
|
|
OzRunTimelineEvent, SetupClientEventReporter, SetupStep,
|
|
};
|
|
use crate::ai::ambient_agents::task::HarnessConfig;
|
|
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
|
use crate::ai::attachment_utils::attachments_download_dir;
|
|
#[cfg(not(target_family = "wasm"))]
|
|
use crate::ai::aws_credentials::refresh_aws_credentials;
|
|
use crate::ai::cloud_environments::CloudAmbientAgentEnvironment;
|
|
use crate::ai::llms::LLMId;
|
|
use crate::ai::skills::{
|
|
clone_repo_for_skill, resolve_skill_spec, ResolveSkillError, ResolvedSkill,
|
|
};
|
|
use crate::auth::auth_manager::{AuthManager, AuthManagerEvent};
|
|
use crate::auth::AuthStateProvider;
|
|
use crate::cloud_object::model::persistence::CloudModel;
|
|
use crate::cloud_object::CloudObjectLookup as _;
|
|
use crate::send_telemetry_sync_from_app_ctx;
|
|
use crate::server::ids::{ServerId, SyncId};
|
|
use crate::server::server_api::ai::{AIClient, AgentConfigSnapshot, GitCredential};
|
|
use crate::server::server_api::ServerApiProvider;
|
|
use crate::terminal::view::ConversationRestorationInNewPaneType;
|
|
use crate::workflows::workflow::Workflow;
|
|
|
|
mod admin;
|
|
mod agent_config;
|
|
mod agent_management;
|
|
mod ambient;
|
|
mod api_key;
|
|
mod artifact;
|
|
pub(crate) mod artifact_upload;
|
|
mod common;
|
|
mod config_file;
|
|
pub(crate) mod driver;
|
|
mod environment;
|
|
mod federate;
|
|
mod harness_support;
|
|
#[cfg(not(target_family = "wasm"))]
|
|
mod integration;
|
|
#[cfg(not(target_family = "wasm"))]
|
|
mod integration_output;
|
|
mod mcp;
|
|
mod mcp_config;
|
|
mod memory_store;
|
|
mod model;
|
|
mod oauth_flow;
|
|
pub mod output;
|
|
mod profiles;
|
|
mod provider;
|
|
pub(crate) mod retry;
|
|
mod schedule;
|
|
mod secret;
|
|
pub(crate) mod setup_observability;
|
|
mod telemetry;
|
|
#[cfg(test)]
|
|
mod test_support;
|
|
mod text_layout;
|
|
|
|
/// Prints a non-blocking warning to stderr when the CLI is invoked with a team-scoped API key.
|
|
fn maybe_warn_team_api_key(ctx: &AppContext) {
|
|
let auth_state = AuthStateProvider::handle(ctx).as_ref(ctx).get();
|
|
let owner_type = auth_state.api_key_owner_type();
|
|
if !matches!(owner_type, Some(OwnerType::Team)) {
|
|
return;
|
|
}
|
|
|
|
eprintln!(
|
|
"\x1b[33mWarning: Free cloud credits apply to personal runs only but this run uses \
|
|
a team API key. If you want to use free cloud credits, consider using a personal API key instead.\x1b[0m"
|
|
);
|
|
}
|
|
|
|
/// Run a Warp CLI command.
|
|
#[tracing::instrument(name = "agent_sdk::run", skip_all, err, fields(tags.cloud_agent = true))]
|
|
pub fn run(
|
|
ctx: &mut AppContext,
|
|
command: CliCommand,
|
|
global_options: GlobalOptions,
|
|
) -> anyhow::Result<()> {
|
|
let event = command_to_telemetry_event(&command);
|
|
send_telemetry_sync_from_app_ctx!(event, ctx);
|
|
|
|
launch_command(ctx, command, global_options)
|
|
}
|
|
|
|
/// Dispatch a CLI command to its handler.
|
|
fn dispatch_command(
|
|
ctx: &mut AppContext,
|
|
command: CliCommand,
|
|
global_options: GlobalOptions,
|
|
) -> anyhow::Result<()> {
|
|
match command {
|
|
CliCommand::Agent(agent_cmd) => run_agent(ctx, global_options, agent_cmd),
|
|
CliCommand::Environment(environment_cmd) => {
|
|
if !FeatureFlag::CloudEnvironments.is_enabled() {
|
|
return Err(anyhow::anyhow!("invalid value 'environment'"));
|
|
}
|
|
environment::run(ctx, global_options, environment_cmd)
|
|
}
|
|
CliCommand::MCP(mcp_cmd) => mcp::run(ctx, global_options, mcp_cmd),
|
|
CliCommand::Run(task_cmd) => run_task(ctx, global_options, task_cmd),
|
|
CliCommand::Model(model_cmd) => model::run(ctx, global_options, model_cmd),
|
|
CliCommand::MemoryStore(memory_store_cmd) => {
|
|
memory_store::run(ctx, global_options, memory_store_cmd)
|
|
}
|
|
CliCommand::Memory(memory_cmd) => memory_store::run_memory(ctx, global_options, memory_cmd),
|
|
CliCommand::Login => admin::login(ctx),
|
|
CliCommand::Logout => admin::logout(ctx),
|
|
CliCommand::Whoami => admin::whoami(ctx, global_options.output_format),
|
|
CliCommand::Provider(provider_cmd) => {
|
|
if !FeatureFlag::ProviderCommand.is_enabled() {
|
|
return Err(anyhow::anyhow!("invalid value 'provider'"));
|
|
}
|
|
provider::run(ctx, global_options, provider_cmd)
|
|
}
|
|
#[cfg(not(target_family = "wasm"))]
|
|
CliCommand::Integration(integration_cmd) => {
|
|
if !FeatureFlag::IntegrationCommand.is_enabled() {
|
|
return Err(anyhow::anyhow!("invalid value 'integration'"));
|
|
}
|
|
integration::run(ctx, global_options, integration_cmd)
|
|
}
|
|
#[cfg(target_family = "wasm")]
|
|
CliCommand::Integration(_) => {
|
|
return Err(anyhow::anyhow!("invalid value 'integration'"));
|
|
}
|
|
CliCommand::Schedule(schedule_cmd) => {
|
|
if !FeatureFlag::ScheduledAmbientAgents.is_enabled() {
|
|
return Err(anyhow::anyhow!("invalid value 'schedule'"));
|
|
}
|
|
schedule::run(ctx, global_options, schedule_cmd)
|
|
}
|
|
CliCommand::Secret(secret_cmd) => {
|
|
if !FeatureFlag::GalaxyManagedSecrets.is_enabled() {
|
|
return Err(anyhow::anyhow!("invalid value 'secret'"));
|
|
}
|
|
secret::run(ctx, global_options, secret_cmd)
|
|
}
|
|
CliCommand::Federate(federate_cmd) => {
|
|
if !FeatureFlag::OzIdentityFederation.is_enabled() {
|
|
return Err(anyhow::anyhow!("invalid value 'federate'"));
|
|
}
|
|
federate::run(ctx, global_options, federate_cmd)
|
|
}
|
|
CliCommand::HarnessSupport(args) => {
|
|
if !FeatureFlag::AgentHarness.is_enabled() {
|
|
return Err(anyhow::anyhow!("invalid value 'harness-support'"));
|
|
}
|
|
harness_support::run(ctx, global_options, args)
|
|
}
|
|
CliCommand::Artifact(artifact_cmd) => {
|
|
if !FeatureFlag::ArtifactCommand.is_enabled() {
|
|
return Err(anyhow::anyhow!("invalid value 'artifact'"));
|
|
}
|
|
artifact::run(ctx, global_options, artifact_cmd)
|
|
}
|
|
CliCommand::ApiKey(api_key_cmd) => {
|
|
if !FeatureFlag::APIKeyManagement.is_enabled() {
|
|
return Err(anyhow::anyhow!("invalid value 'api-key'"));
|
|
}
|
|
api_key::run(ctx, global_options, api_key_cmd)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn format_skill_resolution_error(err: ResolveSkillError) -> String {
|
|
match err {
|
|
ResolveSkillError::NotFound { skill } => {
|
|
format!("Skill '{skill}' not found")
|
|
}
|
|
ResolveSkillError::RepoNotFound { repo } => {
|
|
format!("Repository '{repo}' not found")
|
|
}
|
|
ResolveSkillError::Ambiguous { skill, candidates } => {
|
|
let mut msg = format!(
|
|
"Skill '{skill}' is ambiguous; specify as repo:skill_name\n\nCandidates:\n"
|
|
);
|
|
for path in candidates {
|
|
msg.push_str(&format!("- {}\n", path.display()));
|
|
}
|
|
msg
|
|
}
|
|
ResolveSkillError::OrgMismatch {
|
|
repo,
|
|
expected,
|
|
found,
|
|
} => {
|
|
format!("Repository '{repo}' found but belongs to org '{found}', expected '{expected}'")
|
|
}
|
|
ResolveSkillError::ParseFailed { path, message } => {
|
|
format!("Failed to parse skill file {}: {message}", path.display())
|
|
}
|
|
ResolveSkillError::CloneFailed { org, repo, message } => {
|
|
format!("Failed to clone repository '{org}/{repo}': {message}")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Run the agent with the provided command.
|
|
fn run_agent(
|
|
ctx: &mut AppContext,
|
|
global_options: GlobalOptions,
|
|
command: AgentCommand,
|
|
) -> anyhow::Result<()> {
|
|
match command {
|
|
AgentCommand::Run(args) => {
|
|
if args.environment.is_some() && !FeatureFlag::CloudEnvironments.is_enabled() {
|
|
return Err(anyhow::anyhow!("unexpected argument '--environment' found"));
|
|
}
|
|
if args.conversation.is_some() && !FeatureFlag::CloudConversations.is_enabled() {
|
|
return Err(anyhow::anyhow!(
|
|
"unexpected argument '--conversation' found"
|
|
));
|
|
}
|
|
if args.skill.is_some() && !FeatureFlag::OzPlatformSkills.is_enabled() {
|
|
return Err(anyhow::anyhow!("unexpected argument '--skill' found"));
|
|
}
|
|
if args.harness != Harness::Oz && !FeatureFlag::AgentHarness.is_enabled() {
|
|
return Err(anyhow::anyhow!("unexpected argument '--harness' found"));
|
|
}
|
|
if args.harness == Harness::OpenCode {
|
|
return Err(anyhow::anyhow!(
|
|
"The opencode harness is only supported for local child agent launches."
|
|
));
|
|
}
|
|
|
|
let server_api = ServerApiProvider::handle(ctx).as_ref(ctx).get_ai_client();
|
|
|
|
// Start the agent driver runner, which will handle the rest of the setup steps
|
|
// (managing both sync and async steps) as well as triggering the driver.
|
|
let runner = ctx.add_singleton_model(|_| AgentDriverRunner);
|
|
runner.update(ctx, move |_, ctx| {
|
|
let spawner = ctx.spawner();
|
|
ctx.spawn(
|
|
AgentDriverRunner::setup_and_run_driver(
|
|
spawner,
|
|
args,
|
|
server_api,
|
|
global_options.output_format,
|
|
),
|
|
|_, result, _ctx| {
|
|
if let Err(e) = result {
|
|
report_fatal_error(e.into(), _ctx);
|
|
}
|
|
},
|
|
);
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
AgentCommand::RunCloud(args) => {
|
|
if args.environment.environment.is_some()
|
|
&& !FeatureFlag::CloudEnvironments.is_enabled()
|
|
{
|
|
return Err(anyhow::anyhow!("unexpected argument '--environment' found"));
|
|
}
|
|
if args.conversation.is_some() && !FeatureFlag::CloudConversations.is_enabled() {
|
|
return Err(anyhow::anyhow!(
|
|
"unexpected argument '--conversation' found"
|
|
));
|
|
}
|
|
if args.harness != Harness::Oz && !FeatureFlag::AgentHarness.is_enabled() {
|
|
return Err(anyhow::anyhow!("unexpected argument '--harness' found"));
|
|
}
|
|
if args.claude_auth_secret.is_some() && args.harness != Harness::Claude {
|
|
return Err(anyhow::anyhow!(
|
|
"--claude-auth-secret is only valid with --harness claude."
|
|
));
|
|
}
|
|
if args.runner.is_some() && !FeatureFlag::CloudRunners.is_enabled() {
|
|
return Err(anyhow::anyhow!("unexpected argument '--runner' found"));
|
|
}
|
|
ambient::run_ambient_agent(ctx, args)
|
|
}
|
|
AgentCommand::Profile(sub) => profiles::run(ctx, global_options, sub),
|
|
AgentCommand::List(args) => {
|
|
agent_management::list_agents(ctx, global_options.output_format, args)
|
|
}
|
|
AgentCommand::Get(args) => {
|
|
agent_management::get_agent(ctx, global_options.output_format, args)
|
|
}
|
|
AgentCommand::Create(args) => {
|
|
agent_management::create_agent(ctx, global_options.output_format, args)
|
|
}
|
|
AgentCommand::Update(args) => {
|
|
agent_management::update_agent(ctx, global_options.output_format, args)
|
|
}
|
|
AgentCommand::Delete(args) => {
|
|
agent_management::delete_agent(ctx, global_options.output_format, args)
|
|
}
|
|
AgentCommand::Skills(args) => agent_config::list_skills(ctx, args),
|
|
}
|
|
}
|
|
|
|
/// Build the merged agent configuration from all sources and the Task for the driver.
|
|
/// Merge precedence: file < CLI < skill
|
|
fn build_merged_config_and_task(
|
|
args: &RunAgentArgs,
|
|
resolved_skill: &Option<ResolvedSkill>,
|
|
prompt: &Option<Prompt>,
|
|
ctx: &mut AppContext,
|
|
) -> anyhow::Result<(AgentConfigSnapshot, Task)> {
|
|
// Server-side prompt resolution (task_id is set): the task config already lives on the
|
|
// server and individual CLI flags (--model, --mcp, etc.) are the only local overrides.
|
|
// No config file is involved — the worker never passes --file alongside --task-id.
|
|
if args.task_id.is_some() {
|
|
return build_server_side_task(args, resolved_skill, ctx);
|
|
}
|
|
|
|
let loaded_file = match args.config_file.file.as_deref() {
|
|
Some(path) => Some(config_file::load_config_file(path)?),
|
|
None => None,
|
|
};
|
|
|
|
let cli_mcp_servers = build_mcp_servers_from_specs(&args.all_mcp_specs())?;
|
|
|
|
// Merge precedence: file < CLI < skill
|
|
let file_merged = config_file::merge_with_precedence(loaded_file.as_ref(), Default::default());
|
|
|
|
// Runner support is gated. The `run` command has no `--runner` flag, but a
|
|
// config file can still set `runner_id`, so reject it when the flag is off.
|
|
if file_merged.runner_id.is_some() && !FeatureFlag::CloudRunners.is_enabled() {
|
|
return Err(anyhow::anyhow!(
|
|
"`runner_id` is set in the config file but runner support is not enabled"
|
|
));
|
|
}
|
|
|
|
// Skill provides base_prompt and optionally name
|
|
let (skill_name, runtime_base_prompt) = match resolved_skill {
|
|
Some(skill) => (Some(skill.name.clone()), Some(skill.instructions.clone())),
|
|
None => (None, None),
|
|
};
|
|
|
|
// When a non-Oz harness is active, --model targets the harness rather than the Oz model.
|
|
let harness_model_id = if args.harness != Harness::Oz {
|
|
args.model.model.clone()
|
|
} else {
|
|
None
|
|
};
|
|
let harness_override = (args.harness != Harness::Oz).then_some(HarnessConfig {
|
|
harness_type: args.harness,
|
|
model_id: harness_model_id,
|
|
reasoning_level: None,
|
|
});
|
|
|
|
let oz_model = if args.harness == Harness::Oz {
|
|
args.model.model.clone().or(file_merged.model_id)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let mut merged_config = AgentConfigSnapshot {
|
|
// CLI name > skill name > file name
|
|
name: args.name.clone().or(skill_name).or(file_merged.name),
|
|
environment_id: args.environment.clone().or(file_merged.environment_id),
|
|
runner_id: file_merged.runner_id,
|
|
model_id: oz_model,
|
|
// Skill base_prompt takes precedence over file base_prompt
|
|
base_prompt: runtime_base_prompt.clone().or(file_merged.base_prompt),
|
|
mcp_servers: config_file::merge_mcp_servers(file_merged.mcp_servers, cli_mcp_servers),
|
|
profile_id: args.profile.clone(),
|
|
worker_host: file_merged.worker_host,
|
|
skill_spec: file_merged.skill_spec,
|
|
computer_use_enabled: args
|
|
.computer_use
|
|
.computer_use_override()
|
|
.or(file_merged.computer_use_enabled),
|
|
harness: harness_override,
|
|
harness_auth_secrets: None,
|
|
};
|
|
|
|
let runtime_mcp_specs = match merged_config.mcp_servers.as_ref() {
|
|
Some(mcp_servers) => config_file::mcp_specs_from_mcp_servers(mcp_servers)?,
|
|
None => Vec::new(),
|
|
};
|
|
|
|
let model_override: Option<LLMId> = merged_config
|
|
.model_id
|
|
.as_deref()
|
|
.filter(|_| args.harness == Harness::Oz)
|
|
.map(|model_id| common::validate_agent_mode_base_model_id(model_id, ctx))
|
|
.transpose()?;
|
|
|
|
// Keep the task config snapshot aligned with the effective model selection.
|
|
merged_config.model_id = model_override.clone().map(|id| id.to_string());
|
|
|
|
// Combine base_prompt with user prompt locally.
|
|
let local_prompt = match (merged_config.base_prompt.as_deref(), prompt) {
|
|
(Some(base_prompt), Some(Prompt::PlainText(user_prompt))) => {
|
|
Prompt::PlainText(format!("{base_prompt}\n\n{user_prompt}"))
|
|
}
|
|
(Some(base_prompt), None) => {
|
|
// Skill-only invocation: use skill instructions as the prompt
|
|
Prompt::PlainText(base_prompt.to_string())
|
|
}
|
|
(_, Some(p)) => p.clone(),
|
|
(None, None) => {
|
|
return Err(anyhow::anyhow!(AgentDriverError::InvalidRuntimeState));
|
|
}
|
|
};
|
|
|
|
let task = Task {
|
|
prompt: AgentRunPrompt::Local(resolve_prompt(&local_prompt, ctx)?),
|
|
model: model_override,
|
|
profile: args.profile.clone(),
|
|
mcp_specs: runtime_mcp_specs,
|
|
harness: harness_kind(args.harness)?,
|
|
};
|
|
|
|
Ok((merged_config, task))
|
|
}
|
|
|
|
/// Build the task for server-side prompt resolution (task_id is set).
|
|
/// Only CLI args contribute — no config file merge needed.
|
|
fn build_server_side_task(
|
|
args: &RunAgentArgs,
|
|
resolved_skill: &Option<ResolvedSkill>,
|
|
ctx: &mut AppContext,
|
|
) -> anyhow::Result<(AgentConfigSnapshot, Task)> {
|
|
let cli_mcp_servers = build_mcp_servers_from_specs(&args.all_mcp_specs())?;
|
|
|
|
let runtime_mcp_specs = match cli_mcp_servers.as_ref() {
|
|
Some(mcp_servers) => config_file::mcp_specs_from_mcp_servers(mcp_servers)?,
|
|
None => Vec::new(),
|
|
};
|
|
|
|
let harness_model_id = if args.harness != Harness::Oz {
|
|
args.model.model.clone()
|
|
} else {
|
|
None
|
|
};
|
|
let model_override: Option<LLMId> = if args.harness == Harness::Oz {
|
|
args.model
|
|
.model
|
|
.as_deref()
|
|
.map(|model_id| common::validate_agent_mode_base_model_id(model_id, ctx))
|
|
.transpose()?
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let harness_override = (args.harness != Harness::Oz).then_some(HarnessConfig {
|
|
harness_type: args.harness,
|
|
model_id: harness_model_id,
|
|
reasoning_level: None,
|
|
});
|
|
|
|
let skill_name = resolved_skill.as_ref().map(|s| s.name.clone());
|
|
let model_id_string = model_override.as_ref().map(|id| id.to_string());
|
|
let profile = args.profile.clone();
|
|
let environment = args.environment.clone();
|
|
|
|
let config = AgentConfigSnapshot {
|
|
name: args.name.clone().or(skill_name),
|
|
environment_id: environment.clone(),
|
|
runner_id: None,
|
|
model_id: model_id_string,
|
|
base_prompt: None,
|
|
mcp_servers: cli_mcp_servers,
|
|
profile_id: profile.clone(),
|
|
worker_host: None,
|
|
skill_spec: None,
|
|
computer_use_enabled: args.computer_use.computer_use_override(),
|
|
harness: harness_override,
|
|
harness_auth_secrets: None,
|
|
};
|
|
|
|
let skill = resolved_skill.as_ref().map(|s| s.parsed_skill.clone());
|
|
|
|
let task = Task {
|
|
prompt: AgentRunPrompt::ServerSide {
|
|
skill,
|
|
attachments_dir: None,
|
|
},
|
|
model: model_override,
|
|
profile,
|
|
mcp_specs: runtime_mcp_specs,
|
|
harness: harness_kind(args.harness)?,
|
|
};
|
|
|
|
Ok((config, task))
|
|
}
|
|
|
|
fn reconcile_task_harness(
|
|
task_id: &str,
|
|
selected_harness: &mut Harness,
|
|
task_harness: Harness,
|
|
) -> Result<HarnessKind, AgentDriverError> {
|
|
if *selected_harness == Harness::Oz {
|
|
*selected_harness = task_harness;
|
|
} else if task_harness != *selected_harness {
|
|
return Err(AgentDriverError::TaskHarnessMismatch {
|
|
task_id: task_id.to_string(),
|
|
expected: task_harness.to_string(),
|
|
got: selected_harness.to_string(),
|
|
});
|
|
}
|
|
|
|
harness_kind(*selected_harness)
|
|
}
|
|
|
|
/// Resolve a `Prompt` to a plain string.
|
|
fn resolve_prompt(prompt: &Prompt, ctx: &AppContext) -> Result<String, AgentDriverError> {
|
|
match prompt {
|
|
Prompt::PlainText(prompt_str) => Ok(prompt_str.to_string()),
|
|
Prompt::SavedPrompt(workflow_id) => {
|
|
let Some(workflow) = CloudModel::as_ref(ctx).get_workflow_by_uid(workflow_id) else {
|
|
return Err(AgentDriverError::AIWorkflowNotFound(workflow_id.to_owned()));
|
|
};
|
|
|
|
let Workflow::AgentMode { query, .. } = &workflow.model().data else {
|
|
return Err(AgentDriverError::AIWorkflowNotFound(workflow_id.to_owned()));
|
|
};
|
|
Ok(query.to_owned())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Run the task with the provided command.
|
|
fn run_task(
|
|
ctx: &mut AppContext,
|
|
global_options: GlobalOptions,
|
|
command: TaskCommand,
|
|
) -> anyhow::Result<()> {
|
|
match command {
|
|
TaskCommand::List(args) => ambient::list_ambient_agent_tasks(ctx, global_options, args),
|
|
TaskCommand::Get(args) => {
|
|
if args.conversation {
|
|
if !FeatureFlag::ConversationApi.is_enabled() {
|
|
return Err(anyhow::anyhow!(
|
|
"The --conversation flag is not available in this build"
|
|
));
|
|
}
|
|
ambient::get_run_conversation(ctx, args.task_id)
|
|
} else {
|
|
ambient::get_ambient_agent_task_status(ctx, global_options, args)
|
|
}
|
|
}
|
|
TaskCommand::Conversation(conv_cmd) => {
|
|
if !FeatureFlag::ConversationApi.is_enabled() {
|
|
return Err(anyhow::anyhow!(
|
|
"The 'conversation' subcommand is not available in this build"
|
|
));
|
|
}
|
|
match conv_cmd {
|
|
galaxy_cli::task::ConversationCommand::Get(args) => {
|
|
ambient::get_conversation(ctx, args.conversation_id)
|
|
}
|
|
}
|
|
}
|
|
TaskCommand::Message(message_cmd) => ambient::run_message(ctx, global_options, message_cmd),
|
|
}
|
|
}
|
|
|
|
/// Singleton model that provides a ModelContext for spawning async operations
|
|
/// when starting the agent driver. This is needed because conversation fetching
|
|
/// requires spawning an async task, which requires a ModelContext.
|
|
struct AgentDriverRunner;
|
|
|
|
impl galaxyui::Entity for AgentDriverRunner {
|
|
type Event = ();
|
|
}
|
|
|
|
impl galaxyui::SingletonEntity for AgentDriverRunner {}
|
|
|
|
impl AgentDriverRunner {
|
|
#[tracing::instrument(skip_all, err, fields(
|
|
tags.cloud_agent = true,
|
|
args.sandboxed = args.sandboxed,
|
|
args.computer_use = args.computer_use.computer_use,
|
|
args.no_computer_use = args.computer_use.no_computer_use
|
|
))]
|
|
async fn setup_and_run_driver(
|
|
foreground: ModelSpawner<Self>,
|
|
args: RunAgentArgs,
|
|
server_api: Arc<dyn AIClient>,
|
|
output_format: OutputFormat,
|
|
) -> Result<(), AgentDriverError> {
|
|
// Extract the task ID as early as possible for best-effort setup observability.
|
|
// Local CLI-created runs may not have a task yet, so those setup events explicitly no-op.
|
|
let mut task_id: Option<AmbientAgentTaskId> =
|
|
args.task_id.as_deref().and_then(|s| s.parse().ok());
|
|
Self::set_ambient_agent_task_id(&foreground, task_id).await?;
|
|
let background = foreground.spawn(|_, ctx| ctx.background_executor()).await?;
|
|
let setup_events = match task_id {
|
|
Some(task_id) => SetupClientEventReporter::new(task_id, server_api.clone(), background),
|
|
None => SetupClientEventReporter::noop(server_api.clone(), background),
|
|
};
|
|
setup_events
|
|
.post_timeline_event(OzRunTimelineEvent::WorkerContainerReady)
|
|
.await;
|
|
|
|
// Ensure we've synced team state before starting the driver.
|
|
setup_events
|
|
.record_result(
|
|
SetupStep::TeamMetadataRefresh,
|
|
Self::refresh_team_metadata(&foreground),
|
|
)
|
|
.await?;
|
|
|
|
// Wait for Galaxy Drive to sync before building the task config, since
|
|
// prompt resolution (SavedPrompt -> workflow lookup) and environment
|
|
// resolution (CloudAmbientAgentEnvironment lookup) depend on it.
|
|
setup_events
|
|
.record_result(SetupStep::WarpDriveSync, async {
|
|
if foreground
|
|
.spawn(|_, ctx| common::refresh_warp_drive(ctx))
|
|
.await?
|
|
.await
|
|
.is_err()
|
|
{
|
|
return Err(AgentDriverError::WarpDriveSyncFailed);
|
|
}
|
|
Ok(())
|
|
})
|
|
.await?;
|
|
|
|
// Set up and run the driver, reporting any errors back to the server.
|
|
let result: Result<(), AgentDriverError> = async {
|
|
// Pull relevant variables out of args before moving it into the closure.
|
|
let share_requests = args.share.share.clone();
|
|
let bedrock_inference_role = args.bedrock_inference_role.clone();
|
|
let bedrock_role_region = args.bedrock_role_region.clone();
|
|
let has_task_id = args.task_id.is_some();
|
|
let args_harness = args.harness;
|
|
// `--conversation` path (user-invoked local resume): validate before any task side
|
|
// effects so mismatches fail fast. The `--task-id` path derives its conversation id
|
|
// from the server-side task metadata inside `build_driver_options_and_task`. Both
|
|
// can currently be passed together (the worker server-side appends `--conversation`
|
|
// alongside `--task-id` for Slack/Linear followups); when both are set, the explicit
|
|
// `--conversation` value wins via the merge below.
|
|
if !has_task_id {
|
|
if let Some(conversation_id) = args.conversation.as_deref() {
|
|
common::fetch_and_validate_conversation_harness(
|
|
server_api.clone(),
|
|
conversation_id,
|
|
args_harness,
|
|
)
|
|
.await?;
|
|
}
|
|
}
|
|
let resume_conversation_id = args.conversation.clone();
|
|
|
|
// Build driver options and task, handling task creation or existing task setup.
|
|
// For the `--task-id` path, `task_conversation_id` is the `conversation_id` read off
|
|
// the fetched `AmbientAgentTask` (set by the server when linking the task to an
|
|
// existing conversation, e.g. via `run-cloud --conversation`).
|
|
let (mut driver_options, task, task_conversation_id) =
|
|
Self::build_driver_options_and_task(&foreground, args, &server_api, &setup_events)
|
|
.await?;
|
|
|
|
// Update the effective task ID so errors are reported correctly.
|
|
// This only matters if we created a task ID locally.
|
|
task_id = driver_options.task_id.or(task_id);
|
|
|
|
// The `--task-id` branch already validated `args_harness` against the task's harness
|
|
// setting inside `build_driver_options_and_task`; the conversation that the task spawned
|
|
// necessarily uses the same harness, so no extra conversation-metadata roundtrip is
|
|
// needed here. Just merge the task's linked conversation id into the resume target.
|
|
let resume_conversation_id = resume_conversation_id.or(task_conversation_id);
|
|
|
|
let bedrock_task_id = driver_options.task_id.map(|id| id.to_string());
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
if let Some(role_arn) = bedrock_inference_role {
|
|
// clap's `requires` constraint enforces this at parse time, so a missing
|
|
// region here means a caller is constructing `RunAgentArgs` directly
|
|
// without the flag. Fail loudly so callers don't silently fall back to a
|
|
// hard-coded STS region.
|
|
let role_region = bedrock_role_region.ok_or_else(|| {
|
|
AgentDriverError::AwsBedrockCredentialsFailed(
|
|
"--bedrock-role-region is required when --bedrock-inference-role is set"
|
|
.to_string(),
|
|
)
|
|
})?;
|
|
// Set the OIDC strategy on the UI thread and kick off the refresh; the
|
|
// returned future resolves when credentials are committed to the model.
|
|
let refresh_future = foreground
|
|
.spawn(move |_, ctx| {
|
|
ApiKeyManager::handle(ctx).update(ctx, |manager, ctx| {
|
|
// From here on, refresh credentials via OIDC federation only.
|
|
manager.set_aws_credentials_refresh_strategy(
|
|
AwsCredentialsRefreshStrategy::OidcManaged {
|
|
task_id: bedrock_task_id,
|
|
role_arn,
|
|
region: role_region,
|
|
},
|
|
);
|
|
refresh_aws_credentials(manager, ctx)
|
|
})
|
|
})
|
|
.await?;
|
|
|
|
refresh_future
|
|
.await
|
|
.map_err(AgentDriverError::AwsBedrockCredentialsFailed)?;
|
|
}
|
|
|
|
match &task.harness {
|
|
HarnessKind::Unsupported(harness) => {
|
|
return Err(AgentDriverError::HarnessSetupFailed {
|
|
harness: harness.to_string(),
|
|
reason: format!(
|
|
"The {harness} harness is only supported for local child agent launches."
|
|
),
|
|
});
|
|
}
|
|
HarnessKind::Oz | HarnessKind::ThirdParty(_) => {}
|
|
}
|
|
|
|
// Validate that the third-party harness is installed and authed.
|
|
if let HarnessKind::ThirdParty(harness) = &task.harness {
|
|
harness.validate()?;
|
|
}
|
|
|
|
if let Some(task_id) = driver_options.task_id {
|
|
driver::write_run_started(&task_id.to_string(), output_format);
|
|
}
|
|
|
|
// Pull conversation information, if we have it
|
|
if let Some(conversation_id) = resume_conversation_id {
|
|
driver_options.resume = setup_events
|
|
.record_result(
|
|
SetupStep::ConversationResumeLoading,
|
|
Self::load_conversation_information(
|
|
&foreground,
|
|
conversation_id,
|
|
&task.harness,
|
|
),
|
|
)
|
|
.await?;
|
|
}
|
|
|
|
// Run the driver
|
|
foreground
|
|
.spawn(move |_, ctx| {
|
|
Self::create_and_run_driver(
|
|
ctx,
|
|
driver_options,
|
|
output_format,
|
|
share_requests,
|
|
task,
|
|
);
|
|
})
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
.await;
|
|
|
|
if let Err(ref err) = result {
|
|
if let Some(task_id) = task_id {
|
|
driver::report_driver_error(task_id, err, &server_api).await;
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
async fn refresh_team_metadata(
|
|
foreground: &ModelSpawner<Self>,
|
|
) -> Result<(), AgentDriverError> {
|
|
foreground
|
|
.spawn(
|
|
|_, ctx| -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send>> {
|
|
Box::pin(common::refresh_workspace_metadata(ctx))
|
|
},
|
|
)
|
|
.await?
|
|
.await
|
|
.map_err(|_| AgentDriverError::TeamMetadataRefreshTimeout)
|
|
}
|
|
|
|
async fn set_ambient_agent_task_id(
|
|
foreground: &ModelSpawner<Self>,
|
|
task_id: Option<AmbientAgentTaskId>,
|
|
) -> Result<(), AgentDriverError> {
|
|
foreground
|
|
.spawn(move |_, ctx| {
|
|
ServerApiProvider::handle(ctx)
|
|
.as_ref(ctx)
|
|
.get()
|
|
.set_ambient_agent_task_id(task_id);
|
|
})
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn fetch_task_git_credentials(
|
|
task_id_str: String,
|
|
ai_client: Arc<dyn AIClient>,
|
|
) -> anyhow::Result<Vec<GitCredential>> {
|
|
let workload_token = warp_isolation_platform::issue_workload_token(Some(
|
|
std::time::Duration::from_secs(5 * 60),
|
|
))
|
|
.await?
|
|
.token;
|
|
ai_client
|
|
.get_task_git_credentials(task_id_str, workload_token)
|
|
.await
|
|
}
|
|
|
|
async fn bootstrap_git_credentials_for_task(
|
|
foreground: &ModelSpawner<Self>,
|
|
task_id_str: &str,
|
|
args: &RunAgentArgs,
|
|
) -> Result<(), AgentDriverError> {
|
|
if warp_isolation_platform::detect().is_none() && args.configure_git_credentials_with_github
|
|
{
|
|
foreground
|
|
.spawn(|_, _| {
|
|
command::blocking::Command::new("gh")
|
|
.args(["auth", "setup-git"])
|
|
.spawn()
|
|
.map_err(|err| {
|
|
AgentDriverError::ConfigBuildFailed(anyhow::anyhow!(
|
|
"gh auth setup-git failed: {err:?}"
|
|
))
|
|
})
|
|
})
|
|
.await?
|
|
.map(|_| ())?;
|
|
return Ok(());
|
|
}
|
|
|
|
if !FeatureFlag::GitCredentialRefresh.is_enabled() {
|
|
return Ok(());
|
|
}
|
|
|
|
if task_id_str.parse::<AmbientAgentTaskId>().is_err() {
|
|
log::debug!(
|
|
"Skipping git credentials bootstrap: could not parse task ID '{task_id_str}'"
|
|
);
|
|
return Ok(());
|
|
}
|
|
|
|
let (ai_client, task_id_str) = foreground
|
|
.spawn({
|
|
let task_id_str = task_id_str.to_string();
|
|
move |_, ctx| {
|
|
let ai_client = ServerApiProvider::handle(ctx)
|
|
.as_ref(ctx)
|
|
.get_ai_client()
|
|
.clone();
|
|
(ai_client, task_id_str)
|
|
}
|
|
})
|
|
.await?;
|
|
|
|
let credentials = match Self::fetch_task_git_credentials(task_id_str, ai_client).await {
|
|
Ok(credentials) => credentials,
|
|
Err(err)
|
|
if err
|
|
.downcast_ref::<IsolationPlatformError>()
|
|
.is_some_and(|err| {
|
|
matches!(err, IsolationPlatformError::NoIsolationPlatformDetected)
|
|
}) =>
|
|
{
|
|
log::debug!("Skipping git credentials bootstrap: {err}");
|
|
return Ok(());
|
|
}
|
|
Err(err) => {
|
|
return Err(AgentDriverError::SkillResolutionFailed(format!(
|
|
"Failed to fetch git credentials before skill resolution: {err:#}"
|
|
)));
|
|
}
|
|
};
|
|
if credentials.is_empty() {
|
|
log::debug!("No git credentials returned before skill resolution");
|
|
return Ok(());
|
|
}
|
|
|
|
driver::git_credentials::configure_git_credentials(&credentials).map_err(|err| {
|
|
AgentDriverError::SkillResolutionFailed(format!(
|
|
"Failed to write git credentials before skill resolution: {err:#}"
|
|
))
|
|
})?;
|
|
log::info!("Git credentials configured before task setup");
|
|
Ok(())
|
|
}
|
|
|
|
/// Resolve the skill spec from args, if one was provided.
|
|
///
|
|
/// In sandboxed mode with a fully-qualified spec (org + repo), the repo is
|
|
/// cloned first since it may not exist locally. Otherwise we resolve directly
|
|
/// against the local filesystem.
|
|
async fn resolve_skill(
|
|
foreground: &ModelSpawner<Self>,
|
|
args: &RunAgentArgs,
|
|
working_dir: &Path,
|
|
setup_events: &SetupClientEventReporter,
|
|
) -> Result<Option<ResolvedSkill>, AgentDriverError> {
|
|
if !FeatureFlag::OzPlatformSkills.is_enabled() {
|
|
return Ok(None);
|
|
}
|
|
let Some(skill_spec) = args.skill.clone() else {
|
|
return Ok(None);
|
|
};
|
|
|
|
// In sandboxed mode with a fully-qualified spec, clone the repo first.
|
|
let needs_clone = args.sandboxed && skill_spec.org.is_some() && skill_spec.repo.is_some();
|
|
if needs_clone {
|
|
let org = skill_spec.org.as_ref().expect("org checked above");
|
|
let repo_name = skill_spec.repo.as_ref().expect("repo checked above");
|
|
log::info!("Cloning {org}/{repo_name} for skill resolution in sandboxed mode");
|
|
setup_events
|
|
.record_result(SetupStep::SkillRepoClone, async {
|
|
clone_repo_for_skill(org, repo_name, working_dir)
|
|
.await
|
|
.map_err(|err| {
|
|
AgentDriverError::SkillResolutionFailed(format_skill_resolution_error(
|
|
err,
|
|
))
|
|
})
|
|
})
|
|
.await?;
|
|
}
|
|
|
|
let working_dir_buf = working_dir.to_path_buf();
|
|
let skill = foreground
|
|
.spawn(move |_, ctx| resolve_skill_spec(&skill_spec, &working_dir_buf, ctx))
|
|
.await?
|
|
.map_err(|err| {
|
|
AgentDriverError::SkillResolutionFailed(format_skill_resolution_error(err))
|
|
})?;
|
|
log::debug!(
|
|
"Resolved skill '{}' from {}",
|
|
skill.name,
|
|
skill.skill_path.display()
|
|
);
|
|
Ok(Some(skill))
|
|
}
|
|
|
|
/// Build the AgentDriverOptions and Task, handling task creation or existing task setup.
|
|
///
|
|
/// The third tuple element is the conversation id read off the server-side task metadata
|
|
/// on the `--task-id` branch. It's `None` when no task id was passed or when the task is
|
|
/// not linked to a conversation; callers use it to drive `--task-id`-implied resume
|
|
/// without requiring the caller to also pass `--conversation`.
|
|
async fn build_driver_options_and_task(
|
|
foreground: &ModelSpawner<Self>,
|
|
args: RunAgentArgs,
|
|
server_api: &Arc<dyn AIClient>,
|
|
setup_events: &SetupClientEventReporter,
|
|
) -> Result<(AgentDriverOptions, Task, Option<String>), AgentDriverError> {
|
|
// Get the working directory
|
|
let working_dir = match args.cwd.as_ref() {
|
|
Some(dir) => dunce::canonicalize(dir)
|
|
.with_context(|| format!("Unable to resolve {}", dir.display())),
|
|
None => std::env::current_dir().context("Unable to determine working directory"),
|
|
}
|
|
.map_err(AgentDriverError::ConfigBuildFailed)?;
|
|
|
|
if let Some(task_id_str) = args.task_id.as_ref() {
|
|
Self::bootstrap_git_credentials_for_task(foreground, task_id_str, &args).await?;
|
|
}
|
|
// Resolve the skill, if we have one
|
|
let resolved_skill =
|
|
Self::resolve_skill(foreground, &args, &working_dir, setup_events).await?;
|
|
|
|
// Extract variables we want to use later before moving args into the closure
|
|
let task_id_str = args.task_id.clone();
|
|
let prompt = args.prompt_arg.to_prompt();
|
|
let skill = args.skill.clone();
|
|
|
|
// Build the AgentConfigSnapshot, Task, and AgentDriverOptions
|
|
let prompt_clone = prompt.clone();
|
|
let (merged_config, mut task, mut driver_options) = foreground
|
|
.spawn(move |_, ctx| -> anyhow::Result<_> {
|
|
let (merged_config, task) =
|
|
build_merged_config_and_task(&args, &resolved_skill, &prompt_clone, ctx)?;
|
|
|
|
let task_id = args.task_id.as_ref().and_then(|s| s.parse().ok());
|
|
let should_share = (args.share.is_shared() || args.task_id.is_some())
|
|
&& FeatureFlag::AgentSharedSessions.is_enabled();
|
|
|
|
let third_party_harness_model_config = merged_config
|
|
.harness
|
|
.as_ref()
|
|
.and_then(|h| h.model_config());
|
|
let driver_options = driver::AgentDriverOptions {
|
|
working_dir: working_dir.clone(),
|
|
task_id,
|
|
parent_run_id: None,
|
|
should_share,
|
|
idle_on_complete: args.idle_on_complete.map(|d| d.into()),
|
|
secrets: Default::default(),
|
|
resume: None,
|
|
cloud_providers: Vec::new(),
|
|
environment: None,
|
|
selected_harness: args.harness,
|
|
third_party_harness_model_config,
|
|
snapshot_disabled: args.snapshot.no_snapshot.then_some(true),
|
|
snapshot_upload_timeout: args
|
|
.snapshot
|
|
.snapshot_upload_timeout
|
|
.map(|duration| duration.into()),
|
|
snapshot_script_timeout: args
|
|
.snapshot
|
|
.snapshot_script_timeout
|
|
.map(|duration| duration.into()),
|
|
skip_initial_turn: args.skip_initial_turn,
|
|
strict_mcp_startup: args.strict_mcp_startup,
|
|
mcp_startup_timeout: args.mcp_startup_timeout.map(|duration| duration.into()),
|
|
};
|
|
|
|
Ok((merged_config, task, driver_options))
|
|
})
|
|
.await?
|
|
.map_err(AgentDriverError::ConfigBuildFailed)?;
|
|
|
|
let environment_id = merged_config.environment_id.clone();
|
|
|
|
// Handle secrets/attachments fetch (existing task) or task creation (new run).
|
|
// The existing-task branch also surfaces the task's `conversation_id` (if any) so
|
|
// the caller can wire up resume without a separate `--conversation` arg.
|
|
let task_conversation_id = if let Some(task_id_str) = task_id_str {
|
|
setup_events
|
|
.record_result(
|
|
SetupStep::TaskDataFetch,
|
|
Self::fetch_secrets_and_attachments(
|
|
foreground,
|
|
task_id_str,
|
|
&mut driver_options,
|
|
&mut task,
|
|
),
|
|
)
|
|
.await?
|
|
} else {
|
|
// Extract the prompt text that we'll pass up to the server when we create the task.
|
|
let prompt_for_task_creation = match &prompt {
|
|
Some(Prompt::PlainText(text)) => text.clone(),
|
|
Some(Prompt::SavedPrompt(id)) => format!("Saved prompt ({id})"),
|
|
None => skill
|
|
.as_ref()
|
|
.map(|s| format!("/{}", s.skill_identifier))
|
|
// If we get to this point and we don't have a prompt, saved prompt, or skill,
|
|
// error. `clap` should have handled this when parsing args already.
|
|
.ok_or(AgentDriverError::InvalidRuntimeState)?,
|
|
};
|
|
|
|
Self::initialize_new_task(
|
|
foreground,
|
|
server_api,
|
|
prompt_for_task_creation,
|
|
merged_config,
|
|
&mut driver_options,
|
|
)
|
|
.await?;
|
|
None
|
|
};
|
|
// Resolve environment and cloud providers.
|
|
setup_events
|
|
.record_result(
|
|
SetupStep::EnvironmentResolution,
|
|
Self::resolve_environment(foreground, environment_id, &mut driver_options),
|
|
)
|
|
.await?;
|
|
|
|
Ok((driver_options, task, task_conversation_id))
|
|
}
|
|
|
|
/// Creates a new task on the server for this agent run, sets the task ID on the driver
|
|
/// options, and updates the Server API provider so that all subsequent requests to warp-server
|
|
/// contain this new task ID.
|
|
async fn initialize_new_task(
|
|
foreground: &ModelSpawner<Self>,
|
|
server_api: &Arc<dyn AIClient>,
|
|
prompt: String,
|
|
merged_config: AgentConfigSnapshot,
|
|
driver_options: &mut AgentDriverOptions,
|
|
) -> Result<(), AgentDriverError> {
|
|
let environment = merged_config.environment_id.clone();
|
|
let task_config = if merged_config.is_empty() {
|
|
None
|
|
} else {
|
|
let mut config = merged_config;
|
|
// We don't set a worker, since this is a local run.
|
|
config.worker_host = None;
|
|
Some(config)
|
|
};
|
|
|
|
let task_id = match server_api
|
|
.create_agent_task(prompt, environment, None, task_config)
|
|
.await
|
|
{
|
|
Ok(id) => {
|
|
log::info!("Created task: {id}");
|
|
Some(id)
|
|
}
|
|
Err(e) => {
|
|
log::error!("Failed to create task: {e}");
|
|
// Continue without a task_id rather than failing entirely
|
|
None
|
|
}
|
|
};
|
|
|
|
// Set the task ID on the ServerApi so it's sent with all subsequent requests.
|
|
Self::set_ambient_agent_task_id(foreground, task_id).await?;
|
|
driver_options.task_id = task_id;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// When starting an agent run from an existing task_id, fetch secrets, task metadata,
|
|
/// and task attachments (images and files) from the server and update the driver options.
|
|
///
|
|
/// Returns the task's `conversation_id` when the server has linked the task to an existing
|
|
/// AI conversation (e.g. a `run-cloud --conversation` spawn). The caller uses this to drive
|
|
/// transcript rehydration without a separate `--conversation` CLI arg.
|
|
async fn fetch_secrets_and_attachments(
|
|
foreground: &ModelSpawner<Self>,
|
|
task_id_str: String,
|
|
driver_options: &mut AgentDriverOptions,
|
|
task: &mut Task,
|
|
) -> Result<Option<String>, AgentDriverError> {
|
|
let (task_secrets, ai_client, server_api) = foreground
|
|
.spawn({
|
|
let task_id_str = task_id_str.clone();
|
|
move |_, ctx| {
|
|
let task_secrets = ManagedSecretManager::handle(ctx)
|
|
.as_ref(ctx)
|
|
.get_task_secrets(task_id_str);
|
|
let ai_client = ServerApiProvider::handle(ctx)
|
|
.as_ref(ctx)
|
|
.get_ai_client()
|
|
.clone();
|
|
let server_api = ServerApiProvider::handle(ctx).as_ref(ctx).get();
|
|
(task_secrets, ai_client, server_api)
|
|
}
|
|
})
|
|
.await?;
|
|
|
|
let parsed_task_id = match task_id_str.parse() {
|
|
Ok(id) => Some(id),
|
|
Err(e) => {
|
|
log::error!("Failed to parse task ID: {e}");
|
|
None
|
|
}
|
|
};
|
|
// Set the task ID on the ServerApi before any task-scoped server calls below, so failures
|
|
// during setup can still be reported with cloud-agent context.
|
|
Self::set_ambient_agent_task_id(foreground, parsed_task_id).await?;
|
|
|
|
// Fetch secrets, task metadata, regular attachments, and handoff snapshot
|
|
// attachments in parallel. The handoff snapshot fetch is independent of the
|
|
// other three calls and only shares the download dir (a cloned PathBuf).
|
|
let attachments_download_dir = attachments_download_dir(&driver_options.working_dir);
|
|
let task_ai_client = ai_client.clone();
|
|
let task_metadata = async {
|
|
match parsed_task_id {
|
|
Some(task_id) => task_ai_client
|
|
.get_ambient_agent_task(&task_id)
|
|
.await
|
|
.map(Some),
|
|
None => Ok(None),
|
|
}
|
|
};
|
|
|
|
// Handoff snapshot attachments for follow-up executions are written to
|
|
// {attachments_dir}/handoff/{uuid} so the server-side rehydration prompt
|
|
// references resolve to real files.
|
|
let handoff_snapshot_ai_client = ai_client.clone();
|
|
let handoff_snapshot_server_api = server_api.clone();
|
|
let handoff_snapshot_download_dir = attachments_download_dir.clone();
|
|
let handoff_snapshot = async move {
|
|
if !FeatureFlag::OzHandoff.is_enabled() {
|
|
return Ok(None);
|
|
}
|
|
let Some(task_id_parsed) = parsed_task_id else {
|
|
return Ok(None);
|
|
};
|
|
driver::attachments::fetch_and_download_handoff_snapshot_attachments(
|
|
handoff_snapshot_ai_client,
|
|
handoff_snapshot_server_api.http_client(),
|
|
task_id_parsed,
|
|
handoff_snapshot_download_dir,
|
|
)
|
|
.await
|
|
};
|
|
|
|
let (secrets_result, attachments_result, task_metadata_result, handoff_snapshot_result) = futures::join!(
|
|
task_secrets,
|
|
driver::attachments::fetch_and_download_attachments(
|
|
ai_client.clone(),
|
|
server_api.clone(),
|
|
task_id_str.clone(),
|
|
attachments_download_dir.clone(),
|
|
),
|
|
task_metadata,
|
|
handoff_snapshot,
|
|
);
|
|
|
|
// Extract attachments_dir from successful result, log errors
|
|
let mut attachments_dir = match attachments_result {
|
|
Ok(dir) => dir,
|
|
Err(e) => {
|
|
log::warn!("Failed to fetch and download attachments: {e:#}");
|
|
None
|
|
}
|
|
};
|
|
|
|
match handoff_snapshot_result {
|
|
Ok(Some(dir)) => {
|
|
// Ensure attachments_dir is set so it's passed to the server even when
|
|
// there were no regular task attachments.
|
|
attachments_dir.get_or_insert(dir);
|
|
}
|
|
Ok(None) => {}
|
|
Err(e) => {
|
|
log::warn!("Failed to fetch handoff snapshot attachments: {e:#}");
|
|
}
|
|
}
|
|
|
|
let secrets = match secrets_result {
|
|
Ok(secrets) => secrets,
|
|
Err(err) => {
|
|
// Ignore errors due to running in a non-isolated environment.
|
|
// Otherwise, fail fast - we should not start the driver without secrets
|
|
// in an environment where they should be available.
|
|
if err
|
|
.downcast_ref::<IsolationPlatformError>()
|
|
.is_some_and(|err| {
|
|
matches!(err, IsolationPlatformError::NoIsolationPlatformDetected)
|
|
})
|
|
{
|
|
Default::default()
|
|
} else {
|
|
return Err(AgentDriverError::SecretsFetchFailed(err));
|
|
}
|
|
}
|
|
};
|
|
let (parent_run_id, task_conversation_id, task_harness, task_harness_model_config) =
|
|
match task_metadata_result {
|
|
Ok(Some(task_metadata)) => {
|
|
// The task's harness is stored on the snapshot; if absent, it's the default Oz.
|
|
let task_harness_config = task_metadata
|
|
.agent_config_snapshot
|
|
.as_ref()
|
|
.and_then(|c| c.harness.as_ref());
|
|
let task_harness = task_harness_config
|
|
.map(|h| h.harness_type)
|
|
.unwrap_or(Harness::Oz);
|
|
let task_harness_model_config =
|
|
task_harness_config.and_then(|h| h.model_config());
|
|
(
|
|
task_metadata.parent_run_id,
|
|
task_metadata.conversation_id,
|
|
Some(task_harness),
|
|
task_harness_model_config,
|
|
)
|
|
}
|
|
Ok(None) => (None, None, None, None),
|
|
Err(err) => {
|
|
log::warn!("Failed to fetch task metadata: {err:#}");
|
|
(None, None, None, None)
|
|
}
|
|
};
|
|
|
|
// Validate the requested `--harness` against the task's harness setting. This avoids the
|
|
// extra conversation-metadata roundtrip that would otherwise be needed downstream when the
|
|
// task is linked to an existing conversation, since task harness and conversation harness
|
|
// always match (the task spawned the conversation).
|
|
if let Some(task_harness) = task_harness {
|
|
task.harness = reconcile_task_harness(
|
|
&task_id_str,
|
|
&mut driver_options.selected_harness,
|
|
task_harness,
|
|
)?;
|
|
}
|
|
|
|
driver_options.task_id = parsed_task_id;
|
|
driver_options.parent_run_id = parent_run_id;
|
|
driver_options.secrets = secrets;
|
|
// CLI flags continue to take precedence so users can still override per-invocation.
|
|
if driver_options.third_party_harness_model_config.is_none() {
|
|
driver_options.third_party_harness_model_config = task_harness_model_config;
|
|
}
|
|
|
|
// Update the task prompt to include the downloaded attachments dir
|
|
if let AgentRunPrompt::ServerSide {
|
|
attachments_dir: ref mut dir,
|
|
..
|
|
} = task.prompt
|
|
{
|
|
*dir = attachments_dir;
|
|
}
|
|
|
|
Ok(task_conversation_id)
|
|
}
|
|
|
|
/// If we are starting this agent run from an existing conversation, load the conversation
|
|
/// data from the server and return the harness-specific [`ResumeOptions`] payload that the
|
|
/// caller plugs onto [`AgentDriverOptions::resume`].
|
|
///
|
|
/// `harness` is the resolved harness from the task config (already validated against the
|
|
/// conversation's metadata up-front by [`common::fetch_and_validate_conversation_harness`]).
|
|
///
|
|
/// For the Oz harness, fetches the full conversation and returns a [`driver::ResumeOptions::Oz`].
|
|
/// For third-party harnesses, delegates to [`ThirdPartyHarness::fetch_resume_payload`] and
|
|
/// wraps the returned payload (if any) in [`driver::ResumeOptions::ThirdParty`]; each harness
|
|
/// owns its server call and error mapping. Returns `None` if a third-party harness has no
|
|
/// resume payload to surface.
|
|
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, conversation_id = conversation_id))]
|
|
async fn load_conversation_information(
|
|
foreground: &ModelSpawner<Self>,
|
|
conversation_id: String,
|
|
harness: &HarnessKind,
|
|
) -> Result<Option<driver::ResumeOptions>, AgentDriverError> {
|
|
match harness {
|
|
HarnessKind::Oz => {
|
|
let server_api = foreground
|
|
.spawn(|_, ctx| {
|
|
ServerApiProvider::handle(ctx)
|
|
.as_ref(ctx)
|
|
.get_ai_client()
|
|
.clone()
|
|
})
|
|
.await?;
|
|
let token = ServerConversationToken::new(conversation_id.clone());
|
|
let (conversation_data, metadata) = server_api
|
|
.get_ai_conversation(token)
|
|
.await
|
|
.map_err(|err| AgentDriverError::ConversationLoadFailed(format!("{err}")))?;
|
|
let conversation = convert_conversation_data_to_ai_conversation(
|
|
AIConversationId::default(),
|
|
&conversation_data,
|
|
metadata,
|
|
RestorationMode::Continue,
|
|
)
|
|
.ok_or_else(|| {
|
|
AgentDriverError::ConversationLoadFailed(
|
|
"Failed to convert conversation data to AIConversation".into(),
|
|
)
|
|
})?;
|
|
Ok(Some(driver::ResumeOptions::Oz(Box::new(
|
|
ConversationRestorationInNewPaneType::Historical {
|
|
conversation,
|
|
should_use_live_appearance: false,
|
|
ambient_agent_task_id: None,
|
|
},
|
|
))))
|
|
}
|
|
HarnessKind::ThirdParty(h) => {
|
|
let harness_support_client = foreground
|
|
.spawn(|_, ctx| ServerApiProvider::as_ref(ctx).get_harness_support_client())
|
|
.await?;
|
|
let resume_conversation_id = AIConversationId::try_from(conversation_id.clone())
|
|
.map_err(|err| AgentDriverError::ConversationLoadFailed(format!("{err:#}")))?;
|
|
Ok(
|
|
h.fetch_resume_payload(&resume_conversation_id, harness_support_client)
|
|
.await?
|
|
.map(|payload| driver::ResumeOptions::ThirdParty(Box::new(payload))),
|
|
)
|
|
}
|
|
HarnessKind::Unsupported(harness) => Err(AgentDriverError::HarnessSetupFailed {
|
|
harness: harness.to_string(),
|
|
reason: format!(
|
|
"The {harness} harness is only supported for local child agent launches."
|
|
),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Resolve the environment and store into `driver_options`.
|
|
#[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, ?environment_id))]
|
|
async fn resolve_environment(
|
|
foreground: &ModelSpawner<Self>,
|
|
environment_id: Option<String>,
|
|
driver_options: &mut AgentDriverOptions,
|
|
) -> Result<(), AgentDriverError> {
|
|
let Some(environment_id) = environment_id else {
|
|
return Ok(());
|
|
};
|
|
|
|
let environment = foreground
|
|
.spawn(move |_, ctx| -> Result<_, AgentDriverError> {
|
|
let server_id = ServerId::try_from(environment_id.as_str()).map_err(|_| {
|
|
log::error!("Invalid environment ID: {environment_id}");
|
|
AgentDriver::log_valid_environments(ctx);
|
|
AgentDriverError::EnvironmentNotFound(environment_id.clone())
|
|
})?;
|
|
let sync_id = SyncId::ServerId(server_id);
|
|
|
|
CloudAmbientAgentEnvironment::get_by_id(&sync_id, ctx)
|
|
.ok_or_else(|| {
|
|
log::error!("Environment not found with ID: {environment_id}");
|
|
AgentDriver::log_valid_environments(ctx);
|
|
AgentDriverError::EnvironmentNotFound(environment_id)
|
|
})
|
|
.map(|env| env.model().string_model.clone())
|
|
})
|
|
.await??;
|
|
|
|
if FeatureFlag::OzIdentityFederation.is_enabled() {
|
|
let run_id = driver_options
|
|
.task_id
|
|
.map(|id| id.to_string())
|
|
.unwrap_or_else(|| "local".to_string());
|
|
driver_options.cloud_providers =
|
|
driver::cloud_provider::load_providers(&environment.providers, &run_id)
|
|
.map_err(AgentDriverError::CloudProviderSetupFailed)?;
|
|
}
|
|
|
|
driver_options.environment = Some(environment);
|
|
Ok(())
|
|
}
|
|
|
|
/// Create the AgentDriver and start running the task.
|
|
#[tracing::instrument(skip_all, fields(tags.cloud_agent = true))]
|
|
fn create_and_run_driver(
|
|
ctx: &mut AppContext,
|
|
driver_options: driver::AgentDriverOptions,
|
|
output_format: OutputFormat,
|
|
share_requests: Option<Vec<ShareRequest>>,
|
|
task: driver::Task,
|
|
) {
|
|
maybe_warn_team_api_key(ctx);
|
|
|
|
// Initializing the driver will fail if not logged in. Since we check that above, panic here - it's difficult to
|
|
// fallibly instantiate a UI framework model.
|
|
let driver = ctx.add_singleton_model(|ctx| {
|
|
AgentDriver::new(driver_options, ctx).expect("Could not initialize driver")
|
|
});
|
|
|
|
driver.update(ctx, |driver, ctx| {
|
|
driver.set_output_format(output_format);
|
|
if let Some(share_requests) = share_requests {
|
|
driver.add_share_requests(share_requests, ctx);
|
|
}
|
|
let span =
|
|
tracing::info_span!("AgentDriver::run", tags.cloud_agent = true, ?task.model, ?task.harness);
|
|
let agent_future = driver.run(task, ctx).instrument(span);
|
|
|
|
ctx.spawn(agent_future, |_, result, ctx| match result {
|
|
Ok(()) => {
|
|
ctx.terminate_app(TerminationMode::ForceTerminate, None);
|
|
}
|
|
Err(err) => {
|
|
report_fatal_error(err.into(), ctx);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Returns `true` if the given CLI command requires authentication.
|
|
fn command_requires_auth(command: &CliCommand) -> bool {
|
|
match command {
|
|
CliCommand::Agent(agent_cmd) => match agent_cmd {
|
|
AgentCommand::Run { .. } => true,
|
|
AgentCommand::RunCloud { .. } => true,
|
|
AgentCommand::Profile(sub) => match sub {
|
|
AgentProfileCommand::List => true,
|
|
},
|
|
AgentCommand::List(_) => true,
|
|
AgentCommand::Get(_) => true,
|
|
AgentCommand::Create(_) => true,
|
|
AgentCommand::Update(_) => true,
|
|
AgentCommand::Delete(_) => true,
|
|
AgentCommand::Skills(_) => true,
|
|
},
|
|
CliCommand::Environment(environment_cmd) => match environment_cmd {
|
|
EnvironmentCommand::List => true,
|
|
EnvironmentCommand::Create { .. } => true,
|
|
EnvironmentCommand::Delete { .. } => true,
|
|
EnvironmentCommand::Update { .. } => true,
|
|
EnvironmentCommand::Get { .. } => true,
|
|
EnvironmentCommand::Image(ImageCommand::List) => true,
|
|
},
|
|
CliCommand::MCP(mcp_cmd) => match mcp_cmd {
|
|
MCPCommand::List => true,
|
|
},
|
|
CliCommand::Run(task_cmd) => match task_cmd {
|
|
TaskCommand::List { .. } => true,
|
|
TaskCommand::Get { .. } => true,
|
|
TaskCommand::Conversation { .. } => true,
|
|
TaskCommand::Message { .. } => true,
|
|
},
|
|
CliCommand::Model(model_cmd) => match model_cmd {
|
|
ModelCommand::List => true,
|
|
},
|
|
CliCommand::MemoryStore(_) => true,
|
|
CliCommand::Memory(_) => true,
|
|
CliCommand::Login => false,
|
|
CliCommand::Logout => false,
|
|
CliCommand::Whoami => true,
|
|
CliCommand::Provider(_) => true,
|
|
CliCommand::Integration(_) => true,
|
|
CliCommand::Schedule(_) => true,
|
|
CliCommand::Secret(_) => true,
|
|
CliCommand::Federate(_) => true,
|
|
CliCommand::HarnessSupport(_) => true,
|
|
CliCommand::Artifact(_) => true,
|
|
CliCommand::ApiKey(_) => true,
|
|
}
|
|
}
|
|
|
|
/// Launch a CLI command, checking authentication first if needed.
|
|
///
|
|
/// If auth is not required, dispatches the command immediately.
|
|
/// If auth is required and the user is logged in, triggers a user refresh
|
|
/// before launching the command.
|
|
fn launch_command(
|
|
ctx: &mut AppContext,
|
|
command: CliCommand,
|
|
global_options: GlobalOptions,
|
|
) -> anyhow::Result<()> {
|
|
let requires_auth = command_requires_auth(&command);
|
|
|
|
if !requires_auth {
|
|
return dispatch_command(ctx, command, global_options);
|
|
}
|
|
|
|
let cli_name = galaxy_cli::binary_name().unwrap_or_else(|| "warp".to_string());
|
|
|
|
let auth_state = AuthStateProvider::handle(ctx).as_ref(ctx).get();
|
|
if !auth_state.is_logged_in() {
|
|
return Err(anyhow::anyhow!(
|
|
"You are not logged in - please log in with `{cli_name} login` to continue."
|
|
));
|
|
}
|
|
|
|
// User is logged in — subscribe to auth events, trigger a refresh, and wait
|
|
// for the result before running the command.
|
|
let mut dispatched = false;
|
|
ctx.subscribe_to_model(&AuthManager::handle(ctx), move |_, event, ctx| {
|
|
if dispatched {
|
|
return;
|
|
}
|
|
match event {
|
|
AuthManagerEvent::AuthComplete => {
|
|
dispatched = true;
|
|
if let Err(err) = dispatch_command(ctx, command.clone(), global_options.clone()) {
|
|
report_fatal_error(err, ctx);
|
|
}
|
|
}
|
|
AuthManagerEvent::NeedsReauth => {
|
|
dispatched = true;
|
|
let auth_state = AuthStateProvider::handle(ctx).as_ref(ctx).get();
|
|
let message = if auth_state.is_api_key_authenticated() {
|
|
"Your API key is invalid. Please provide a valid key via '--api-key' or the GALAXY_API_KEY environment variable.".to_string()
|
|
} else {
|
|
format!("Your credentials are invalid. Please log in again with `{cli_name} login`.")
|
|
};
|
|
report_fatal_error(anyhow::anyhow!(message), ctx);
|
|
}
|
|
AuthManagerEvent::AuthFailed(err) => {
|
|
dispatched = true;
|
|
report_fatal_error(anyhow::anyhow!("Authentication failed: {err:#}"), ctx);
|
|
}
|
|
_ => {}
|
|
}
|
|
});
|
|
|
|
// Trigger the user refresh - the subscription above will handle the result.
|
|
AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| {
|
|
auth_manager.refresh_user(ctx);
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Check if we're running within Warp (for example, if this is an invocation of the Warp CLI
|
|
/// within a Warp terminal session).
|
|
pub fn is_running_in_warp() -> bool {
|
|
std::env::var("TERM_PROGRAM")
|
|
.map(|v| v == "WarpTerminal")
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Report a fatal error and terminate the app.
|
|
fn report_fatal_error(err: anyhow::Error, ctx: &mut AppContext) {
|
|
let mut message = err.to_string();
|
|
for cause in err.chain().skip(1) {
|
|
let _ = write!(&mut message, "\n=> {cause}");
|
|
}
|
|
|
|
tracing::event!(tracing::Level::ERROR, tags.cloud_agent = true, message);
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
{
|
|
if let Ok(path) = log_file_path() {
|
|
let _ = write!(
|
|
message,
|
|
"\n\nFor more information, check Warp logs at {}",
|
|
path.display()
|
|
);
|
|
}
|
|
}
|
|
|
|
let error = anyhow::anyhow!(message);
|
|
ctx.terminate_app(TerminationMode::ForceTerminate, Some(Err(error)));
|
|
}
|
|
|
|
fn resolve_orchestration_harness_label() -> &'static str {
|
|
let Ok(raw) = std::env::var(OZ_HARNESS_ENV) else {
|
|
return "unknown";
|
|
};
|
|
match Harness::parse_orchestration_harness(&raw) {
|
|
Some(Harness::Oz) => "oz",
|
|
Some(Harness::Claude) => "claude",
|
|
Some(Harness::OpenCode) => "opencode",
|
|
Some(Harness::Gemini) => "gemini",
|
|
Some(Harness::Codex) => "codex",
|
|
Some(Harness::Unknown) | None => "unknown",
|
|
}
|
|
}
|
|
|
|
/// Map each CLI command into a telemetry event to emit when it's executed.
|
|
fn command_to_telemetry_event(command: &CliCommand) -> CliTelemetryEvent {
|
|
match command {
|
|
CliCommand::Agent(AgentCommand::Run(args)) => CliTelemetryEvent::AgentRun {
|
|
gui: args.gui,
|
|
requested_mcp_servers: args.mcp_specs.len() + args.mcp_servers.len(),
|
|
has_environment: args.environment.is_some(),
|
|
task_id: args.task_id.clone(),
|
|
harness: args.harness.to_string(),
|
|
},
|
|
CliCommand::Agent(AgentCommand::RunCloud(_)) => CliTelemetryEvent::AgentRunAmbient,
|
|
CliCommand::Agent(AgentCommand::Profile(sub)) => match sub {
|
|
AgentProfileCommand::List => CliTelemetryEvent::AgentProfileList,
|
|
},
|
|
CliCommand::Agent(AgentCommand::List(_)) => CliTelemetryEvent::AgentList,
|
|
CliCommand::Agent(AgentCommand::Get(_)) => CliTelemetryEvent::AgentGet,
|
|
CliCommand::Agent(AgentCommand::Create(_)) => CliTelemetryEvent::AgentCreate,
|
|
CliCommand::Agent(AgentCommand::Update(_)) => CliTelemetryEvent::AgentUpdate,
|
|
CliCommand::Agent(AgentCommand::Delete(_)) => CliTelemetryEvent::AgentDelete,
|
|
CliCommand::Agent(AgentCommand::Skills(_)) => CliTelemetryEvent::AgentSkills,
|
|
CliCommand::Environment(EnvironmentCommand::List) => CliTelemetryEvent::EnvironmentList,
|
|
CliCommand::Environment(EnvironmentCommand::Create { .. }) => {
|
|
CliTelemetryEvent::EnvironmentCreate
|
|
}
|
|
CliCommand::Environment(EnvironmentCommand::Delete { .. }) => {
|
|
CliTelemetryEvent::EnvironmentDelete
|
|
}
|
|
CliCommand::Environment(EnvironmentCommand::Update { .. }) => {
|
|
CliTelemetryEvent::EnvironmentUpdate
|
|
}
|
|
CliCommand::Environment(EnvironmentCommand::Get { .. }) => {
|
|
CliTelemetryEvent::EnvironmentGet
|
|
}
|
|
CliCommand::Environment(EnvironmentCommand::Image(ImageCommand::List)) => {
|
|
CliTelemetryEvent::EnvironmentImageList
|
|
}
|
|
CliCommand::MCP(MCPCommand::List) => CliTelemetryEvent::MCPList,
|
|
CliCommand::Run(TaskCommand::List(_)) => CliTelemetryEvent::TaskList,
|
|
CliCommand::Run(TaskCommand::Get(args)) => {
|
|
if args.conversation {
|
|
CliTelemetryEvent::RunConversationGet
|
|
} else {
|
|
CliTelemetryEvent::TaskGet
|
|
}
|
|
}
|
|
CliCommand::Run(TaskCommand::Conversation(_)) => CliTelemetryEvent::ConversationGet,
|
|
CliCommand::Run(TaskCommand::Message(message_cmd)) => match message_cmd {
|
|
MessageCommand::Watch(_) => CliTelemetryEvent::RunMessageWatch {
|
|
harness: resolve_orchestration_harness_label(),
|
|
},
|
|
MessageCommand::Send(_) => CliTelemetryEvent::RunMessageSend {
|
|
harness: resolve_orchestration_harness_label(),
|
|
},
|
|
MessageCommand::List(_) => CliTelemetryEvent::RunMessageList {
|
|
harness: resolve_orchestration_harness_label(),
|
|
},
|
|
MessageCommand::Read(_) => CliTelemetryEvent::RunMessageRead {
|
|
harness: resolve_orchestration_harness_label(),
|
|
},
|
|
MessageCommand::MarkDelivered(_) => CliTelemetryEvent::RunMessageMarkDelivered {
|
|
harness: resolve_orchestration_harness_label(),
|
|
},
|
|
},
|
|
CliCommand::Model(ModelCommand::List) => CliTelemetryEvent::ModelList,
|
|
CliCommand::MemoryStore(memory_store_cmd) => match memory_store_cmd {
|
|
MemoryStoreCommand::List => CliTelemetryEvent::MemoryStoreList,
|
|
MemoryStoreCommand::Get(_) => CliTelemetryEvent::MemoryStoreGetStore,
|
|
MemoryStoreCommand::Update(_) => CliTelemetryEvent::MemoryStoreUpdateStore,
|
|
MemoryStoreCommand::ListStoreAgents(_) => CliTelemetryEvent::MemoryStoreListStoreAgents,
|
|
},
|
|
CliCommand::Memory(memory_cmd) => match memory_cmd {
|
|
MemoryCommand::List(_) => CliTelemetryEvent::MemoryStoreListMemories,
|
|
MemoryCommand::Create(_) => CliTelemetryEvent::MemoryStoreCreateMemory,
|
|
MemoryCommand::Update(_) => CliTelemetryEvent::MemoryStoreUpdateMemory,
|
|
MemoryCommand::Delete(_) => CliTelemetryEvent::MemoryStoreDeleteMemory,
|
|
MemoryCommand::Versions(_) => CliTelemetryEvent::MemoryStoreListVersions,
|
|
},
|
|
CliCommand::Login => CliTelemetryEvent::Login,
|
|
CliCommand::Logout => CliTelemetryEvent::Logout,
|
|
CliCommand::Whoami => CliTelemetryEvent::Whoami,
|
|
CliCommand::Provider(ProviderCommand::Setup(_)) => CliTelemetryEvent::ProviderSetup,
|
|
CliCommand::Provider(ProviderCommand::List) => CliTelemetryEvent::ProviderList,
|
|
CliCommand::Integration(integration_cmd) => match integration_cmd {
|
|
IntegrationCommand::Create(_) => CliTelemetryEvent::IntegrationCreate,
|
|
IntegrationCommand::Update(_) => CliTelemetryEvent::IntegrationUpdate,
|
|
IntegrationCommand::List => CliTelemetryEvent::IntegrationList,
|
|
},
|
|
CliCommand::Schedule(c) => match c.subcommand() {
|
|
None | Some(ScheduleSubcommand::Create(_)) => CliTelemetryEvent::ScheduleCreate,
|
|
Some(ScheduleSubcommand::List) => CliTelemetryEvent::ScheduleList,
|
|
Some(ScheduleSubcommand::Get(_)) => CliTelemetryEvent::ScheduleGet,
|
|
Some(ScheduleSubcommand::Pause(_)) => CliTelemetryEvent::SchedulePause,
|
|
Some(ScheduleSubcommand::Unpause(_)) => CliTelemetryEvent::ScheduleUnpause,
|
|
Some(ScheduleSubcommand::Update(_)) => CliTelemetryEvent::ScheduleUpdate,
|
|
Some(ScheduleSubcommand::Delete(_)) => CliTelemetryEvent::ScheduleDelete,
|
|
},
|
|
CliCommand::Secret(secret_cmd) => match secret_cmd {
|
|
SecretCommand::Create(_) => CliTelemetryEvent::SecretCreate,
|
|
SecretCommand::Delete(_) => CliTelemetryEvent::SecretDelete,
|
|
SecretCommand::Update(_) => CliTelemetryEvent::SecretUpdate,
|
|
SecretCommand::List(_) => CliTelemetryEvent::SecretList,
|
|
},
|
|
CliCommand::Federate(federate_cmd) => match federate_cmd {
|
|
FederateCommand::IssueToken(_) => CliTelemetryEvent::FederateIssueToken,
|
|
FederateCommand::IssueGcpToken(_) => CliTelemetryEvent::FederateIssueGcpToken,
|
|
},
|
|
CliCommand::HarnessSupport(args) => match &args.command {
|
|
HarnessSupportCommand::Ping => CliTelemetryEvent::HarnessSupportPing,
|
|
HarnessSupportCommand::ReportArtifact(report_args) => match &report_args.command {
|
|
ReportArtifactCommand::PullRequest(_) => {
|
|
CliTelemetryEvent::HarnessSupportReportArtifact {
|
|
artifact_type: "pull_request",
|
|
}
|
|
}
|
|
},
|
|
HarnessSupportCommand::NotifyUser(_) => CliTelemetryEvent::HarnessSupportNotifyUser,
|
|
HarnessSupportCommand::FinishTask(finish_args) => {
|
|
CliTelemetryEvent::HarnessSupportFinishTask {
|
|
success: finish_args.status == TaskStatus::Success,
|
|
}
|
|
}
|
|
HarnessSupportCommand::ReportShutdown(_) => {
|
|
CliTelemetryEvent::HarnessSupportReportShutdown
|
|
}
|
|
},
|
|
CliCommand::Artifact(artifact_cmd) => match artifact_cmd {
|
|
ArtifactCommand::Upload(_) => CliTelemetryEvent::ArtifactUpload,
|
|
ArtifactCommand::Get(_) => CliTelemetryEvent::ArtifactGet,
|
|
ArtifactCommand::Download(_) => CliTelemetryEvent::ArtifactDownload,
|
|
},
|
|
CliCommand::ApiKey(api_key_cmd) => match api_key_cmd {
|
|
ApiKeyCommand::List(_) => CliTelemetryEvent::ApiKeyList,
|
|
ApiKeyCommand::Create(_) => CliTelemetryEvent::ApiKeyCreate,
|
|
ApiKeyCommand::Expire(_) => CliTelemetryEvent::ApiKeyExpire,
|
|
},
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "mod_tests.rs"]
|
|
mod tests;
|