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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,15 @@ const overrides = new Map([
["src-tauri/src/commands/agents.rs", 860], // remote agent lifecycle routing (local + provider branches) + scope enforcement + mcp_toolsets field; rustfmt adds line breaks around long tuple/closure blocks
["src-tauri/src/managed_agents/runtime.rs", 650], // KNOWN_AGENT_BINARIES const + process_belongs_to_us FFI (macOS proc_name + Linux /proc/comm) + terminate_process + start/stop/sync lifecycle
["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests
["src/features/agents/hooks.ts", 520], // agent query/mutation surface now includes built-in persona library activation
["src/features/agents/hooks.ts", 540], // agent query/mutation surface now includes built-in persona library activation + useUpdateManagedAgentMutation
["src/features/agents/ui/AgentsView.tsx", 880], // remote agent lifecycle controls + persona/team management + persona import-update dialog wiring + built-in catalog/library state orchestration
["src/features/agents/ui/ManagedAgentRow.tsx", 530], // EditAgentDialog integration + provider/local branching
["src/features/agents/ui/TeamDialog.tsx", 530], // team create/edit dialog with persona multi-select, import button, window drag detection, removal confirmation
["src/features/agents/ui/TeamImportUpdateDialog.tsx", 660], // team import diff preview with member matching/updating/adding/removing sections, LCS line counts, removal confirmation
["src/features/agents/ui/useTeamActions.ts", 510], // team CRUD + export + import + import-update orchestration with query invalidation
["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes
["src/features/channels/ui/AddChannelBotDialog.tsx", 640], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display
["src/shared/api/types.ts", 535], // persona provider/model fields + forum types + workflow type re-exports + ephemeral channel TTL fields + mcpToolsets
["src/shared/api/types.ts", 545], // persona provider/model fields + forum types + workflow type re-exports + ephemeral channel TTL fields + mcpToolsets + UpdateManagedAgentInput edit fields
]);

async function walkFiles(directory) {
Expand Down
164 changes: 118 additions & 46 deletions desktop/src-tauri/src/commands/agent_models.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
use std::collections::HashSet;

use nostr::Keys;
use tauri::{AppHandle, State};

use crate::{
app_state::AppState,
managed_agents::{
build_managed_agent_summary, default_agent_workdir, find_managed_agent_mut,
load_managed_agents, missing_command_message, normalize_agent_args, resolve_command,
save_managed_agents, sync_managed_agent_processes, AgentModelInfo, AgentModelsResponse,
ManagedAgentSummary, UpdateManagedAgentRequest,
load_managed_agents, managed_agent_avatar_url, missing_command_message,
normalize_agent_args, resolve_command, save_managed_agents, sync_managed_agent_processes,
AgentModelInfo, AgentModelsResponse, ManagedAgentSummary, UpdateManagedAgentRequest,
UpdateManagedAgentResponse,
},
relay::sync_managed_agent_profile,
util::now_iso,
};

Expand Down Expand Up @@ -96,56 +99,125 @@ pub async fn get_agent_models(

/// Update mutable fields on an existing managed agent record.
///
/// Does NOT auto-restart the agent. The frontend should prompt the user
/// to restart for model changes to take effect.
/// Does NOT auto-restart the agent. Runtime config changes (system prompt,
/// parallelism, commands, toolsets) take effect on the next agent spawn.
/// Name changes are synced to the relay immediately via a kind:0 re-publish.
#[tauri::command]
pub fn update_managed_agent(
pub async fn update_managed_agent(
input: UpdateManagedAgentRequest,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<ManagedAgentSummary, String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let mut records = load_managed_agents(&app)?;
let mut runtimes = state
.managed_agent_processes
.lock()
.map_err(|e| e.to_string())?;
sync_managed_agent_processes(&mut records, &mut runtimes);

let record = find_managed_agent_mut(&mut records, &input.pubkey)?;

if let Some(name_update) = input.name {
let trimmed = name_update.trim().to_string();
if !trimmed.is_empty() {
record.name = trimmed;
) -> Result<UpdateManagedAgentResponse, String> {
// Phase 1: local save (synchronous, under lock)
let (summary, sync_params) = {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let mut records = load_managed_agents(&app)?;
let mut runtimes = state
.managed_agent_processes
.lock()
.map_err(|e| e.to_string())?;
sync_managed_agent_processes(&mut records, &mut runtimes);

let record = find_managed_agent_mut(&mut records, &input.pubkey)?;

let mut name_changed = false;
if let Some(name_update) = input.name {
let trimmed = name_update.trim().to_string();
if !trimmed.is_empty() && trimmed != record.name {
record.name = trimmed;
name_changed = true;
}
}
}
// Tri-state: None = don't touch, Some(None) = clear, Some(Some(v)) = set
if let Some(model_update) = input.model {
record.model = model_update;
}
if let Some(prompt_update) = input.system_prompt {
record.system_prompt = prompt_update;
}
if let Some(toolsets_update) = input.mcp_toolsets {
record.mcp_toolsets = toolsets_update
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string);
}
record.updated_at = now_iso();
if let Some(model_update) = input.model {
record.model = model_update;
}
if let Some(prompt_update) = input.system_prompt {
record.system_prompt = prompt_update;
}
if let Some(toolsets_update) = input.mcp_toolsets {
record.mcp_toolsets = toolsets_update
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string);
}
if let Some(parallelism) = input.parallelism {
record.parallelism = parallelism;
}
if let Some(turn_timeout_seconds) = input.turn_timeout_seconds {
record.turn_timeout_seconds = turn_timeout_seconds;
}
if let Some(relay_url) = input.relay_url {
record.relay_url = relay_url;
}
if let Some(acp_command) = input.acp_command {
record.acp_command = acp_command;
}
if let Some(agent_command) = input.agent_command {
record.agent_command = agent_command;
}
if let Some(agent_args) = input.agent_args {
record.agent_args = agent_args;
}
if let Some(mcp_command) = input.mcp_command {
record.mcp_command = mcp_command;
}
record.updated_at = now_iso();

save_managed_agents(&app, &records)?;

save_managed_agents(&app, &records)?;
let record = records
.iter()
.find(|r| r.pubkey == input.pubkey)
.ok_or_else(|| format!("agent {} not found", input.pubkey))?;

let sync_params = if name_changed {
let agent_keys = Keys::parse(&record.private_key_nsec)
.map_err(|e| format!("failed to parse agent keys: {e}"))?;
let relay_url = record.relay_url.clone();
let api_token = record.api_token.clone();
let display_name = record.name.clone();
let avatar_url = managed_agent_avatar_url(&record.agent_command);
Some((agent_keys, relay_url, api_token, display_name, avatar_url))
} else {
None
};

let record = records
.iter()
.find(|r| r.pubkey == input.pubkey)
.ok_or_else(|| format!("agent {} not found", input.pubkey))?;
build_managed_agent_summary(&app, record, &runtimes)
let summary = build_managed_agent_summary(&app, record, &runtimes)?;
(summary, sync_params)
}; // lock dropped here

// Phase 2: relay profile sync (async, best-effort, outside lock)
let profile_sync_error =
if let Some((agent_keys, relay_url, api_token, display_name, avatar_url)) = sync_params {
match sync_managed_agent_profile(
&state,
&relay_url,
&agent_keys,
api_token.as_deref(),
&[],
&display_name,
avatar_url.as_deref(),
)
.await
{
Ok(()) => None,
Err(e) => {
eprintln!("sprout-desktop: relay profile sync failed after rename: {e}");
Some(e)
}
}
} else {
None
};

Ok(UpdateManagedAgentResponse {
agent: summary,
profile_sync_error,
})
}

// ── Model normalization ───────────────────────────────────────────────────────
Expand Down
21 changes: 21 additions & 0 deletions desktop/src-tauri/src/managed_agents/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,27 @@ pub struct UpdateManagedAgentRequest {
pub system_prompt: Option<Option<String>>,
#[serde(default)]
pub mcp_toolsets: Option<Option<String>>,
#[serde(default)]
pub parallelism: Option<u32>,
#[serde(default)]
pub turn_timeout_seconds: Option<u64>,
#[serde(default)]
pub relay_url: Option<String>,
#[serde(default)]
pub acp_command: Option<String>,
#[serde(default)]
pub agent_command: Option<String>,
#[serde(default)]
pub agent_args: Option<Vec<String>>,
#[serde(default)]
pub mcp_command: Option<String>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateManagedAgentResponse {
pub agent: ManagedAgentSummary,
pub profile_sync_error: Option<String>,
}

/// Response from `get_agent_models` — normalized model info for the frontend.
Expand Down
25 changes: 25 additions & 0 deletions desktop/src/features/agents/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
mintManagedAgentToken,
startManagedAgent,
stopManagedAgent,
updateManagedAgent,
} from "@/shared/api/tauri";
import {
createPersona,
Expand All @@ -43,6 +44,7 @@ import type {
CreateTeamInput,
ManagedAgent,
MintManagedAgentTokenInput,
UpdateManagedAgentInput,
UpdatePersonaInput,
UpdateTeamInput,
} from "@/shared/api/types";
Expand Down Expand Up @@ -195,6 +197,29 @@ export function useCreateManagedAgentMutation() {
});
}

export function useUpdateManagedAgentMutation() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (input: UpdateManagedAgentInput) => updateManagedAgent(input),
onSuccess: (result) => {
queryClient.setQueryData<ManagedAgent[]>(
managedAgentsQueryKey,
(current) => {
if (!current) return current;
return current.map((agent) =>
agent.pubkey === result.agent.pubkey ? result.agent : agent,
);
},
);
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey });
await queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey });
},
});
}

export function useCreatePersonaMutation() {
const queryClient = useQueryClient();

Expand Down
Loading
Loading