mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): add backend selector to edit and import
Co-authored-by: npub1m0vvn9qm5md0a080p27qzkm9uaw49e699ukwfq7fc0756xq0y5zqhzhdk2 <dbd8c9941ba6dafebcef0abc015b65e75d52e7452f2ce483c9c3fd4d180f2504@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1m0vvn9qm5md0a080p27qzkm9uaw49e699ukwfq7fc0756xq0y5zqhzhdk2 <dbd8c9941ba6dafebcef0abc015b65e75d52e7452f2ce483c9c3fd4d180f2504@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1m0vvn9qm5md0a080p27qzkm9uaw49e699ukwfq7fc0756xq0y5zqhzhdk2
parent
6e419b9f1c
commit
6e6c8aa307
@@ -0,0 +1,116 @@
|
||||
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<BackendKind>,
|
||||
) -> Result<Option<BackendKind>, 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<ManagedAgentRuntimeKey, ManagedAgentPairRuntime>,
|
||||
requested: Option<BackendKind>,
|
||||
) -> Result<Option<BackendKind>, 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<BackendKind>,
|
||||
fallback: ManagedAgentSummary,
|
||||
) -> Result<ManagedAgentSummary, String> {
|
||||
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(),
|
||||
&crate::managed_agents::load_global_agent_config(app).unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,3 @@
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use nostr::Keys;
|
||||
use serde::Deserialize;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use super::agent_model_process::run_agent_models_command;
|
||||
// The map-only lookup is reached solely from the base-URL helpers that exist for
|
||||
// their unit tests; discovery itself always goes through the process-env variant.
|
||||
@@ -13,7 +7,6 @@ use super::agent_models_env::{
|
||||
effective_discovery_provider, env_or_process_value, redaction_env_with_value, DiscoveryProvider,
|
||||
};
|
||||
use super::agent_update_rollback::{rollback_failed_agent_update, AgentUpdateRollback};
|
||||
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
managed_agents::{
|
||||
@@ -27,7 +20,10 @@ use crate::{
|
||||
relay::{relay_ws_url_with_override, sync_managed_agent_profile},
|
||||
util::now_iso,
|
||||
};
|
||||
|
||||
use nostr::Keys;
|
||||
use serde::Deserialize;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use tauri::{AppHandle, State};
|
||||
/// Query available models from an agent via `buzz-acp models --json`.
|
||||
///
|
||||
/// Spawns a short-lived subprocess (no relay connection needed). The subprocess
|
||||
@@ -56,35 +52,28 @@ pub async fn get_agent_models(
|
||||
for pubkey in &exited_pubkeys {
|
||||
state.clear_agent_session_caches(pubkey);
|
||||
}
|
||||
|
||||
let record = records
|
||||
.iter()
|
||||
.find(|r| r.pubkey == pubkey)
|
||||
.ok_or_else(|| format!("agent {pubkey} not found"))?;
|
||||
|
||||
let resolved = resolve_command(&record.acp_command)
|
||||
.ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?;
|
||||
|
||||
// Resolve the effective harness from the linked persona (mirrors spawn),
|
||||
// so model discovery runs against the persona's current harness, not the
|
||||
// frozen record snapshot. An explicit per-agent override wins.
|
||||
let personas = load_personas(&app).unwrap_or_default();
|
||||
let global = load_global_agent_config(&app).unwrap_or_default();
|
||||
|
||||
// Single pure helper — descriptor + authoritative model/provider
|
||||
// resolver, packaged so the linked-agent regression test binds the
|
||||
// exact values this command consumes. Returns Err on dangling harness
|
||||
// id, propagating it to the caller.
|
||||
let discovery = agent_model_discovery_config(record, &personas, &global)
|
||||
.map_err(|e| model_discovery_error(&pubkey, &e))?;
|
||||
|
||||
let resolved_agent = resolve_command(&discovery.command)
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|| discovery.command.clone());
|
||||
|
||||
(resolved, resolved_agent, discovery)
|
||||
}; // store lock released — subprocess runs without holding the lock
|
||||
|
||||
let AgentModelDiscoveryConfig {
|
||||
args: agent_args,
|
||||
model: persisted_model,
|
||||
@@ -93,7 +82,6 @@ pub async fn get_agent_models(
|
||||
env: merged_env,
|
||||
command: _,
|
||||
} = discovery;
|
||||
|
||||
let merged_env = discovery_env_with_baked_floor(merged_env);
|
||||
// Resolve against the baked/process env when the record saved no provider,
|
||||
// so a build-provided provider still gets live discovery.
|
||||
@@ -109,7 +97,6 @@ pub async fn get_agent_models(
|
||||
{
|
||||
return Ok(models);
|
||||
}
|
||||
|
||||
if let Some(models) = discover_openai_compatible_models(
|
||||
&state.http_client,
|
||||
&effective_provider,
|
||||
@@ -120,7 +107,6 @@ pub async fn get_agent_models(
|
||||
{
|
||||
return Ok(models);
|
||||
}
|
||||
|
||||
if let Some(models) = discover_anthropic_models(
|
||||
&state.http_client,
|
||||
&effective_provider,
|
||||
@@ -131,7 +117,6 @@ pub async fn get_agent_models(
|
||||
{
|
||||
return Ok(models);
|
||||
}
|
||||
|
||||
if let Some(models) = discover_databricks_models(
|
||||
&state.http_client,
|
||||
&effective_provider,
|
||||
@@ -142,7 +127,6 @@ pub async fn get_agent_models(
|
||||
{
|
||||
return Ok(models);
|
||||
}
|
||||
|
||||
run_agent_models_command(
|
||||
resolved_acp,
|
||||
agent_command,
|
||||
@@ -813,8 +797,9 @@ pub async fn update_managed_agent(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<UpdateManagedAgentResponse, String> {
|
||||
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()
|
||||
@@ -830,9 +815,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();
|
||||
@@ -929,14 +913,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
|
||||
@@ -979,11 +970,20 @@ pub async fn update_managed_agent(
|
||||
)?
|
||||
};
|
||||
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.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::{commands::agent_backend_update, managed_agents::BackendKind};
|
||||
|
||||
#[test]
|
||||
fn openai_model_normalization_keeps_agent_text_models() {
|
||||
@@ -881,3 +882,47 @@ fn draft_agent_model_discovery_env_layers_all_three_tiers_in_order() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
|
||||
@@ -456,7 +456,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,
|
||||
@@ -1358,7 +1358,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)]
|
||||
|
||||
@@ -51,7 +51,7 @@ pub(crate) fn resolve_deploy_model_provider(
|
||||
/// 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,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod agent_auth;
|
||||
mod agent_backend_update;
|
||||
mod agent_config;
|
||||
mod agent_discovery;
|
||||
mod agent_logs;
|
||||
|
||||
@@ -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,
|
||||
@@ -220,6 +224,9 @@ pub struct TeamSnapshotImportConfirm {
|
||||
pub file_bytes: Vec<u8>,
|
||||
/// 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<BackendKind>,
|
||||
}
|
||||
|
||||
/// Per-member outcome reported after a confirmed team snapshot import.
|
||||
@@ -252,6 +259,10 @@ pub struct EncodedTeamSnapshotPayload {
|
||||
pub file_name: String,
|
||||
}
|
||||
|
||||
fn import_backend_or_local(backend: Option<BackendKind>) -> BackendKind {
|
||||
backend.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Per-member minted key material, assembled before entering the store lock.
|
||||
struct MintedMember {
|
||||
definition: AgentDefinition,
|
||||
@@ -504,6 +515,11 @@ pub async fn confirm_team_snapshot_import(
|
||||
) -> Result<TeamSnapshotImportResult, String> {
|
||||
// ── 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.
|
||||
@@ -577,9 +593,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,
|
||||
@@ -754,7 +776,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<TeamSnapshotImportMemberResult> = Vec::with_capacity(minted.len());
|
||||
|
||||
@@ -773,7 +795,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<String> = Vec::new();
|
||||
|
||||
@@ -761,3 +761,17 @@ mod egress_guard_boundary {
|
||||
assert!(err.contains("key-backup material"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
@@ -200,6 +200,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<BackendKind>,
|
||||
/// Absent = don't touch. Present = rename the agent.
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
|
||||
@@ -2,7 +2,6 @@ import * as React from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
useAcpRuntimesQuery,
|
||||
useAgentConfigSurface,
|
||||
@@ -89,12 +88,13 @@ import {
|
||||
runtimeDropdownAction,
|
||||
usePendingHarnessSelection,
|
||||
} from "./addCustomHarness";
|
||||
|
||||
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,
|
||||
@@ -117,7 +117,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<HTMLButtonElement>(null);
|
||||
@@ -144,6 +143,7 @@ export function AgentInstanceEditDialog({
|
||||
const [envVars, setEnvVars] = React.useState<EnvVarsValue>(agent.envVars);
|
||||
const [autoRestartOnConfigChange, setAutoRestartOnConfigChange] =
|
||||
React.useState(agent.autoRestartOnConfigChange);
|
||||
const backendEdit = useAgentBackendEdit(agent.backend);
|
||||
const personasQuery = usePersonasQuery();
|
||||
const linkedPersona = React.useMemo(
|
||||
() =>
|
||||
@@ -165,14 +165,11 @@ export function AgentInstanceEditDialog({
|
||||
React.useState(false);
|
||||
const [isAddHarnessOpen, setIsAddHarnessOpen] = 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(() => {
|
||||
@@ -193,6 +190,7 @@ export function AgentInstanceEditDialog({
|
||||
setIsCustomProviderEditing(false);
|
||||
setEnvVars(agent.envVars);
|
||||
setAutoRestartOnConfigChange(agent.autoRestartOnConfigChange);
|
||||
backendEdit.reset();
|
||||
setRespondTo(agent.respondTo);
|
||||
setRespondToAllowlist(agent.respondToAllowlist);
|
||||
setAvatarUrl(agent.avatarUrl ?? "");
|
||||
@@ -207,7 +205,6 @@ export function AgentInstanceEditDialog({
|
||||
updateMutation.reset();
|
||||
}
|
||||
}, [open, agent.pubkey]);
|
||||
|
||||
// Re-derive the runtime id when the catalog loads.
|
||||
React.useEffect(() => {
|
||||
if (!open || runtimeTouched.current || runtimes.length === 0) {
|
||||
@@ -220,7 +217,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),
|
||||
@@ -231,7 +227,6 @@ export function AgentInstanceEditDialog({
|
||||
() => runtimes.find((r) => r.id === selectedRuntimeId),
|
||||
[runtimes, selectedRuntimeId],
|
||||
);
|
||||
|
||||
const runtimeDropdownValue = selectedRuntimeId || NO_RUNTIME_DROPDOWN_VALUE;
|
||||
|
||||
const runtimeDropdownOptions: PersonaDropdownOption[] = React.useMemo(() => {
|
||||
@@ -612,7 +607,8 @@ export function AgentInstanceEditDialog({
|
||||
}) &&
|
||||
providerValid &&
|
||||
!updateMutation.isPending &&
|
||||
!isAvatarUploadPending;
|
||||
!isAvatarUploadPending &&
|
||||
canSubmitWhereToRun(backendEdit.draft);
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
@@ -625,7 +621,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.
|
||||
@@ -635,7 +630,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
|
||||
@@ -648,7 +642,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.
|
||||
@@ -723,6 +716,7 @@ export function AgentInstanceEditDialog({
|
||||
respondToAllowlist.join(",") !== agent.respondToAllowlist.join(",")
|
||||
? respondToAllowlist
|
||||
: undefined,
|
||||
backend: backendEdit.update,
|
||||
};
|
||||
|
||||
const result = await updateMutation.mutateAsync(input);
|
||||
@@ -946,6 +940,12 @@ export function AgentInstanceEditDialog({
|
||||
variant="persona"
|
||||
/>
|
||||
|
||||
<WhereToRunSection
|
||||
draft={backendEdit.draft}
|
||||
isPending={updateMutation.isPending}
|
||||
onDraftChange={backendEdit.setDraft}
|
||||
/>
|
||||
|
||||
{/* Provider (runtime) */}
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
|
||||
@@ -621,8 +621,11 @@ export function AgentsView() {
|
||||
isConfirming={teamActions.isTeamSnapshotImportConfirming}
|
||||
result={teamActions.teamSnapshotImportResult}
|
||||
confirmError={teamActions.teamSnapshotImportConfirmError}
|
||||
onConfirm={(keepAllowlist) => {
|
||||
void teamActions.handleConfirmTeamSnapshotImport(keepAllowlist);
|
||||
onConfirm={(keepAllowlist, backend) => {
|
||||
void teamActions.handleConfirmTeamSnapshotImport(
|
||||
keepAllowlist,
|
||||
backend,
|
||||
);
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
TeamSnapshotImportPreview,
|
||||
TeamSnapshotImportResult,
|
||||
} from "@/shared/api/tauriTeams";
|
||||
import type { ManagedAgentBackend } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -18,6 +19,14 @@ import {
|
||||
deriveImportPhase,
|
||||
getProfileSyncFailures,
|
||||
} from "./teamSnapshotImport.lib";
|
||||
import { WhereToRunSection } from "./WhereToRunSection";
|
||||
import {
|
||||
backendIntentToManagedAgentBackend,
|
||||
canSubmitWhereToRun,
|
||||
emptyWhereToRunDraft,
|
||||
resolveBackendIntent,
|
||||
type WhereToRunDraft,
|
||||
} from "./whereToRunIntent";
|
||||
|
||||
type TeamSnapshotImportDialogProps = {
|
||||
open: boolean;
|
||||
@@ -29,8 +38,8 @@ type TeamSnapshotImportDialogProps = {
|
||||
result: TeamSnapshotImportResult | null;
|
||||
/** Error from the confirm mutation, if any. */
|
||||
confirmError: string | null;
|
||||
/** Called with keepAllowlist when user clicks Import. */
|
||||
onConfirm: (keepAllowlist: boolean) => void;
|
||||
/** Called with keepAllowlist and backend when user clicks Import. */
|
||||
onConfirm: (keepAllowlist: boolean, backend: ManagedAgentBackend) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
@@ -46,11 +55,13 @@ export function TeamSnapshotImportDialog({
|
||||
onOpenChange,
|
||||
}: TeamSnapshotImportDialogProps) {
|
||||
const [keepAllowlist, setKeepAllowlist] = React.useState(false);
|
||||
const [runDraft, setRunDraft] = React.useState(emptyWhereToRunDraft);
|
||||
|
||||
// Reset choice whenever the dialog opens with new data.
|
||||
// Reset choices whenever the dialog opens with new data.
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setKeepAllowlist(false);
|
||||
setRunDraft(emptyWhereToRunDraft);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
@@ -74,8 +85,15 @@ export function TeamSnapshotImportDialog({
|
||||
<>
|
||||
<Button
|
||||
data-testid="team-snapshot-import-confirm"
|
||||
disabled={isConfirming}
|
||||
onClick={() => onConfirm(keepAllowlist)}
|
||||
disabled={isConfirming || !canSubmitWhereToRun(runDraft)}
|
||||
onClick={() =>
|
||||
onConfirm(
|
||||
keepAllowlist,
|
||||
backendIntentToManagedAgentBackend(
|
||||
resolveBackendIntent(runDraft),
|
||||
),
|
||||
)
|
||||
}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="default"
|
||||
@@ -113,6 +131,9 @@ export function TeamSnapshotImportDialog({
|
||||
preview={preview}
|
||||
keepAllowlist={keepAllowlist}
|
||||
onKeepAllowlistChange={setKeepAllowlist}
|
||||
runDraft={runDraft}
|
||||
onRunDraftChange={setRunDraft}
|
||||
isConfirming={isConfirming}
|
||||
/>
|
||||
{confirmError ? (
|
||||
<div
|
||||
@@ -142,10 +163,16 @@ function PreviewBody({
|
||||
preview,
|
||||
keepAllowlist,
|
||||
onKeepAllowlistChange,
|
||||
runDraft,
|
||||
onRunDraftChange,
|
||||
isConfirming,
|
||||
}: {
|
||||
preview: TeamSnapshotImportPreview;
|
||||
keepAllowlist: boolean;
|
||||
onKeepAllowlistChange: (v: boolean) => void;
|
||||
runDraft: WhereToRunDraft;
|
||||
onRunDraftChange: (draft: WhereToRunDraft) => void;
|
||||
isConfirming: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4 py-1">
|
||||
@@ -189,6 +216,12 @@ function PreviewBody({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<WhereToRunSection
|
||||
draft={runDraft}
|
||||
isPending={isConfirming}
|
||||
onDraftChange={onRunDraftChange}
|
||||
/>
|
||||
|
||||
{/* Allowlist section */}
|
||||
{preview.hasSourceAllowlist ? (
|
||||
<div
|
||||
|
||||
@@ -50,7 +50,8 @@ export function WhereToRunSection({
|
||||
onDraftChange({
|
||||
...draft,
|
||||
probedProvider: result,
|
||||
providerConfig: defaults,
|
||||
providerConfig: { ...defaults, ...draft.providerConfig },
|
||||
allowUnprobedProvider: false,
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { ManagedAgentBackend } from "@/shared/api/types";
|
||||
|
||||
import {
|
||||
backendIntentToManagedAgentBackend,
|
||||
resolveBackendIntent,
|
||||
type WhereToRunDraft,
|
||||
} from "./whereToRunIntent";
|
||||
|
||||
function draftForBackend(backend: ManagedAgentBackend): WhereToRunDraft {
|
||||
if (backend.type === "local") {
|
||||
return {
|
||||
runOn: "local" as const,
|
||||
providerConfig: {},
|
||||
probedProvider: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
runOn: backend.id,
|
||||
providerConfig: Object.fromEntries(
|
||||
Object.entries(backend.config).map(([key, value]) => [
|
||||
key,
|
||||
String(value),
|
||||
]),
|
||||
),
|
||||
probedProvider: null,
|
||||
allowUnprobedProvider: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAgentBackendEdit(backend: ManagedAgentBackend) {
|
||||
const [draft, setDraft] = React.useState<WhereToRunDraft>(() =>
|
||||
draftForBackend(backend),
|
||||
);
|
||||
const reset = React.useCallback(
|
||||
() => setDraft(draftForBackend(backend)),
|
||||
[backend],
|
||||
);
|
||||
const selected = backendIntentToManagedAgentBackend(
|
||||
resolveBackendIntent(draft),
|
||||
);
|
||||
return {
|
||||
draft,
|
||||
setDraft,
|
||||
reset,
|
||||
update:
|
||||
JSON.stringify(selected) === JSON.stringify(backend)
|
||||
? undefined
|
||||
: selected,
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
AgentTeam,
|
||||
Channel,
|
||||
CreateTeamInput,
|
||||
ManagedAgentBackend,
|
||||
UpdateTeamInput,
|
||||
} from "@/shared/api/types";
|
||||
import { deriveImportToast } from "./teamSnapshotImport.lib";
|
||||
@@ -98,8 +99,7 @@ export function useTeamActions(
|
||||
}) => previewTeamSnapshotImport(fileBytes, fileName),
|
||||
});
|
||||
const confirmTeamSnapshotImportMutation = useMutation({
|
||||
mutationFn: (input: { fileBytes: number[]; keepAllowlist: boolean }) =>
|
||||
confirmTeamSnapshotImport(input),
|
||||
mutationFn: confirmTeamSnapshotImport,
|
||||
});
|
||||
|
||||
const teams = teamsQuery.data ?? [];
|
||||
@@ -281,7 +281,10 @@ export function useTeamActions(
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmTeamSnapshotImport(keepAllowlist: boolean) {
|
||||
async function handleConfirmTeamSnapshotImport(
|
||||
keepAllowlist: boolean,
|
||||
backend: ManagedAgentBackend,
|
||||
) {
|
||||
if (!teamSnapshotImportState) {
|
||||
return;
|
||||
}
|
||||
@@ -290,6 +293,7 @@ export function useTeamActions(
|
||||
const result = await confirmTeamSnapshotImportMutation.mutateAsync({
|
||||
fileBytes: teamSnapshotImportState.fileBytes,
|
||||
keepAllowlist,
|
||||
backend,
|
||||
});
|
||||
setTeamSnapshotImportResult(result);
|
||||
void queryClient.invalidateQueries({ queryKey: personasQueryKey });
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { BackendIntent } from "../lib/instanceInputForDefinition";
|
||||
import type { BackendProviderProbeResult } from "@/shared/api/types";
|
||||
import type {
|
||||
BackendProviderProbeResult,
|
||||
ManagedAgentBackend,
|
||||
} from "@/shared/api/types";
|
||||
import { coerceConfigValues } from "./ProviderConfigFields";
|
||||
|
||||
/** Draft state of the optional remote-backend selector. */
|
||||
@@ -7,6 +10,8 @@ export type WhereToRunDraft = {
|
||||
runOn: "local" | string;
|
||||
providerConfig: Record<string, string>;
|
||||
probedProvider: BackendProviderProbeResult | null;
|
||||
/** Existing persisted provider selections remain valid while their probe loads. */
|
||||
allowUnprobedProvider?: boolean;
|
||||
};
|
||||
|
||||
export const emptyWhereToRunDraft: WhereToRunDraft = {
|
||||
@@ -17,7 +22,7 @@ export const emptyWhereToRunDraft: WhereToRunDraft = {
|
||||
|
||||
export function providerConfigComplete(draft: WhereToRunDraft): boolean {
|
||||
if (draft.runOn === "local") return true;
|
||||
if (!draft.probedProvider) return false;
|
||||
if (!draft.probedProvider) return draft.allowUnprobedProvider === true;
|
||||
const schema = draft.probedProvider.config_schema as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
@@ -44,3 +49,15 @@ export function resolveBackendIntent(
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function backendIntentToManagedAgentBackend(
|
||||
backendIntent: BackendIntent | null,
|
||||
): ManagedAgentBackend {
|
||||
return backendIntent
|
||||
? {
|
||||
type: "provider" as const,
|
||||
id: backendIntent.id,
|
||||
config: backendIntent.config,
|
||||
}
|
||||
: { type: "local" as const };
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { invokeTauri } from "@/shared/api/tauri";
|
||||
import type {
|
||||
AgentTeam,
|
||||
CreateTeamInput,
|
||||
ManagedAgentBackend,
|
||||
UpdateTeamInput,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
@@ -101,6 +102,7 @@ export type TeamSnapshotImportPreview = {
|
||||
export type TeamSnapshotImportConfirm = {
|
||||
fileBytes: number[];
|
||||
keepAllowlist: boolean;
|
||||
backend?: ManagedAgentBackend;
|
||||
};
|
||||
|
||||
export type TeamSnapshotImportMemberResult = {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export type ChannelType = "stream" | "forum" | "dm";
|
||||
export type ChannelVisibility = "open" | "private";
|
||||
export type ChannelRole = "owner" | "admin" | "member" | "guest" | "bot";
|
||||
|
||||
export type Channel = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -20,7 +19,6 @@ export type Channel = {
|
||||
ttlSeconds: number | null;
|
||||
ttlDeadline: string | null;
|
||||
};
|
||||
|
||||
export type ChannelDetail = Channel & {
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
@@ -711,6 +709,8 @@ export type RuntimeConfigSurface = {
|
||||
|
||||
export type UpdateManagedAgentInput = {
|
||||
pubkey: string;
|
||||
/** Absent = don't touch. Present = switch the execution backend. */
|
||||
backend?: ManagedAgentBackend;
|
||||
name?: string;
|
||||
model?: string | null;
|
||||
provider?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user