Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
8 changes: 8 additions & 0 deletions desktop/src-tauri/src/commands/agent_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,10 @@ mod tests {
is_active: true,
source_team: None,
source_team_persona_slug: None,
definition_respond_to: None,
definition_respond_to_allowlist: Vec::new(),
definition_mcp_toolsets: None,
definition_parallelism: None,
relay_mesh: None,
agent_command_override: None,
persona_source_version: None,
Expand All @@ -548,6 +552,10 @@ mod tests {
source_team: None,
source_team_persona_slug: None,
env_vars: BTreeMap::new(),
respond_to: None,
respond_to_allowlist: Vec::new(),
mcp_toolsets: None,
parallelism: None,
created_at: "".to_string(),
updated_at: "".to_string(),
}
Expand Down
154 changes: 40 additions & 114 deletions desktop/src-tauri/src/commands/agents.rs

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 IMPORTANT — Provider deploy payload drops mcp_toolsets (Thufir-sourced, Paul source-verified)

build_deploy_payload (lines 367–399) serializes parallelism, respond_to, and respond_to_allowlist into the provider agent_json, but omits record.mcp_toolsets. The local spawn path correctly sets BUZZ_TOOLSETS from the record at runtime.rs:1768–1772. All three provider paths (create at line 961, start at line 1070, redeploy) funnel through build_deploy_payload, so provider-backed agents silently lose inherited or explicit toolset settings.

Fix: Add "mcp_toolsets": &record.mcp_toolsets to the serde_json::json! block in build_deploy_payload, and add a regression test for provider-create payload completeness.


should-fix — Parallelism input validation at wrong layer + misleading doc (Paul-raised; Duncan and Thufir independently corrected the framing)

resolve_mint_behavioral_defaults at types.rs:932 passes explicit input parallelism through without range validation, while the definition branch validates loudly. The doc comment on MintBehavioralDefaults.parallelism ("Validated (1..=32) when present") is false for the input branch.

However, call-site validation at agents.rs:525–529 guards the full create path before this function is reached — so the behavior is not broken end-to-end. This is a wrong-layer defense + inaccurate doc comment, not a live behavior defect.

Fix: Either add range validation inside the function's input branch, or update the doc comment to state the caller is responsible for validating input parallelism.

Original file line number Diff line number Diff line change
Expand Up @@ -301,104 +301,6 @@ async fn start_local_agent_with_preflight(
build_managed_agent_summary(app, record, &runtimes, &personas)
}

/// Build the standard agent JSON payload for provider deploy calls.
///
/// Like local spawn, provider deploy re-reads live persona env vars and
/// structured model/provider so remote agents receive current credentials
/// and the same authoritative values that local spawn derives from
/// `runtime_metadata_env_vars`. The only field still pinned is
/// `agent_command`/`agent_args` — those were captured at create time.
/// The only read-time resolution is `relay_url`: a blank pin resolves to
/// the active workspace relay here, matching the create-path contract.
///
/// Fails closed when the private key is unavailable (keyring outage leaves
/// it empty after hydration): without this guard a provider deploy would
/// serialize `"private_key_nsec": ""` and launch the agent with no
/// identity — the same hazard the local spawn path refuses via
/// `spawn_key_refusal`.
fn build_deploy_payload(
app: &AppHandle,
state: &AppState,
record: &ManagedAgentRecord,
) -> Result<serde_json::Value, String> {
// Fails closed when the private key is unavailable — same guard as local
// spawn. Without this, a keyring outage would serialize `"private_key_nsec": ""`
// and launch the agent with no identity.
if let Some(err) = crate::managed_agents::spawn_key_refusal(record) {
return Err(err);
}

// Merge persona env_vars + agent env_vars for provider deploy — the same
// live-persona-under-overrides semantics as local spawn. Without this,
// provider-backed agents wouldn't receive credentials saved on the persona
// or the agent itself.
let persona_env =
crate::managed_agents::resolve_persona_env(app, record.persona_id.as_deref())?;
let merged_env = crate::managed_agents::merged_user_env(&persona_env, &record.env_vars);

// Resolve the persona's structured provider/model so the remote provider
// receives the same authoritative values that local spawn derives from
// `runtime_metadata_env_vars`. Without this, remote deploy would rely on
// stale derived env copies in `env_vars` (or have no provider at all for
// imported personas whose derived keys were filtered at import time).
//
// Precedence: persona field wins when non-blank; otherwise falls back to the
// record's own field (same blank-normalization as the snapshot path). This
// matches `persona_snapshot_with_agent_config_fallback` exactly — a blank
// persona field must not wipe a record value that the user configured.
let (effective_model, effective_provider) = if let Some(pid) = record.persona_id.as_deref() {
let personas = load_personas(app).map_err(|e| {
format!(
"failed to load personas while building deploy payload for persona `{pid}`: {e}"
)
})?;
let persona = personas
.into_iter()
.find(|p| p.id == pid)
.ok_or_else(|| format!("persona `{pid}` not found while building deploy payload"))?;
let fallback = crate::managed_agents::persona_events::persona_field_with_record_fallback;
let model = fallback(persona.model.as_deref(), record.model.as_deref()); // fallback: record.model
let provider = fallback(persona.provider.as_deref(), record.provider.as_deref()); // fallback: record.provider
(model, provider)
} else {
(record.model.clone(), record.provider.clone())
};

Ok(serde_json::json!({
"name": &record.name,
// Resolve the per-agent pin against the active workspace relay here:
// this payload crosses the host boundary to a remote provider harness
// that has no notion of the desktop's workspace, so the blank→workspace
// fallback (otherwise applied at read-time in `effective_agent_relay_url`)
// must be materialized into a concrete URL before serializing.
"relay_url": crate::relay::effective_agent_relay_url(
&record.relay_url,
&relay_ws_url_with_override(state),
),
"private_key_nsec": &record.private_key_nsec,
"auth_tag": &record.auth_tag,
"agent_command": &record.agent_command,
"agent_args": &record.agent_args,
"system_prompt": &record.system_prompt,
"model": effective_model,
// Structured provider from the persona record. Providers that don't
// yet read this field will fall back to env_vars or their own default
// — no protocol break.
"provider": effective_provider,
"turn_timeout_seconds": record.turn_timeout_seconds,
"idle_timeout_seconds": record.idle_timeout_seconds,
"max_turn_duration_seconds": record.max_turn_duration_seconds,
"parallelism": record.parallelism,
// Inbound author gate. Providers that don't yet read these fall back
// to the harness default (`owner-only`) — no protocol break.
"respond_to": record.respond_to,
"respond_to_allowlist": &record.respond_to_allowlist,
// Merged persona + agent env vars. Providers that don't read this
// field will simply ignore it — no protocol break.
"env_vars": merged_env,
}))
}

/// Deploy an agent to a provider backend. Resolves the binary, calls deploy via
/// spawn_blocking, and persists the result (backend_agent_id or last_error).
///
Expand Down Expand Up @@ -532,10 +434,13 @@ pub async fn create_managed_agent(
// Validate & normalize the respond-to allowlist BEFORE any side effects.
// The harness has its own validator (buzz-acp/src/config.rs) but we want
// to catch malformed input at the boundary so the agent never tries to
// start with a list that will crash it on launch.
// start with a list that will crash it on launch. The mode/allowlist
// pairing (and the definition-default fallback) is resolved later at the
// mint site via `resolve_mint_behavioral_defaults`, where the linked
// definition is in hand.
let respond_to_allowlist =
crate::managed_agents::validate_respond_to_allowlist(&input.respond_to_allowlist)?;
if input.respond_to == crate::managed_agents::RespondTo::Allowlist
if input.respond_to == Some(crate::managed_agents::RespondTo::Allowlist)
&& respond_to_allowlist.is_empty()
{
return Err(
Expand Down Expand Up @@ -742,20 +647,39 @@ pub async fn create_managed_agent(
// (input.env_vars), and the live persona env is merged underneath at
// read time (spawn / readiness / deploy) so persona credential edits
// refresh on the next spawn like prompt/model/provider already do.
let persona_snapshot = requested_persona_id.as_deref().and_then(|pid| {
let linked_persona = requested_persona_id.as_deref().and_then(|pid| {
load_personas(&app)
.ok()?
.into_iter()
.find(|persona| persona.id == pid)
.map(|persona| crate::managed_agents::persona_events::persona_snapshot(&persona))
});
let persona_snapshot = linked_persona
.as_ref()
.map(crate::managed_agents::persona_events::persona_snapshot);
let snapshot_prompt = persona_snapshot
.as_ref()
.and_then(|s| s.system_prompt.clone());
let snapshot_model = persona_snapshot.as_ref().and_then(|s| s.model.clone());
let snapshot_provider = persona_snapshot.as_ref().and_then(|s| s.provider.clone());
let snapshot_source_version = persona_snapshot.as_ref().map(|s| s.source_version.clone());

// Mint-time behavioral quad: explicit input wins, then the linked
// definition's NIP-AP defaults, then client defaults. The ONLY parse
// point for definition behavioral strings — fails loudly on a bad
// mode/range instead of minting an agent the author didn't describe.
let minted = crate::managed_agents::resolve_mint_behavioral_defaults(
input.respond_to,
respond_to_allowlist.clone(),
input
.mcp_toolsets
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
input.parallelism,
linked_persona.as_ref(),
)?;

let record = crate::managed_agents::ManagedAgentRecord {
pubkey: pubkey.clone(),
name: name.clone(),
Expand All @@ -782,10 +706,7 @@ pub async fn create_managed_agent(
// 0 or None → harness uses its own default (320s idle, 3600s max), and the CLI also clamps 0 → minimum.
idle_timeout_seconds: input.idle_timeout_seconds.filter(|s| *s > 0),
max_turn_duration_seconds: input.max_turn_duration_seconds.filter(|s| *s > 0),
parallelism: input
.parallelism
.filter(|count| (1..=32).contains(count))
.unwrap_or(DEFAULT_AGENT_PARALLELISM),
parallelism: minted.parallelism.unwrap_or(DEFAULT_AGENT_PARALLELISM),
system_prompt: snapshot_prompt.or_else(|| {
input
.system_prompt
Expand All @@ -811,12 +732,7 @@ pub async fn create_managed_agent(
.map(str::to_string)
}),
persona_source_version: snapshot_source_version,
mcp_toolsets: input
.mcp_toolsets
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
mcp_toolsets: minted.mcp_toolsets.clone(),
// Provider agents are managed externally — force false.
start_on_app_launch: if input.backend != BackendKind::Local {
false
Expand All @@ -841,8 +757,8 @@ pub async fn create_managed_agent(
last_exit_code: None,
last_error: None,
last_error_code: None,
respond_to: input.respond_to,
respond_to_allowlist: respond_to_allowlist.clone(),
respond_to: minted.respond_to,
respond_to_allowlist: minted.respond_to_allowlist.clone(),
display_name: None,
slug: None,
runtime: None,
Expand All @@ -851,6 +767,10 @@ pub async fn create_managed_agent(
is_active: true,
source_team: None,
source_team_persona_slug: None,
definition_respond_to: None,
definition_respond_to_allowlist: Vec::new(),
definition_mcp_toolsets: None,
definition_parallelism: None,
relay_mesh: relay_mesh.clone(),
};

Expand Down Expand Up @@ -1256,6 +1176,12 @@ pub async fn delete_managed_agent(
// 3. Desktop's existing presence polling sees "offline" — UI updates automatically
// No backend Tauri command needed. Presence IS the status.

#[path = "agents_deploy.rs"]
mod deploy;
use deploy::build_deploy_payload;
#[cfg(test)]
use deploy::deploy_payload_json;

#[path = "agents_profile.rs"]
mod profile;
#[cfg(test)]
Expand Down
131 changes: 131 additions & 0 deletions desktop/src-tauri/src/commands/agents_deploy.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
//! Provider deploy payload construction, split from `agents.rs` (file-size
//! guard). `build_deploy_payload` gathers live state; `deploy_payload_json`
//! is the pure serialization half so payload completeness stays testable.

use tauri::AppHandle;

use crate::{
app_state::AppState,
managed_agents::{load_personas, ManagedAgentRecord},
relay::relay_ws_url_with_override,
};

/// Build the standard agent JSON payload for provider deploy calls.
///
/// Like local spawn, provider deploy re-reads live persona env vars and
/// structured model/provider so remote agents receive current credentials
/// and the same authoritative values that local spawn derives from
/// `runtime_metadata_env_vars`. The only field still pinned is
/// `agent_command`/`agent_args` — those were captured at create time.
/// The only read-time resolution is `relay_url`: a blank pin resolves to
/// the active workspace relay here, matching the create-path contract.
///
/// Fails closed when the private key is unavailable (keyring outage leaves
/// it empty after hydration): without this guard a provider deploy would
/// 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(
app: &AppHandle,
state: &AppState,
record: &ManagedAgentRecord,
) -> Result<serde_json::Value, String> {
// Fails closed when the private key is unavailable — same guard as local
// spawn. Without this, a keyring outage would serialize `"private_key_nsec": ""`
// and launch the agent with no identity.
if let Some(err) = crate::managed_agents::spawn_key_refusal(record) {
return Err(err);
}

// Merge persona env_vars + agent env_vars for provider deploy — the same
// live-persona-under-overrides semantics as local spawn. Without this,
// provider-backed agents wouldn't receive credentials saved on the persona
// or the agent itself.
let persona_env =
crate::managed_agents::resolve_persona_env(app, record.persona_id.as_deref())?;
let merged_env = crate::managed_agents::merged_user_env(&persona_env, &record.env_vars);

// Resolve the persona's structured provider/model so the remote provider
// receives the same authoritative values that local spawn derives from
// `runtime_metadata_env_vars`. Without this, remote deploy would rely on
// stale derived env copies in `env_vars` (or have no provider at all for
// imported personas whose derived keys were filtered at import time).
//
// Precedence: persona field wins when non-blank; otherwise falls back to the
// record's own field (same blank-normalization as the snapshot path). This
// matches `persona_snapshot_with_agent_config_fallback` exactly — a blank
// persona field must not wipe a record value that the user configured.
let (effective_model, effective_provider) = if let Some(pid) = record.persona_id.as_deref() {
let personas = load_personas(app).map_err(|e| {
format!(
"failed to load personas while building deploy payload for persona `{pid}`: {e}"
)
})?;
let persona = personas
.into_iter()
.find(|p| p.id == pid)
.ok_or_else(|| format!("persona `{pid}` not found while building deploy payload"))?;
let fallback = crate::managed_agents::persona_events::persona_field_with_record_fallback;
let model = fallback(persona.model.as_deref(), record.model.as_deref()); // fallback: record.model
let provider = fallback(persona.provider.as_deref(), record.provider.as_deref()); // fallback: record.provider
(model, provider)
} else {
(record.model.clone(), record.provider.clone())
};

Ok(deploy_payload_json(
record,
crate::relay::effective_agent_relay_url(
&record.relay_url,
&relay_ws_url_with_override(state),
),
effective_model,
effective_provider,
merged_env,
))
}

/// Pure serialization half of [`build_deploy_payload`] — every field the
/// provider harness receives is deliberately listed here, so payload
/// completeness is testable without an `AppHandle`.
pub(super) fn deploy_payload_json(
record: &ManagedAgentRecord,
relay_url: String,
effective_model: Option<String>,
effective_provider: Option<String>,
merged_env: std::collections::BTreeMap<String, String>,
) -> serde_json::Value {
serde_json::json!({
"name": &record.name,
// Resolve the per-agent pin against the active workspace relay here:
// this payload crosses the host boundary to a remote provider harness
// that has no notion of the desktop's workspace, so the blank→workspace
// fallback (otherwise applied at read-time in `effective_agent_relay_url`)
// must be materialized into a concrete URL before serializing.
"relay_url": relay_url,
"private_key_nsec": &record.private_key_nsec,
"auth_tag": &record.auth_tag,
"agent_command": &record.agent_command,
"agent_args": &record.agent_args,
"system_prompt": &record.system_prompt,
"model": effective_model,
// Structured provider from the persona record. Providers that don't
// yet read this field will fall back to env_vars or their own default
// — no protocol break.
"provider": effective_provider,
"turn_timeout_seconds": record.turn_timeout_seconds,
"idle_timeout_seconds": record.idle_timeout_seconds,
"max_turn_duration_seconds": record.max_turn_duration_seconds,
"parallelism": record.parallelism,
// Inbound author gate. Providers that don't yet read these fall back
// to the harness default (`owner-only`) — no protocol break.
"respond_to": record.respond_to,
"respond_to_allowlist": &record.respond_to_allowlist,
// MCP toolset filter (BUZZ_TOOLSETS on the local spawn path).
// Providers that don't yet read this fall back to their default.
"mcp_toolsets": &record.mcp_toolsets,
// Merged persona + agent env vars. Providers that don't read this
// field will simply ignore it — no protocol break.
"env_vars": merged_env,
})
}
Loading
Loading