diff --git a/desktop/src-tauri/src/commands/agent_backend_update.rs b/desktop/src-tauri/src/commands/agent_backend_update.rs new file mode 100644 index 0000000000..c7238c9a53 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_backend_update.rs @@ -0,0 +1,115 @@ +use std::collections::HashMap; + +use tauri::AppHandle; + +use crate::{ + app_state::AppState, + managed_agents::{ + build_managed_agent_summary, load_managed_agents, load_personas, + stop_managed_agent_process, BackendKind, ManagedAgentPairRuntime, ManagedAgentRecord, + ManagedAgentRuntimeKey, ManagedAgentSummary, + }, +}; + +pub(super) fn requested_backend_update( + current: &BackendKind, + backend_agent_id: Option<&str>, + requested: Option, +) -> Result, String> { + let Some(requested) = requested else { + return Ok(None); + }; + if requested == BackendKind::Local + && *current != BackendKind::Local + && backend_agent_id.is_some() + { + return Err( + "cannot move a deployed provider agent to local: the provider protocol does not support undeploy" + .to_string(), + ); + } + Ok(Some(requested)) +} + +pub(super) fn apply_backend_update( + app: &AppHandle, + record: &mut ManagedAgentRecord, + runtimes: &mut HashMap, + requested: Option, +) -> Result, String> { + let Some(backend) = requested_backend_update( + &record.backend, + record.backend_agent_id.as_deref(), + requested, + )? + else { + return Ok(None); + }; + if record.backend == BackendKind::Local && backend != BackendKind::Local { + stop_managed_agent_process(app, record, runtimes)?; + } + record.provider_binary_path = match &backend { + BackendKind::Provider { config, id } => { + crate::managed_agents::validate_provider_config(config)?; + Some( + crate::managed_agents::resolve_provider_binary(id)? + .display() + .to_string(), + ) + } + BackendKind::Local => None, + }; + if backend != record.backend { + record.backend_agent_id = None; + } + record.start_on_app_launch = backend == BackendKind::Local; + record.backend = backend; + Ok(match &record.backend { + BackendKind::Provider { .. } => Some(record.backend.clone()), + BackendKind::Local => None, + }) +} + +pub(super) async fn deploy_updated_backend( + app: &AppHandle, + state: &AppState, + pubkey: &str, + backend: Option, + fallback: ManagedAgentSummary, +) -> Result { + let Some(BackendKind::Provider { id, config }) = backend else { + return Ok(fallback); + }; + let agent_json = { + let _guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let records = load_managed_agents(app)?; + let record = records + .iter() + .find(|r| r.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + super::agents::build_deploy_payload(app, state, record)? + }; + super::agents::deploy_to_provider(app, state, pubkey, &id, &config, agent_json, None).await?; + let _guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let records = load_managed_agents(app)?; + let runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + let record = records + .iter() + .find(|r| r.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + build_managed_agent_summary( + app, + record, + &runtimes, + &load_personas(app).unwrap_or_default(), + ) +} diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 425eadb5f6..e08234fdc6 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -1,12 +1,10 @@ -use std::collections::{BTreeMap, HashSet}; - +use super::agent_model_process::run_agent_models_command; +use super::agent_update_rollback::{rollback_failed_agent_update, AgentUpdateRollback}; use nostr::Keys; use serde::Deserialize; +use std::collections::{BTreeMap, HashSet}; use tauri::{AppHandle, State}; -use super::agent_model_process::run_agent_models_command; -use super::agent_update_rollback::{rollback_failed_agent_update, AgentUpdateRollback}; - use crate::{ app_state::AppState, managed_agents::{ @@ -778,8 +776,9 @@ pub async fn update_managed_agent( app: AppHandle, state: State<'_, AppState>, ) -> Result { + let pubkey = input.pubkey.clone(); // Phase 1: local save (synchronous, under lock) - let (summary, sync_params, rollback) = { + let (summary, sync_params, rollback, provider_deploy) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -795,9 +794,8 @@ pub async fn update_managed_agent( state.clear_agent_session_caches(pubkey); } - let record = find_managed_agent_mut(&mut records, &input.pubkey)?; + let record = find_managed_agent_mut(&mut records, &pubkey)?; let previous_record = record.clone(); - let mut name_changed = false; if let Some(name_update) = input.name { let trimmed = name_update.trim().to_string(); @@ -897,14 +895,21 @@ pub async fn update_managed_agent( record.respond_to_allowlist = prospective_allowlist; } + let backend_deploy = super::agent_backend_update::apply_backend_update( + &app, + record, + &mut runtimes, + input.backend, + )?; + record.updated_at = now_iso(); save_managed_agents(&app, &records)?; let record = records .iter() - .find(|r| r.pubkey == input.pubkey) - .ok_or_else(|| format!("agent {} not found", input.pubkey))?; + .find(|r| r.pubkey == pubkey) + .ok_or_else(|| format!("agent {} not found", pubkey))?; // Publish the edit to the relay. After-save, inside the lock, before // any .await. The retention upsert hashes the opt-IN projection, so an @@ -941,11 +946,20 @@ pub async fn update_managed_agent( build_managed_agent_summary(&app, record, &runtimes, &personas)? }; let rollback = name_changed.then(|| AgentUpdateRollback::new(previous_record, record)); - (summary, sync_params, rollback) + (summary, sync_params, rollback, backend_deploy) }; // lock dropped here try_regenerate_nest(&app); + let summary = super::agent_backend_update::deploy_updated_backend( + &app, + &state, + &pubkey, + provider_deploy, + summary, + ) + .await?; + // Phase 2: relay profile sync (async, outside lock). A rename is committed // only when this succeeds; otherwise restore the complete pre-edit record // so Desktop and the relay keep one authoritative name. diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index 0f99927c9e..874d7bf8b3 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::{commands::agent_backend_update, managed_agents::BackendKind}; #[test] fn openai_model_normalization_keeps_agent_text_models() { @@ -260,3 +261,47 @@ fn is_databricks_provider_matches_both_variants() { assert!(!is_databricks_provider(Some("anthropic"))); assert!(!is_databricks_provider(None)); } + +#[test] +fn absent_backend_update_preserves_current_backend() { + let current = BackendKind::Provider { + id: "test".to_string(), + config: serde_json::json!({"name": "existing"}), + }; + assert_eq!( + agent_backend_update::requested_backend_update(¤t, None, None).unwrap(), + None + ); +} + +#[test] +fn provider_backend_update_requests_deploy() { + let requested = BackendKind::Provider { + id: "test".to_string(), + config: serde_json::json!({"name": "changed"}), + }; + assert_eq!( + agent_backend_update::requested_backend_update( + &BackendKind::Local, + None, + Some(requested.clone()) + ) + .unwrap(), + Some(requested) + ); +} + +#[test] +fn deployed_provider_cannot_move_local_without_undeploy_protocol() { + let current = BackendKind::Provider { + id: "test".to_string(), + config: serde_json::json!({}), + }; + let error = agent_backend_update::requested_backend_update( + ¤t, + Some("remote-1"), + Some(BackendKind::Local), + ) + .unwrap_err(); + assert!(error.contains("does not support undeploy")); +} diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index c65900b5de..a742b0bef8 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -457,7 +457,7 @@ pub(super) async fn start_local_agent_with_preflight( /// /// Returns Ok(()) on success, Err(message) on failure. Either way the record is /// updated and saved before returning. -async fn deploy_to_provider( +pub(super) async fn deploy_to_provider( app: &AppHandle, state: &AppState, pubkey: &str, @@ -1320,7 +1320,7 @@ pub async fn delete_managed_agent( #[path = "agents_deploy.rs"] mod deploy; -use deploy::build_deploy_payload; +pub(super) use deploy::build_deploy_payload; #[cfg(test)] use deploy::deploy_payload_json; #[cfg(test)] diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index be132225ea..b00e44de85 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -57,7 +57,7 @@ pub(crate) fn resolve_deploy_model_provider<'a>( /// serialize `"private_key_nsec": ""` and launch the agent with no /// identity — the same hazard the local spawn path refuses via /// `spawn_key_refusal`. -pub(super) fn build_deploy_payload( +pub(crate) fn build_deploy_payload( app: &AppHandle, state: &AppState, record: &ManagedAgentRecord, diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 7e9d916be6..4324ed6230 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,4 +1,5 @@ mod agent_auth; +mod agent_backend_update; mod agent_config; mod agent_discovery; mod agent_logs; diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 0476be79a9..4155d27d92 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -10,7 +10,11 @@ use uuid::Uuid; use crate::{ app_state::AppState, - commands::{export_util::save_bytes_with_dialog, personas::resolve_snapshot_import_behavior}, + commands::{ + agents::{build_deploy_payload, deploy_to_provider}, + export_util::save_bytes_with_dialog, + personas::resolve_snapshot_import_behavior, + }, managed_agents::team_snapshot::{ build_team_snapshot, decode_team_snapshot_json, decode_team_snapshot_png, encode_team_snapshot_json, encode_team_snapshot_png, TeamSnapshot, @@ -18,7 +22,7 @@ use crate::{ managed_agents::{ agent_snapshot::{build_snapshot, AgentSnapshot, AgentSnapshotMemoryEntry, MemoryLevel}, load_managed_agents, load_personas, load_teams, load_teams_readonly, save_managed_agents, - save_personas, save_teams, AgentDefinition, ManagedAgentRecord, TeamRecord, + save_personas, save_teams, AgentDefinition, BackendKind, ManagedAgentRecord, TeamRecord, }, relay::{effective_agent_relay_url, relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -218,6 +222,9 @@ pub struct TeamSnapshotImportConfirm { pub file_bytes: Vec, /// Applied uniformly to every member in v1. pub keep_allowlist: bool, + /// Applied uniformly to every imported member. Absent preserves local. + #[serde(default)] + pub backend: Option, } /// Per-member outcome reported after a confirmed team snapshot import. @@ -250,6 +257,10 @@ pub struct EncodedTeamSnapshotPayload { pub file_name: String, } +fn import_backend_or_local(backend: Option) -> BackendKind { + backend.unwrap_or_default() +} + /// Per-member minted key material, assembled before entering the store lock. struct MintedMember { definition: AgentDefinition, @@ -502,6 +513,11 @@ pub async fn confirm_team_snapshot_import( ) -> Result { // ── Phase 1: validate (no I/O) ─────────────────────────────────────────── let snapshot = decode_team_snapshot_from_bytes(&input.file_bytes)?; + let import_backend = import_backend_or_local(input.backend); + if let BackendKind::Provider { config, id } = &import_backend { + crate::managed_agents::validate_provider_config(config)?; + crate::managed_agents::resolve_provider_binary(id)?; + } let now = now_iso(); // Resolve behavioral defaults for every member before any key generation. @@ -575,9 +591,15 @@ pub async fn confirm_team_snapshot_import( start_on_app_launch: false, auto_restart_on_config_change: true, runtime_pid: None, - backend: crate::managed_agents::BackendKind::Local, + backend: import_backend.clone(), backend_agent_id: None, - provider_binary_path: None, + provider_binary_path: if let BackendKind::Provider { ref id, .. } = import_backend { + crate::managed_agents::resolve_provider_binary(id) + .ok() + .map(|path| path.display().to_string()) + } else { + None + }, team_id: Some(imported_team.id.clone()), persona_team_dir: None, persona_name_in_team: None, @@ -750,7 +772,7 @@ pub async fn confirm_team_snapshot_import( imported_team }; - // ── Phase 4 & 5: profile sync + memory restore (async, outside lock) ──── + // ── Phase 4-6: profile sync, provider deploy, memory restore ────────── let relay_ws = relay_ws_url_with_override(&state); let mut member_results: Vec = Vec::with_capacity(minted.len()); @@ -769,7 +791,24 @@ pub async fn confirm_team_snapshot_import( .await .err(); - // Phase 5: memory restore (best-effort). + // Phase 5: provider deploy for remote imports (best-effort). + if let BackendKind::Provider { id, config } = &m.record.backend { + let agent_json = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let records = load_managed_agents(&app)?; + let record = records + .iter() + .find(|record| record.pubkey == m.pubkey) + .ok_or_else(|| format!("agent {} not found", m.pubkey))?; + build_deploy_payload(&app, &state, record)? + }; + let _ = deploy_to_provider(&app, &state, &m.pubkey, id, config, agent_json, None).await; + } + + // Phase 6: memory restore (best-effort). let memory_total = snap_member.memory.entries.len(); let mut memory_written = 0usize; let mut memory_errors: Vec = Vec::new(); diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index ca7dc61830..fdd576f055 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -724,3 +724,17 @@ fn full_rollback_at_teams_boundary_absent_agents_store() { assert!(!teams_path.exists()); assert_eq!(errors.len(), 1, "only the teams-write error"); } + +#[test] +fn explicit_import_backend_is_applied() { + let backend = BackendKind::Provider { + id: "test".to_string(), + config: serde_json::json!({"name": "imported"}), + }; + assert_eq!(import_backend_or_local(Some(backend.clone())), backend); +} + +#[test] +fn absent_import_backend_defaults_local() { + assert_eq!(import_backend_or_local(None), BackendKind::Local); +} diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index 58d60218a1..1b09435dba 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -196,6 +196,9 @@ pub struct CreateManagedAgentRequest { #[serde(rename_all = "camelCase")] pub struct UpdateManagedAgentRequest { pub pubkey: String, + /// Absent = don't touch. Present = switch the execution backend. + #[serde(default)] + pub backend: Option, /// Absent = don't touch. Present = rename the agent. #[serde(default)] pub name: Option, diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 5cd5c7a016..532e01f63e 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -1,9 +1,7 @@ import * as React from "react"; import { ChevronDown } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; - import { toast } from "sonner"; - import { useAcpRuntimesQuery, useAgentConfigSurface, @@ -83,12 +81,13 @@ import { AgentDefaultsDialog } from "./AgentDefaultsDialog"; import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; import { resolveModelFieldStatusMessage } from "./agentConfigControls"; import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge"; - +import { WhereToRunSection } from "./WhereToRunSection"; +import { canSubmitWhereToRun } from "./whereToRunIntent"; +import { useAgentBackendEdit } from "./useAgentBackendEdit"; const ADVANCED_FIELDS_MOTION_TRANSITION = { duration: 0.18, ease: [0.23, 1, 0.32, 1], } as const; - export function AgentInstanceEditDialog({ agent, initialFocus, @@ -112,7 +111,6 @@ export function AgentInstanceEditDialog({ const runtimesQuery = useAcpRuntimesQuery({ enabled: open }); const configSurfaceQuery = useAgentConfigSurface(open ? agent.pubkey : null); const runtimes = runtimesQuery.data ?? []; - const [name, setName] = React.useState(agent.name); const [aiDefaultsOpen, setAiDefaultsOpen] = React.useState(false); const aiDefaultsTriggerRef = React.useRef(null); @@ -139,6 +137,7 @@ export function AgentInstanceEditDialog({ const [envVars, setEnvVars] = React.useState(agent.envVars); const [autoRestartOnConfigChange, setAutoRestartOnConfigChange] = React.useState(agent.autoRestartOnConfigChange); + const backendEdit = useAgentBackendEdit(agent.backend); const personasQuery = usePersonasQuery(); const linkedPersona = React.useMemo( () => @@ -159,14 +158,11 @@ export function AgentInstanceEditDialog({ const [isAvatarUploadPending, setIsAvatarUploadPending] = React.useState(false); const shouldReduceMotion = useReducedMotion(); - // Runtime selector: defaults to "custom" until the dialog opens and the // catalog loads. The open-effect re-derives the correct id from the catalog. const [selectedRuntimeId, setSelectedRuntimeId] = React.useState("custom"); - // Tracks whether the user has made an in-dialog runtime selection. const runtimeTouched = React.useRef(false); - // Reset form state only when the dialog opens or when switching to a different agent. // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — including agent fields would re-fire on every 5s poll and wipe edits React.useEffect(() => { @@ -187,6 +183,7 @@ export function AgentInstanceEditDialog({ setIsCustomProviderEditing(false); setEnvVars(agent.envVars); setAutoRestartOnConfigChange(agent.autoRestartOnConfigChange); + backendEdit.reset(); setRespondTo(agent.respondTo); setRespondToAllowlist(agent.respondToAllowlist); setAvatarUrl(agent.avatarUrl ?? ""); @@ -213,7 +210,6 @@ export function AgentInstanceEditDialog({ setSelectedRuntimeId(matched.id); } }, [open, runtimes, agent.agentCommand]); - // Build the sorted runtime catalog for the dropdown. const sortedRuntimes = React.useMemo( () => sortPersonaRuntimes(runtimes), @@ -224,7 +220,6 @@ export function AgentInstanceEditDialog({ () => runtimes.find((r) => r.id === selectedRuntimeId), [runtimes, selectedRuntimeId], ); - const runtimeDropdownValue = selectedRuntimeId || NO_RUNTIME_DROPDOWN_VALUE; const runtimeDropdownOptions: PersonaDropdownOption[] = React.useMemo(() => { @@ -590,7 +585,8 @@ export function AgentInstanceEditDialog({ }) && providerValid && !updateMutation.isPending && - !isAvatarUploadPending; + !isAvatarUploadPending && + canSubmitWhereToRun(backendEdit.draft); async function handleSubmit() { try { @@ -603,7 +599,6 @@ export function AgentInstanceEditDialog({ // provider-backed inherit-transition carries the persona model (readiness // requires one) and a deliberate local model still wins. const normalizedModel = inheritedSubmission.model; - // Harness pin resolution — see resolveAgentCommandUpdate for the full // sentinel/pin/no-op contract, including the inherit→pin transition where // the prefilled command equals the original but must still be pinned. @@ -613,7 +608,6 @@ export function AgentInstanceEditDialog({ originalAgentCommand: agent.agentCommand, agentCommandOverride: agent.agentCommandOverride ?? null, }); - // Classify the effective post-submit runtime's provider capability as a // tri-state: "capable" persists the provider, "locked" clears it (only // when we KNOW it's provider-locked, e.g. Claude), "unknown" OMITS it so a @@ -626,7 +620,6 @@ export function AgentInstanceEditDialog({ prospectiveRuntimeId, runtimeSupportsLlmProviderSelection(prospectiveRuntimeId), ); - // Provider + env to persist — the shared inherited-submission snapshot // (same values the credential gate validates), so gate ↔ record ↔ spawn // all agree. See resolveInheritedRuntimeSubmission. @@ -695,6 +688,7 @@ export function AgentInstanceEditDialog({ respondToAllowlist.join(",") !== agent.respondToAllowlist.join(",") ? respondToAllowlist : undefined, + backend: backendEdit.update, }; const result = await updateMutation.mutateAsync(input); @@ -917,6 +911,12 @@ export function AgentInstanceEditDialog({ variant="persona" /> + + {/* Provider (runtime) */}