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
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"BUZZ_ACP_LAZY_POOL": "true",
"BUZZ_ACP_MODEL": "gpt-5",
"BUZZ_ACP_RELAY_OBSERVER": "true",
"BUZZ_ACP_SESSION_POLICY": "channel",
"BUZZ_ACP_SESSION_TITLE": "worker",
"GOOSE_MODE": "auto"
}
Expand Down
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export default defineConfig({
"**/profile-backup-settings.spec.ts",
"**/signout-confirmation.spec.ts",
"**/settings-section-layout.spec.ts",
"**/experimental-features.spec.ts",
"**/agent-provider-dropdowns.spec.ts",
"**/agent-lifecycle-feedback.spec.ts",
"**/agent-access-warning.spec.ts",
Expand Down
6 changes: 3 additions & 3 deletions desktop/src-tauri/src/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ pub struct AppState {
pub workspace_apply_generation: AtomicU64,
/// Defers managed-agent restore until `apply_workspace` installs relay and identity.
pub managed_agent_restore_pending: AtomicBool,
/// Disabled by agent-managed profiles so agent profile updates survive start/restore.
pub managed_agent_profile_reconcile_enabled: AtomicBool,
/// Experiment state applied to managed-agent starts and profile reconciliation.
pub managed_agent_experiments: crate::managed_agents::ManagedAgentExperimentState,
/// Shared shutdown signal checked by launch-time agent restoration.
pub shutdown_started: AtomicBool,
/// Serializes every managed-runtime transition that changes the protected
Expand Down Expand Up @@ -207,7 +207,7 @@ pub fn build_app_state() -> AppState {
workspace_apply_lock: Arc::new(AsyncMutex::new(())),
workspace_apply_generation: AtomicU64::new(0),
managed_agent_restore_pending: AtomicBool::new(false),
managed_agent_profile_reconcile_enabled: AtomicBool::new(true),
managed_agent_experiments: crate::managed_agents::ManagedAgentExperimentState::default(),
shutdown_started: AtomicBool::new(false),
managed_agent_runtime_transition: Mutex::new(()),
identity_mutation: Mutex::new(()),
Expand Down
9 changes: 8 additions & 1 deletion desktop/src-tauri/src/commands/agent_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,17 @@ use crate::{
#[tauri::command]
pub fn set_agent_managed_profiles(enabled: bool, state: State<'_, AppState>) {
state
.managed_agent_profile_reconcile_enabled
.managed_agent_profile_reconcile_enabled()
.store(!enabled, Ordering::Release);
}

#[tauri::command]
pub fn set_thread_scoped_acp_sessions(enabled: bool, state: State<'_, AppState>) {
state
.thread_scoped_acp_sessions_enabled()
.store(enabled, Ordering::Release);
}

#[tauri::command]
pub async fn set_managed_agent_start_on_app_launch(
pubkey: String,
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1014,7 +1014,7 @@ pub async fn start_managed_agent(
// with no persisted avatar, this also backfills the avatar from the relay.
if result.is_ok()
&& state
.managed_agent_profile_reconcile_enabled
.managed_agent_profile_reconcile_enabled()
.load(std::sync::atomic::Ordering::Acquire)
{
let reconcile_pubkey = pubkey.clone();
Expand Down
67 changes: 61 additions & 6 deletions desktop/src-tauri/src/commands/agents_deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,17 @@ pub(crate) fn resolve_deploy_model_provider(

/// Serialize the portable launch contract shared with provider-backed agents.
///
/// `descriptor.env` is the authoritative six-layer environment. Policy values
/// are deliberately separate because providers apply them below that layered
/// environment, preserving the local spawn's power-user override semantics.
pub(super) fn build_launch_block(
/// `descriptor.env` is the authoritative six-layer environment for ordinary
/// values. Desktop-owned settings are reserved, stripped from that layer, and
/// emitted through `policy_env` so local and provider launches agree.
fn build_launch_block_for_policy(
record: &ManagedAgentRecord,
descriptor: &crate::managed_agents::readiness::EffectiveHarnessDescriptor,
teams: &[crate::managed_agents::TeamRecord],
effective_prompt: Option<&str>,
effective_model: Option<&str>,
owner_pubkey: &str,
session_policy: crate::managed_agents::AcpSessionPolicy,
) -> serde_json::Value {
use crate::managed_agents::{
known_acp_runtime, resolve_session_title, DISPLAY_NAME_ENV_VAR, SESSION_TITLE_ENV_VAR,
Expand All @@ -78,6 +79,7 @@ pub(super) fn build_launch_block(
"BUZZ_ACP_AGENTS".into(),
crate::managed_agents::acp_agents_value(&descriptor.command, record.parallelism),
);
crate::managed_agents::insert_acp_session_policy_env(&mut policy_env, session_policy);

if let Some(value) = effective_prompt {
policy_env.insert("BUZZ_ACP_SYSTEM_PROMPT".into(), value.to_string());
Expand Down Expand Up @@ -138,7 +140,8 @@ pub(super) fn build_launch_block(
// matching local, where `apply_claude_model_env(None)` removes both.
let is_claude = runtime.map(|r| r.id == "claude").unwrap_or(false);
let strip_key = |k: &str| {
(record.effort_level.is_some() && k.eq_ignore_ascii_case("BUZZ_ACP_EFFORT_LEVEL"))
k.eq_ignore_ascii_case(crate::managed_agents::ACP_SESSION_POLICY_ENV_VAR)
|| (record.effort_level.is_some() && k.eq_ignore_ascii_case("BUZZ_ACP_EFFORT_LEVEL"))
|| (is_claude
&& (k.eq_ignore_ascii_case("BUZZ_ACP_MODEL")
|| k.eq_ignore_ascii_case("ANTHROPIC_MODEL")))
Expand All @@ -159,6 +162,26 @@ pub(super) fn build_launch_block(
})
}

#[cfg(test)]
pub(super) fn build_launch_block(
record: &ManagedAgentRecord,
descriptor: &crate::managed_agents::readiness::EffectiveHarnessDescriptor,
teams: &[crate::managed_agents::TeamRecord],
effective_prompt: Option<&str>,
effective_model: Option<&str>,
owner_pubkey: &str,
) -> serde_json::Value {
build_launch_block_for_policy(
record,
descriptor,
teams,
effective_prompt,
effective_model,
owner_pubkey,
crate::managed_agents::AcpSessionPolicy::Channel,
)
}

pub(super) fn ensure_remote_provider_supported(provider: Option<&str>) -> Result<(), String> {
if provider.map(str::trim) == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) {
return Err(
Expand Down Expand Up @@ -198,13 +221,14 @@ pub(crate) fn build_deploy_payload(
crate::managed_agents::resolve_effective_harness_descriptor(record, &personas, &global)
.map_err(|error| crate::managed_agents::user_facing_harness_error(&error))?;
let owner_pubkey = super::workspace_owner_hex(state)?;
let launch = build_launch_block(
let launch = build_launch_block_for_policy(
record,
&descriptor,
&teams,
effective.system_prompt.value.as_deref(),
effective.model.value.as_deref(),
&owner_pubkey,
crate::managed_agents::acp_session_policy(state),
);

let effective_parallelism =
Expand Down Expand Up @@ -342,9 +366,40 @@ mod tests {
assert_eq!(launch["policy_env"]["BUZZ_ACP_IDLE_TIMEOUT"], "17");
assert_eq!(launch["policy_env"]["BUZZ_ACP_MAX_TURN_DURATION"], "23");
assert_eq!(launch["policy_env"]["BUZZ_ACP_AGENTS"], "4");
assert_eq!(launch["policy_env"]["BUZZ_ACP_SESSION_POLICY"], "channel");
assert_eq!(launch["owner_pubkey"], "owner-hex");
}

#[test]
fn launch_block_thread_policy_is_authoritative_and_preserves_unrelated_env() {
let record = record();
let descriptor = EffectiveHarnessDescriptor {
command: "goose".into(),
args: vec![],
env: BTreeMap::from([
("BUZZ_ACP_SESSION_POLICY".to_string(), "channel".to_string()),
("KEEP_ME".to_string(), "yes".to_string()),
]),
};

let launch = build_launch_block_for_policy(
&record,
&descriptor,
&[],
None,
None,
"owner-hex",
crate::managed_agents::AcpSessionPolicy::Thread,
);

assert_eq!(launch["policy_env"]["BUZZ_ACP_SESSION_POLICY"], "thread");
assert!(
launch["env"]["BUZZ_ACP_SESSION_POLICY"].is_null(),
"desktop policy must not be shadowed by descriptor env"
);
assert_eq!(launch["env"]["KEEP_ME"], "yes");
}

#[test]
fn launch_block_claude_runtime_uses_anthropic_model_not_buzz_acp_model() {
// B2: remote claude deploys must send ANTHROPIC_MODEL, not BUZZ_ACP_MODEL,
Expand Down
4 changes: 2 additions & 2 deletions desktop/src-tauri/src/commands/agents_profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ pub(crate) async fn reconcile_agent_profile(
);

if !state
.managed_agent_profile_reconcile_enabled
.managed_agent_profile_reconcile_enabled()
.load(std::sync::atomic::Ordering::Acquire)
{
return Ok(ProfileReconcileOutcome::SkippedDisabled);
Expand Down Expand Up @@ -262,7 +262,7 @@ pub(crate) async fn reconcile_agent_profile(
.map_err(|e| format!("failed to parse agent keys: {e}"))?;

if !state
.managed_agent_profile_reconcile_enabled
.managed_agent_profile_reconcile_enabled()
.load(std::sync::atomic::Ordering::Acquire)
{
return Ok(ProfileReconcileOutcome::SkippedDisabled);
Expand Down
10 changes: 9 additions & 1 deletion desktop/src-tauri/src/commands/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ pub async fn apply_workspace(
nsec: Option<String>,
repos_dir: Option<String>,
agent_managed_profiles: Option<bool>,
thread_scoped_acp_sessions: Option<bool>,
app: AppHandle,
) -> Result<(), String> {
let state = app.state::<AppState>();
Expand Down Expand Up @@ -228,8 +229,15 @@ pub async fn apply_workspace(
// experiment before launch-time restore can spawn any agents. Missing
// means the stable behavior: desktop remains authoritative.
state
.managed_agent_profile_reconcile_enabled
.managed_agent_profile_reconcile_enabled()
.store(!agent_managed_profiles.unwrap_or(false), Ordering::Release);
// Persisted frontend experiment state must land before launch-time
// restore so every restored agent starts with the selected ACP policy.
// Missing preserves the stable channel-scoped behavior.
state.thread_scoped_acp_sessions_enabled().store(
thread_scoped_acp_sessions.unwrap_or(false),
Ordering::Release,
);

// ── Filesystem side-effect (non-fatal) ────────────────────────────────
// Persist the *effective* repos_dir (None when the candidate failed
Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,7 @@ pub fn run() {
start_managed_agent,
stop_managed_agent,
set_agent_managed_profiles,
set_thread_scoped_acp_sessions,
set_managed_agent_start_on_app_launch,
set_managed_agent_auto_restart,
delete_managed_agent,
Expand Down
7 changes: 7 additions & 0 deletions desktop/src-tauri/src/managed_agents/env_vars/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,13 @@ fn reserved_keys_include_remote_lifetime_policy() {
}
}

#[test]
fn reserved_keys_include_desktop_acp_session_policy() {
assert!(is_reserved_env_key("BUZZ_ACP_SESSION_POLICY"));
let agent = map(&[("BUZZ_ACP_SESSION_POLICY", "thread")]);
assert!(merged_user_env(&BTreeMap::new(), &agent).is_empty());
}

#[test]
fn reserved_keys_include_code_execution_surface() {
// The agent/MCP command + args are what Buzz actually exec's.
Expand Down
5 changes: 5 additions & 0 deletions desktop/src-tauri/src/managed_agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ pub mod retention;
mod runtime;
mod runtime_commands;
mod runtime_types;
mod session_policy;
pub(crate) mod snapshot_avatar;
pub(crate) mod spawn_snapshot;
pub(crate) mod storage;
Expand Down Expand Up @@ -85,6 +86,10 @@ pub use restore::*;
pub use runtime::*;
pub use runtime_commands::*;
pub use runtime_types::*;
pub(crate) use session_policy::{
acp_session_policy, apply_app_acp_session_policy_env, insert_acp_session_policy_env,
AcpSessionPolicy, ManagedAgentExperimentState, ACP_SESSION_POLICY_ENV_VAR,
};
pub use storage::*;
pub use teams::*;
pub use types::*;
Expand Down
3 changes: 3 additions & 0 deletions desktop/src-tauri/src/managed_agents/reserved_env_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[
// Desktop-owned pool lifetime policy: user env must not disable or reset
// the idle worker-reclamation window while the desktop launcher sets it.
"BUZZ_ACP_IDLE_POOL_SLEEP",
// Desktop experiment policy: the Settings toggle is the sole authority
// for whether channel threads receive independent ACP sessions.
"BUZZ_ACP_SESSION_POLICY",
"BUZZ_ACP_NO_PRESENCE",
// Readiness handoff: desktop is the ONLY readiness source. A saved or
// ambient env var must not be able to forge setup mode (NotReady) on a
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/managed_agents/restore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,7 @@ fn profile_reconcile_completed(outcome: crate::commands::ProfileReconcileOutcome
pub(crate) fn spawn_pending_profile_reconciliations(app: &tauri::AppHandle, workspace_relay: &str) {
let state = app.state::<AppState>();
if !state
.managed_agent_profile_reconcile_enabled
.managed_agent_profile_reconcile_enabled()
.load(Ordering::Acquire)
{
return;
Expand Down
20 changes: 7 additions & 13 deletions desktop/src-tauri/src/managed_agents/runtime.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::collections::HashMap;

use tauri::AppHandle;
use tauri::{AppHandle, Manager};

use super::agent_env::{build_buzz_agent_provider_defaults, idle_pool_sleep_env};

Expand All @@ -23,8 +23,8 @@ pub(crate) use super::access_policy::{build_respond_to_env_with_policy, RespondT

mod metadata;
pub(crate) use metadata::{
apply_agent_display_env, resolve_session_title, runtime_metadata_env_vars,
DISPLAY_NAME_ENV_VAR, SESSION_TITLE_ENV_VAR,
apply_agent_display_env, child_rust_log_filter, resolve_session_title,
runtime_metadata_env_vars, DISPLAY_NAME_ENV_VAR, SESSION_TITLE_ENV_VAR,
};

mod stop;
Expand Down Expand Up @@ -109,7 +109,6 @@ pub(crate) fn workspace_pair_key(
app: &AppHandle,
record: &ManagedAgentRecord,
) -> Option<ManagedAgentRuntimeKey> {
use tauri::Manager;
let state = app.state::<crate::app_state::AppState>();
resolve_workspace_pair_key(
&record.pubkey,
Expand Down Expand Up @@ -254,6 +253,7 @@ pub fn build_managed_agent_summary(
&key.relay_url,
global_config,
super::owner_only_access_build(),
super::acp_session_policy(app.state::<crate::app_state::AppState>().inner()),
);
(runtime, current)
});
Expand Down Expand Up @@ -809,7 +809,8 @@ pub fn spawn_agent_child(
for (key, value) in &descriptor.env {
command.env(key, value);
}

// Resolve once and stamp the same value onto the snapshot below.
let acp_session_policy = super::apply_app_acp_session_policy_env(app, &mut command);
// B5: carry persisted effort; harness resolves thought_level configId at first session.
// Written AFTER descriptor.env so the canonical persisted value wins over any
// user-supplied BUZZ_ACP_EFFORT_LEVEL entry, mirroring the A1 model-authority pattern
Expand Down Expand Up @@ -861,6 +862,7 @@ pub fn spawn_agent_child(
model: effective_model.as_deref(),
provider: effective_provider.as_deref(),
enforced_owner_only: super::owner_only_access_build(),
session_policy: acp_session_policy,
},
);

Expand Down Expand Up @@ -928,14 +930,6 @@ pub fn spawn_agent_child(
})
}

fn child_rust_log_filter() -> String {
match std::env::var("RUST_LOG") {
Ok(existing) if existing.contains("buzz_acp") => existing,
Ok(existing) if !existing.trim().is_empty() => format!("{existing},buzz_acp=info"),
_ => "buzz_acp=info".to_string(),
}
}

/// Spawn (or adopt) the runtime pair for `record` on the caller's bound
/// workspace relay. `workspace_relay` can only be produced by
/// `bind_expected_relay_scope`, so this spawn consumes — by construction — the
Expand Down
11 changes: 11 additions & 0 deletions desktop/src-tauri/src/managed_agents/runtime/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,17 @@ pub(crate) fn resolve_session_title(display_name: Option<&str>, name: &str) -> O
.find(|value| !value.is_empty())
}

/// Build the `RUST_LOG` value forwarded to the agent child: keep an existing
/// filter that already mentions `buzz_acp`, append `buzz_acp=info` to any other
/// non-empty filter, and default to `buzz_acp=info` when unset.
pub(crate) fn child_rust_log_filter() -> String {
match std::env::var("RUST_LOG") {
Ok(existing) if existing.contains("buzz_acp") => existing,
Ok(existing) if !existing.trim().is_empty() => format!("{existing},buzz_acp=info"),
_ => "buzz_acp=info".to_string(),
}
}

#[cfg(test)]
mod tests {
use super::resolve_session_title;
Expand Down
Loading
Loading