feat(desktop): cap OpenClaw agent parallelism at 5 (#4019)

OpenClaw connects to a single shared Gateway daemon. Spawning the
default 10 ACP workers per agent is both resource-expensive and
architecturally wrong — each worker opens a separate gateway connection.
Tyler's ruling: cap at 5, lower if needed.

## Contract

Store the requested value (1–32) verbatim at every persistence and wire
boundary. Apply `effective = min(requested, harness_cap)` only at the
four enforcement points:

| Boundary | Implementation |
|---|---|
| Local spawn | `BUZZ_ACP_AGENTS` env var in child `Command` |
| Remote deploy | `launch.policy_env["BUZZ_ACP_AGENTS"]` + legacy
`parallelism` field |
| Restart badge | `SpawnConfigSnapshot.parallelism` stores effective
value; the diff surface displays what actually runs |
| UI copy | Amber hint when requested > cap; no `max` attribute, no
save-path clamp |

`BUZZ_ACP_AGENTS` is added to `RESERVED_ENV_KEYS` — the Desktop resolves
the effective value into `policy_env`; a user-supplied override in `env`
would bypass the cap and is silently stripped.

## Changes

**`managed_agents/parallelism.rs`** (new) — policy core:
- `OPENCLAW_MAX_PARALLELISM = 5`
- `harness_max_parallelism(command)` — keyed on
`normalize_command_identity` so path prefixes, `.exe` suffixes, and
other cosmetic differences are ignored
- `effective_parallelism(command, value)` — identity for uncapped
harnesses
- `acp_agents_value(command, parallelism)` — `env("BUZZ_ACP_AGENTS", …)`
helper

**`runtime.rs`** — spawn clamp: `BUZZ_ACP_AGENTS =
acp_agents_value(effective_command, record.parallelism)`

**`agents_deploy.rs`** — deploy egress clamp: `build_deploy_payload`
resolves `effective_parallelism` once from `descriptor.command`; both
`launch.policy_env["BUZZ_ACP_AGENTS"]` and the legacy top-level
`parallelism` field use that value — the two are always consistent
regardless of stale `record.agent_command` pins

**`spawn_snapshot.rs`** — `from_inputs` stores
`effective_parallelism(&descriptor.command, record.parallelism)` in the
`parallelism` field. Over-cap edits that don't change the pool (e.g. 10
→ 8, both clamp to 5 on OpenClaw) produce equal snapshots; cap crossings
(8 → 3) produce different snapshots.

**`AcpRuntimeCatalogEntry.max_parallelism: Option<u32>`** — derived from
the static definition command, not the probed `entry.command` (which may
be `null` for unavailable entries), so unavailable OpenClaw entries
still carry the cap. Propagated through all four catalog constructors
(builtin discovery, preset catalog construction, custom discovery,
custom-save response), IPC types
(`RawAcpRuntimeCatalogEntry.max_parallelism`), and the frontend catalog
type.

**UI** — `EditAgentAdvancedFields` and `PersonaAdvancedFields` show an
amber hint when `selectedRuntime.maxParallelism` is set and the current
value exceeds it. Cap and label come from the catalog entry — no
hardcoded 5 in TS. No `max` attribute on inputs; the input stays
`type="text"` with 1–32 copy.

**Docs** — `docs/remote-agents.md`: `BUZZ_ACP_AGENTS` moved from the
deliberately-non-reserved section to reserved; new contract documented.
`desktop/src/features/agents/AGENTS.md`: command-keyed execution policy
documented as the sanctioned second metadata source feeding the catalog
projection.

## Tests

**Rust** (`parallelism.rs`):
- `policy_table` — `harness_max_parallelism` and `effective_parallelism`
across all openclaw variants and uncapped harnesses
- `acp_agents_value_openclaw_above_cap_is_capped` — spawn-env seam
- `override_direction_*` — both override directions (openclaw runtime +
goose override; goose runtime + openclaw override)
- `summary_persona_inherited_*` — live persona wins over stale
`agent_command`
- `snapshot_export_carries_requested_definition_parallelism` — requested
value travels wire/sync unchanged

**Rust** (`spawn_snapshot/tests.rs`):
- `openclaw_above_cap_parallelism_snapshots_equal` — stored 10 vs 8,
both clamp to 5 → snapshots equal
- `openclaw_cap_crossing_parallelism_snapshots_differ` — 8 (clamps to 5)
vs 3 → snapshots differ

**Rust** (`discovery/presets.rs`):
- `openclaw_preset_unavailable_carries_max_parallelism` /
`openclaw_preset_available_carries_max_parallelism` — catalog metadata
present with `command: null` and with a resolved path

**Rust** (`agents_deploy.rs`):
- `launch_block_openclaw_over_cap_policy_env_is_capped` — direct
`launch.policy_env` seam
-
`deploy_payload_json_stale_goose_record_live_openclaw_descriptor_both_capped`
— stale `record.agent_command=goose`, live descriptor=openclaw: both
fields cap to 5
-
`deploy_payload_json_stale_openclaw_record_live_goose_descriptor_both_uncapped`
— stale `record.agent_command=openclaw`, live descriptor=goose: both
fields pass through requested
- `deploy_payload_json_explicit_openclaw_override_both_capped` —
explicit `agent_command_override=openclaw`: both fields cap to 5

**Rust** (`persona_events/stale_pin_tests.rs`):
- `apply_persona_snapshot_goose_to_custom_harness_drops_stale_goose_pin`
— custom-direction stale-pin drop (builtin pin → loaded custom harness
via `update_loaded_harness_registry`)

**TypeScript** (`agentParallelism.test.mjs`):
- `parallelismCapHint` — at/below cap (null), above cap (hint includes
label and cap value), singular form for cap=1, uncapped harness (null)

**TypeScript** (`tauri.test.mjs`):
- `fromRawAcpRuntimeCatalogEntry` round-trips `max_parallelism` →
`maxParallelism`; absent when `undefined`

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-08-05 16:09:14 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 6df7eba24d
commit 6c40ce394f
23 changed files with 945 additions and 71 deletions
@@ -155,7 +155,6 @@ pub async fn save_custom_harness(
Ok(AcpRuntimeCatalogEntry {
id: definition.id,
label: definition.label,
// Security: no user-supplied avatar URL in catalog entries.
avatar_url: String::new(),
availability,
command: command_opt,
@@ -177,8 +176,8 @@ pub async fn save_custom_harness(
auth_status: AuthStatus::NotApplicable,
login_hint: None,
source: HarnessSource::Custom,
// Carry definition env back so the edit form can read and preserve it.
definition_env: definition.env,
max_parallelism: crate::managed_agents::harness_max_parallelism(&definition.command),
})
}
+1 -1
View File
@@ -1360,7 +1360,7 @@ pub async fn delete_managed_agent(
mod deploy;
use deploy::build_deploy_payload;
#[cfg(test)]
use deploy::deploy_payload_json;
use deploy::{deploy_payload_json, DeployProjections};
#[cfg(test)]
use deploy::{ensure_remote_provider_supported, resolve_deploy_model_provider};
+217 -11
View File
@@ -14,6 +14,18 @@ use crate::{
relay::relay_ws_url_with_override,
};
/// Effective projection fields for the deploy payload — all derived from the
/// resolved descriptor and effective config so that the serialised payload and
/// the `launch` block are always internally consistent.
pub(super) struct DeployProjections {
pub effective_model: Option<String>,
pub effective_provider: Option<String>,
pub effective_prompt: Option<String>,
/// Effective parallelism derived from the same resolved `descriptor.command`
/// as `launch.policy_env["BUZZ_ACP_AGENTS"]`.
pub effective_parallelism: u32,
}
/// Resolve the deploy-specific structured model/provider for a managed agent.
#[cfg(test)]
pub(crate) fn resolve_deploy_model_provider(
@@ -60,7 +72,10 @@ pub(super) fn build_launch_block(
}
policy_env.insert("BUZZ_ACP_RELAY_OBSERVER".into(), "true".into());
policy_env.insert("BUZZ_ACP_LAZY_POOL".into(), "true".into());
policy_env.insert("BUZZ_ACP_AGENTS".into(), record.parallelism.to_string());
policy_env.insert(
"BUZZ_ACP_AGENTS".into(),
crate::managed_agents::acp_agents_value(&descriptor.command, record.parallelism),
);
if let Some(value) = effective_prompt {
policy_env.insert("BUZZ_ACP_SYSTEM_PROMPT".into(), value.to_string());
@@ -141,15 +156,21 @@ pub(super) fn build_deploy_payload(
&owner_pubkey,
);
let effective_parallelism =
crate::managed_agents::effective_parallelism(&descriptor.command, record.parallelism);
Ok(deploy_payload_json(
record,
crate::relay::effective_agent_relay_url(
&record.relay_url,
&relay_ws_url_with_override(state),
),
effective.model.value,
effective.provider.value,
effective.system_prompt.value,
DeployProjections {
effective_model: effective.model.value,
effective_provider: effective.provider.value,
effective_prompt: effective.system_prompt.value,
effective_parallelism,
},
merged_user_env,
launch,
))
@@ -157,12 +178,13 @@ pub(super) fn build_deploy_payload(
/// Pure serialization half of [`build_deploy_payload`]. Legacy top-level fields
/// remain for display/bookkeeping; providers execute the resolved `launch` block.
/// `projections.effective_parallelism` is pre-computed from the same resolved
/// descriptor as `launch.policy_env["BUZZ_ACP_AGENTS"]` — the two fields are
/// always consistent regardless of stale `record.agent_command` pins.
pub(super) fn deploy_payload_json(
record: &ManagedAgentRecord,
relay_url: String,
effective_model: Option<String>,
effective_provider: Option<String>,
effective_prompt: Option<String>,
projections: DeployProjections,
merged_env: BTreeMap<String, String>,
launch: serde_json::Value,
) -> serde_json::Value {
@@ -173,13 +195,15 @@ pub(super) fn deploy_payload_json(
"auth_tag": &record.auth_tag,
"agent_command": &record.agent_command,
"agent_args": &record.agent_args,
"system_prompt": effective_prompt,
"model": effective_model,
"provider": effective_provider,
"system_prompt": projections.effective_prompt,
"model": projections.effective_model,
"provider": projections.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,
// Legacy top-level field: projected from the same resolved descriptor as
// launch.policy_env["BUZZ_ACP_AGENTS"] — the two are always consistent.
"parallelism": projections.effective_parallelism,
"respond_to": record.respond_to,
"respond_to_allowlist": &record.respond_to_allowlist,
"env_vars": merged_env,
@@ -261,4 +285,186 @@ mod tests {
assert_eq!(launch["policy_env"]["BUZZ_ACP_AGENTS"], "4");
assert_eq!(launch["owner_pubkey"], "owner-hex");
}
/// OpenClaw descriptor: `launch.policy_env["BUZZ_ACP_AGENTS"]` must be "5"
/// even when the record's requested parallelism is 10. This is the direct
/// `launch.policy_env` seam test — the executable contract for remote providers.
#[test]
fn launch_block_openclaw_over_cap_policy_env_is_capped() {
let mut record = record();
record.agent_command = "openclaw".into();
record.parallelism = 10; // above the OpenClaw spawn-time cap
let descriptor = EffectiveHarnessDescriptor {
command: "openclaw".into(),
args: vec![],
env: BTreeMap::new(),
};
let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex");
assert_eq!(
launch["policy_env"]["BUZZ_ACP_AGENTS"],
crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM.to_string(),
"launch.policy_env[BUZZ_ACP_AGENTS] must be capped at {} for OpenClaw, not 10",
crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM
);
}
/// Uncapped harness (goose): `launch.policy_env["BUZZ_ACP_AGENTS"]` passes
/// the requested value through unchanged.
#[test]
fn launch_block_goose_policy_env_is_not_capped() {
let mut record = record();
record.parallelism = 8;
let descriptor = EffectiveHarnessDescriptor {
command: "goose".into(),
args: vec![],
env: BTreeMap::new(),
};
let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex");
assert_eq!(
launch["policy_env"]["BUZZ_ACP_AGENTS"], "8",
"goose: policy_env[BUZZ_ACP_AGENTS] must pass through requested value 8"
);
}
/// deploy_payload_json: legacy top-level `parallelism` is the effective value
/// derived from the descriptor, not `record.agent_command`.
///
/// Stale-persona scenario: `record.agent_command` is "goose" (created before
/// the user switched the persona to OpenClaw), but the live descriptor resolves
/// OpenClaw. Both `launch.policy_env["BUZZ_ACP_AGENTS"]` and the legacy
/// top-level `parallelism` must be the effective OpenClaw value (5), not the
/// record's stale Goose identity (requested 10).
#[test]
fn deploy_payload_json_stale_goose_record_live_openclaw_descriptor_both_capped() {
let mut record = record();
// Stale agent_command from record creation — persona has since switched to OpenClaw.
record.agent_command = "goose".into();
record.parallelism = 10;
// Resolved descriptor reflects the live persona (OpenClaw).
let descriptor = EffectiveHarnessDescriptor {
command: "openclaw".into(),
args: vec![],
env: BTreeMap::new(),
};
let cap = crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM;
let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex");
let effective_parallelism =
crate::managed_agents::effective_parallelism(&descriptor.command, record.parallelism);
let payload = deploy_payload_json(
&record,
"wss://relay.example".to_string(),
DeployProjections {
effective_model: None,
effective_provider: None,
effective_prompt: None,
effective_parallelism,
},
BTreeMap::new(),
launch.clone(),
);
assert_eq!(
launch["policy_env"]["BUZZ_ACP_AGENTS"],
cap.to_string(),
"launch.policy_env[BUZZ_ACP_AGENTS] must be capped at {cap} for live OpenClaw descriptor"
);
assert_eq!(
payload["parallelism"], cap,
"legacy top-level parallelism must match launch.policy_env — both must be {cap}"
);
}
/// Inverse stale-persona scenario: `record.agent_command` is "openclaw"
/// (created before the user switched the persona to Goose), but the live
/// descriptor resolves Goose. Both projections must be the uncapped requested
/// value (4), not the old OpenClaw cap.
#[test]
fn deploy_payload_json_stale_openclaw_record_live_goose_descriptor_both_uncapped() {
let mut record = record();
// Stale agent_command from record creation — persona has since switched to Goose.
record.agent_command = "openclaw".into();
record.parallelism = 4;
// Resolved descriptor reflects the live persona (Goose).
let descriptor = EffectiveHarnessDescriptor {
command: "goose".into(),
args: vec![],
env: BTreeMap::new(),
};
let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex");
let effective_parallelism =
crate::managed_agents::effective_parallelism(&descriptor.command, record.parallelism);
let payload = deploy_payload_json(
&record,
"wss://relay.example".to_string(),
DeployProjections {
effective_model: None,
effective_provider: None,
effective_prompt: None,
effective_parallelism,
},
BTreeMap::new(),
launch.clone(),
);
assert_eq!(
launch["policy_env"]["BUZZ_ACP_AGENTS"],
"4",
"launch.policy_env[BUZZ_ACP_AGENTS] must pass through requested 4 for live Goose descriptor"
);
assert_eq!(
payload["parallelism"], 4,
"legacy top-level parallelism must match launch.policy_env — both must be 4 (uncapped)"
);
}
/// Explicit agent_command_override direction: record has an explicit override
/// pinning OpenClaw while the persona default is Goose. The override wins
/// via the descriptor — both projections must be capped at the OpenClaw limit.
#[test]
fn deploy_payload_json_explicit_openclaw_override_both_capped() {
let mut record = record();
// Explicit override: user pinned OpenClaw on this agent.
record.agent_command_override = Some("openclaw".into());
record.agent_command = "goose".into(); // persona default, overridden
record.parallelism = 10;
// Descriptor reflects the resolved override (OpenClaw wins).
let descriptor = EffectiveHarnessDescriptor {
command: "openclaw".into(),
args: vec![],
env: BTreeMap::new(),
};
let cap = crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM;
let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex");
let effective_parallelism =
crate::managed_agents::effective_parallelism(&descriptor.command, record.parallelism);
let payload = deploy_payload_json(
&record,
"wss://relay.example".to_string(),
DeployProjections {
effective_model: None,
effective_provider: None,
effective_prompt: None,
effective_parallelism,
},
BTreeMap::new(),
launch.clone(),
);
assert_eq!(
launch["policy_env"]["BUZZ_ACP_AGENTS"],
cap.to_string(),
"launch.policy_env[BUZZ_ACP_AGENTS] must be {cap} for explicit OpenClaw override"
);
assert_eq!(
payload["parallelism"], cap,
"legacy top-level parallelism must match launch.policy_env — both must be {cap}"
);
}
}
@@ -465,9 +465,15 @@ fn deploy_payload_matches_the_shared_full_launch_fixture() {
let agent = deploy_payload_json(
&record,
"wss://relay.example".into(),
Some("gpt-5".into()),
Some("openai".into()),
None,
DeployProjections {
effective_model: Some("gpt-5".into()),
effective_provider: Some("openai".into()),
effective_prompt: None,
effective_parallelism: crate::managed_agents::effective_parallelism(
&descriptor.command,
record.parallelism,
),
},
std::collections::BTreeMap::from([("USER_KEY".into(), "user-value".into())]),
launch,
);
@@ -1403,8 +1403,8 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr
auth_status: AuthStatus::Unknown,
login_hint: None,
source: HarnessSource::Builtin,
// Builtin entries have no user-editable env; definition_env is empty.
definition_env: Default::default(),
max_parallelism: super::parallelism::harness_max_parallelism(runtime.id),
},
}
}
@@ -1565,9 +1565,8 @@ pub fn discover_acp_runtimes_from(
auth_status: AuthStatus::NotApplicable,
login_hint: None,
source: HarnessSource::Custom,
// Carry definition env into the catalog so the edit form can
// read it back — prevents silently erasing env on save.
definition_env: def.env.clone(),
definition_env: def.env.clone(), // preserve for edit round-trip
max_parallelism: super::parallelism::harness_max_parallelism(&def.command),
});
}
}
@@ -82,6 +82,10 @@ pub(super) fn preset_catalog_entry(
login_hint: None,
source: HarnessSource::Preset,
definition_env: Default::default(),
// Derived from the static preset command (`def.command`). This ensures
// unavailable entries (command: null in JSON, None here) still carry
// the cap — the harness cap is command-keyed, not availability-gated.
max_parallelism: crate::managed_agents::harness_max_parallelism(def.command),
}
}
@@ -405,4 +409,65 @@ mod tests {
assert!(!entry.requires_external_cli);
assert!(entry.underlying_cli_path.is_none());
}
// ── Catalog max_parallelism: command-keyed execution policy ──────────────
/// Unavailable OpenClaw (command not on PATH → command: null in JSON):
/// max_parallelism must still be Some(5) — derived from the static `def.command`,
/// not the probed `entry.command`.
#[test]
fn openclaw_preset_unavailable_carries_max_parallelism() {
let openclaw = PRESET_HARNESSES
.iter()
.find(|p| p.id == "openclaw")
.expect("openclaw preset must be present");
// Simulate "not installed" — resolver always returns None.
let entry = preset_catalog_entry(openclaw, |_| None);
assert_eq!(entry.availability, AcpAvailabilityStatus::NotInstalled);
assert!(
entry.command.is_none(),
"unavailable entry must have command: null"
);
assert_eq!(
entry.max_parallelism,
Some(crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM),
"unavailable OpenClaw must still carry max_parallelism {}",
crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM
);
}
/// Available OpenClaw: max_parallelism present regardless of install status.
#[test]
fn openclaw_preset_available_carries_max_parallelism() {
let openclaw = PRESET_HARNESSES
.iter()
.find(|p| p.id == "openclaw")
.expect("openclaw preset must be present");
let entry = preset_catalog_entry(openclaw, |cmd| {
(cmd == openclaw.id || cmd == "openclaw")
.then(|| std::path::PathBuf::from("/usr/local/bin/openclaw"))
});
assert_eq!(
entry.max_parallelism,
Some(crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM),
"available OpenClaw must carry max_parallelism {}",
crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM
);
}
/// Uncapped preset (devin): max_parallelism must be None.
#[test]
fn uncapped_preset_has_no_max_parallelism() {
let devin = PRESET_HARNESSES
.iter()
.find(|p| p.id == "devin")
.expect("devin preset must be present");
let entry = preset_catalog_entry(devin, |_| None);
assert_eq!(
entry.max_parallelism, None,
"uncapped preset (devin) must have max_parallelism: None"
);
}
}
@@ -5,11 +5,19 @@
//! Precedence: desktop parent env < persona env < agent env (last wins on
//! key collision). See `runtime::spawn_agent_child`.
//!
//! A small set of *reserved* keys — Buzz's identity and secrets — are
//! rejected at save time and stripped at runtime so a typo or malicious
//! value can't swap the agent's nsec. Behavior knobs (GOOSE_MODE, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain
//! freely overridable — those have dedicated UI fields, but power users
//! may want to bypass them.
//! A small set of *reserved* keys — Buzz's identity and secrets, and
//! control-plane values set by the Desktop — are rejected at save time and
//! stripped at runtime so a typo or malicious value can't swap the agent's
//! nsec or bypass a harness-specific execution cap. Behavior knobs
//! (GOOSE_MODE, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain freely
//! overridable — those have dedicated UI fields, but power users may want
//! to bypass them.
//!
//! `BUZZ_ACP_AGENTS` is reserved because the Desktop resolves the effective
//! parallelism (applying per-harness caps such as OpenClaw's cap of 5) and
//! writes the result into `launch.policy_env`. A user-supplied
//! `BUZZ_ACP_AGENTS` would bypass the cap and cause OpenClaw agents to spawn
//! uncapped workers against their single shared Gateway daemon.
use std::collections::BTreeMap;
@@ -40,7 +48,7 @@ pub(crate) fn is_derived_provider_model_key(key: &str) -> bool {
}
/// Env var keys that Buzz sets itself and users must not override from
/// the persona/agent env_vars UI. Three categories:
/// the persona/agent env_vars UI. Four categories:
///
/// 1. **Identity / secrets** — overriding would swap the agent's nsec or
/// leak credentials.
@@ -50,11 +58,15 @@ pub(crate) fn is_derived_provider_model_key(key: &str) -> bool {
/// relay URL would silently break the saved security settings (the UI
/// shows owner-only while the running agent answers anyone, for
/// example), or redirect the agent to an attacker-controlled relay.
/// 4. **Control-plane execution policy** — the Desktop owns the effective
/// value, derived from structured record fields after applying per-harness
/// caps. A user-supplied override would bypass the cap and produce a
/// worker pool size that neither the record nor the UI represents.
///
/// This list is deliberately narrow — it only covers keys with security
/// implications. Behavior knobs (GOOSE_MODE, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain freely
/// overridable; those have dedicated UI fields but power users may want
/// to bypass them.
/// This list is deliberately narrow — it only covers keys with security or
/// correctness implications. Behavior knobs (GOOSE_MODE, BUZZ_ACP_MODEL,
/// BUZZ_ACP_SYSTEM_PROMPT, …) remain freely overridable; those have
/// dedicated UI fields but power users may want to bypass them.
pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[
// Identity / secrets.
"BUZZ_PRIVATE_KEY",
@@ -71,6 +83,11 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[
"BUZZ_ACP_AGENT_COMMAND",
"BUZZ_ACP_AGENT_ARGS",
"BUZZ_ACP_MCP_COMMAND",
// Control-plane parallelism: the Desktop resolves the effective
// worker-pool size (applying any per-harness cap) and writes it into
// launch.policy_env. A user-supplied BUZZ_ACP_AGENTS would bypass the
// harness cap and cause OpenClaw agents to spawn uncapped workers.
"BUZZ_ACP_AGENTS",
// Security gates: respond-to mode + allowlist + legacy owner-only
// fallback. Overriding would make the running agent's gate diverge
// from the saved/UI-visible settings.
@@ -16,6 +16,7 @@ pub(crate) mod git_bash;
pub(crate) mod global_config;
mod managed_node_paths;
mod nest;
pub(crate) mod parallelism;
mod persona_avatars;
pub(crate) mod persona_events;
mod personas;
@@ -59,6 +60,7 @@ pub(crate) use global_config::{
};
pub(crate) use managed_node_paths::*;
pub use nest::*;
pub use parallelism::{acp_agents_value, effective_parallelism, harness_max_parallelism};
pub use personas::*;
#[cfg(windows)]
pub use process_lifecycle::*;
@@ -0,0 +1,305 @@
// ── Per-harness parallelism cap ───────────────────────────────────────────────
//
// Contract: stored = requested; effective = min(requested, harness cap).
//
// `ManagedAgentRecord.parallelism` stores the user's requested value verbatim,
// never clamped at persistence. The cap is applied only where the value
// becomes a running worker-pool size:
//
// * local spawn — `BUZZ_ACP_AGENTS` in the child environment
// * remote deploy — `launch.policy_env["BUZZ_ACP_AGENTS"]` + legacy field
// * restart hash — `SpawnConfigSnapshot` stores the effective value
// * display copy — the UI derives effective for explanatory hints only
//
// `AgentDefinition.parallelism` is the portable requested value, unchanged
// at every boundary so it travels across devices and harness switches intact.
/// Maximum parallelism for the OpenClaw harness.
///
/// Each buzz-acp worker spawned by the Desktop is a client of the single
/// shared OpenClaw Gateway daemon — running more than this number of workers
/// is both resource-expensive and architecturally wrong per the OpenClaw
/// design. Tyler's ruling: "try 5 and lower if needed."
pub const OPENCLAW_MAX_PARALLELISM: u32 = 5;
/// Return the maximum allowed parallelism for the given harness command, or
/// `None` when the harness has no cap.
///
/// Keyed on [`super::discovery::normalize_command_identity`] so path prefixes,
/// the `.exe` suffix on Windows, and other cosmetic differences are ignored.
pub fn harness_max_parallelism(command: &str) -> Option<u32> {
match super::discovery::normalize_command_identity(command).as_str() {
"openclaw" => Some(OPENCLAW_MAX_PARALLELISM),
_ => None,
}
}
/// Return the effective parallelism for the given harness command and
/// requested value: `min(value, harness_max_parallelism(command))`.
///
/// For harnesses without a cap this is the identity function.
pub fn effective_parallelism(command: &str, value: u32) -> u32 {
match harness_max_parallelism(command) {
Some(cap) => value.min(cap),
None => value,
}
}
/// Return the value to emit as `BUZZ_ACP_AGENTS` for a spawn command.
///
/// Pure helper extracted from `spawn_agent_child` so both the production path
/// and tests can call it without spawning a process. The result is
/// `effective_parallelism(effective_command, record_parallelism)` formatted as
/// a decimal string ready for `command.env("BUZZ_ACP_AGENTS", …)`.
///
/// `effective_command` must be the already-resolved harness command (override →
/// runtime → persona runtime → default).
pub fn acp_agents_value(effective_command: &str, record_parallelism: u32) -> String {
effective_parallelism(effective_command, record_parallelism).to_string()
}
#[cfg(test)]
mod tests {
use crate::managed_agents::types::ManagedAgentRecord;
fn record_with(runtime: Option<&str>, parallelism: u32) -> ManagedAgentRecord {
ManagedAgentRecord {
pubkey: String::new(),
name: "r".to_string(),
persona_id: None,
private_key_nsec: String::new(),
auth_tag: None,
relay_url: String::new(),
avatar_url: None,
acp_command: String::new(),
agent_command: String::new(),
agent_command_override: None,
agent_args: vec![],
mcp_command: String::new(),
turn_timeout_seconds: 0,
idle_timeout_seconds: None,
max_turn_duration_seconds: None,
parallelism,
system_prompt: None,
model: None,
provider: None,
persona_source_version: None,
start_on_app_launch: false,
auto_restart_on_config_change: true,
runtime_pid: None,
backend: Default::default(),
backend_agent_id: None,
provider_binary_path: None,
team_id: None,
persona_team_dir: None,
persona_name_in_team: None,
env_vars: std::collections::BTreeMap::new(),
created_at: String::new(),
updated_at: String::new(),
last_started_at: None,
last_stopped_at: None,
last_exit_code: None,
last_error: None,
last_error_code: None,
respond_to: Default::default(),
respond_to_allowlist: vec![],
display_name: None,
slug: None,
runtime: runtime.map(str::to_string),
name_pool: Vec::new(),
is_builtin: false,
is_active: true,
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
definition_respond_to: None,
definition_respond_to_allowlist: Vec::new(),
definition_parallelism: None,
relay_mesh: None,
}
}
fn persona_def(
id: &str,
runtime: Option<&str>,
) -> crate::managed_agents::types::AgentDefinition {
use crate::managed_agents::types::AgentDefinition;
AgentDefinition {
id: id.to_string(),
display_name: String::new(),
avatar_url: None,
system_prompt: String::new(),
runtime: runtime.map(str::to_string),
model: None,
provider: None,
name_pool: vec![],
is_builtin: false,
is_active: true,
shared: false,
source_team: None,
source_team_persona_slug: None,
catalog_source: None,
env_vars: std::collections::BTreeMap::new(),
respond_to: None,
respond_to_allowlist: vec![],
parallelism: None,
created_at: String::new(),
updated_at: String::new(),
}
}
// ── Policy table: harness_max_parallelism / effective_parallelism ─────────
#[test]
fn policy_table() {
let cap = super::OPENCLAW_MAX_PARALLELISM;
// harness_max_parallelism: openclaw variants → Some(cap); others → None.
assert_eq!(super::harness_max_parallelism("openclaw"), Some(cap));
assert_eq!(
super::harness_max_parallelism("/usr/local/bin/openclaw"),
Some(cap)
);
assert_eq!(super::harness_max_parallelism("openclaw.exe"), Some(cap));
assert_eq!(
super::harness_max_parallelism(r"C:\Tools\openclaw.exe"),
Some(cap)
);
assert_eq!(super::harness_max_parallelism("goose"), None);
assert_eq!(super::harness_max_parallelism("buzz-agent"), None);
assert_eq!(super::harness_max_parallelism(""), None);
// effective_parallelism: openclaw clamps above cap, honors at/below; goose passes through.
assert_eq!(super::effective_parallelism("openclaw", cap + 5), cap);
assert_eq!(super::effective_parallelism("openclaw", cap), cap);
assert_eq!(super::effective_parallelism("openclaw", cap - 2), cap - 2);
assert_eq!(super::effective_parallelism("goose", 99), 99);
assert_eq!(super::effective_parallelism("buzz-agent", 32), 32);
}
// ── acp_agents_value: spawn-env seam ──────────────────────────────────────
//
// Drives the pure helper extracted from spawn_agent_child.
// Deleting or changing it breaks this test AND the production spawn env.
/// Legacy OpenClaw record (parallelism 10, above cap): BUZZ_ACP_AGENTS must be "5".
#[test]
fn acp_agents_value_openclaw_above_cap_is_capped() {
assert_eq!(
super::acp_agents_value("openclaw", 10),
"5",
"BUZZ_ACP_AGENTS for openclaw with parallelism 10 must be \"5\""
);
assert_eq!(super::acp_agents_value("goose", 10), "10");
}
// ── Override-direction: summary seam agreement ────────────────────────────
//
// Tests effective_parallelism and record_agent_command agreement for both
// override directions. Removing either direction loses the seam test for
// that cap/uncap path through the summary resolver.
/// OpenClaw runtime + Goose override: summary resolves goose → uncapped (10).
#[test]
fn override_direction_openclaw_runtime_goose_override_is_uncapped() {
let mut record = record_with(Some("openclaw"), 10);
record.agent_command_override = Some("goose".to_string());
let cmd = crate::managed_agents::record_agent_command(&record, &[]);
assert_eq!(cmd, "goose");
assert_eq!(super::effective_parallelism(&cmd, record.parallelism), 10);
}
/// Goose runtime + OpenClaw override: summary resolves openclaw → capped (5).
#[test]
fn override_direction_goose_runtime_openclaw_override_is_capped() {
let mut record = record_with(Some("goose"), 10);
record.agent_command_override = Some("openclaw".to_string());
let cmd = crate::managed_agents::record_agent_command(&record, &[]);
assert_eq!(cmd, "openclaw");
assert_eq!(
super::effective_parallelism(&cmd, record.parallelism),
super::OPENCLAW_MAX_PARALLELISM
);
}
// ── Summary: persona-inherited runtime (runtime=None) ─────────────────────
//
// Covers the case where runtime was cleared by an "inherit from persona"
// update: summary must resolve via the LIVE persona, not stale agent_command.
/// Stale agent_command="openclaw", live persona=goose → summary resolves goose → uncapped.
#[test]
fn summary_persona_inherited_stale_openclaw_live_goose_is_uncapped() {
let persona = persona_def("p-goose", Some("goose"));
let mut record = record_with(None, 10);
record.persona_id = Some("p-goose".to_string());
record.agent_command = "openclaw".to_string();
let cmd =
crate::managed_agents::record_agent_command(&record, std::slice::from_ref(&persona));
assert_eq!(
cmd, "goose",
"live persona must win over stale agent_command"
);
assert_eq!(super::effective_parallelism(&cmd, record.parallelism), 10);
}
/// Stale agent_command="goose", live persona=openclaw → summary resolves openclaw → capped.
#[test]
fn summary_persona_inherited_stale_goose_live_openclaw_is_capped() {
let persona = persona_def("p-openclaw", Some("openclaw"));
let mut record = record_with(None, 10);
record.persona_id = Some("p-openclaw".to_string());
record.agent_command = "goose".to_string();
let cmd =
crate::managed_agents::record_agent_command(&record, std::slice::from_ref(&persona));
assert_eq!(
cmd, "openclaw",
"live persona must win over stale agent_command"
);
assert_eq!(
super::effective_parallelism(&cmd, record.parallelism),
super::OPENCLAW_MAX_PARALLELISM
);
}
// ── Snapshot export: requested-definition / effective-instance contract ───
fn snapshot_record(
runtime: Option<&str>,
parallelism: u32,
definition_parallelism: Option<u32>,
) -> ManagedAgentRecord {
use crate::managed_agents::types::{BackendKind, RespondTo};
use std::collections::BTreeMap;
let mut r = record_with(runtime, parallelism);
r.name = "snap-test".to_string();
r.definition_parallelism = definition_parallelism;
r.backend = BackendKind::Local;
r.respond_to = RespondTo::OwnerOnly;
r.env_vars = BTreeMap::new();
r
}
/// Snapshot export carries the requested definition parallelism verbatim.
#[test]
fn snapshot_export_carries_requested_definition_parallelism() {
use crate::managed_agents::agent_snapshot::{build_snapshot, MemoryLevel};
// definition_parallelism=Some(10) stored → exported as 10 unchanged.
let snap = build_snapshot(
&snapshot_record(Some("openclaw"), 10, Some(10)),
MemoryLevel::None,
vec![],
None,
);
assert_eq!(snap.definition.parallelism, Some(10));
// No definition_parallelism stored → falls back to record.parallelism.
let snap2 = build_snapshot(
&snapshot_record(Some("openclaw"), 10, None),
MemoryLevel::None,
vec![],
None,
);
assert_eq!(snap2.definition.parallelism, Some(10));
}
}
@@ -99,3 +99,53 @@ fn apply_persona_snapshot_same_harness_path_pin_is_kept() {
"same-harness path override must NOT be dropped"
);
}
// ── Stale-pin drop: builtin pin → loaded custom harness (tier-1→tier-3) ──────
/// Persona→CustomHarness: stale Goose override dropped.
///
/// This is the custom-direction regression: before `canonical_harness_command`
/// the destination lookup (`known_acp_runtime_exact`) only saw the four
/// tier-1 builtins, so a switch to a loaded custom harness left any stale
/// builtin pin authoritative.
///
/// Tier-3 (loaded custom harness) is reached via `lookup_loaded_harness_by_id`,
/// which reads the in-process registry — so we must populate it via
/// `update_loaded_harness_registry` under `registry_test_lock()`.
#[test]
fn apply_persona_snapshot_goose_to_custom_harness_drops_stale_goose_pin() {
use crate::managed_agents::custom_harnesses::{
registry_test_lock, update_loaded_harness_registry, HarnessDefinition,
};
use std::collections::BTreeMap;
let _lock = registry_test_lock();
// Register a custom harness definition so the resolver finds it at tier 3.
update_loaded_harness_registry(vec![HarnessDefinition {
id: "my-custom-harness".to_string(),
label: "My Custom Harness".to_string(),
command: "my-custom-bin".to_string(),
args: vec![],
env: BTreeMap::new(),
install_instructions_url: String::new(),
install_hint: String::new(),
}]);
let mut record = sample_record();
record.agent_command_override = Some("goose".to_string());
apply_persona_snapshot(
&mut record,
&AgentDefinition {
runtime: Some("my-custom-harness".to_string()),
..sample_persona()
},
);
assert_eq!(
record.agent_command_override, None,
"stale goose pin must be dropped when persona switches to a loaded custom harness"
);
// Clean up the registry so parallel tests start from a known state.
update_loaded_harness_registry(vec![]);
}
@@ -703,12 +703,9 @@ pub fn spawn_agent_child(
);
}
}
// Only emit BUZZ_ACP_IDLE_TIMEOUT when the user has explicitly set an
// override. When unset, the buzz-acp harness applies its own default
// (see `DEFAULT_IDLE_TIMEOUT_SECS` in crates/buzz-acp/src/config.rs),
// which is the single source of truth. The previously-emitted
// `BUZZ_ACP_TURN_TIMEOUT` is deprecated upstream and was pinning every
// agent to the desktop's stale default (320s), bypassing harness bumps.
// Emit BUZZ_ACP_IDLE_TIMEOUT only when explicitly set; the harness
// DEFAULT_IDLE_TIMEOUT_SECS is the single source of truth. The deprecated
// BUZZ_ACP_TURN_TIMEOUT pinned agents to a stale default (320s).
if let Some(idle) = record.idle_timeout_seconds {
command.env("BUZZ_ACP_IDLE_TIMEOUT", idle.to_string());
}
@@ -716,7 +713,8 @@ pub fn spawn_agent_child(
if let Some(max_dur) = record.max_turn_duration_seconds {
command.env("BUZZ_ACP_MAX_TURN_DURATION", max_dur.to_string());
}
command.env("BUZZ_ACP_AGENTS", record.parallelism.to_string());
let acp_n = super::acp_agents_value(effective_command, record.parallelism);
command.env("BUZZ_ACP_AGENTS", acp_n);
command.env("BUZZ_ACP_MULTIPLE_EVENT_HANDLING", "steer");
command.env("BUZZ_ACP_DEDUP", "queue");
if let Some(meta) = runtime_meta {
@@ -167,7 +167,13 @@ impl SpawnConfigSnapshot {
),
idle_timeout_seconds: record.idle_timeout_seconds,
max_turn_duration_seconds: record.max_turn_duration_seconds,
parallelism: record.parallelism,
// Hash the effective parallelism so over-cap edits that don't change
// the running pool size (e.g. 10 → 8, both clamp to 5 on OpenClaw)
// do not raise a spurious "restart required" badge. Cap crossings
// (e.g. 8 → 3, where 3 is below the cap) do change the effective
// pool and must badge. The diff surface consequently displays the
// effective value — that is correct, it is what actually runs.
parallelism: super::effective_parallelism(&descriptor.command, record.parallelism),
}
}
@@ -778,3 +778,52 @@ fn spawn_snapshot_instance_args_win_over_definition_args() {
"instance args and definition args must produce different snapshots"
);
}
// ── Parallelism cap: above-cap equivalence + cap crossing ─────────────────────
//
// The snapshot stores the *effective* parallelism (min(requested, harness cap))
// so that over-cap edits that don't change the running pool size do not raise a
// spurious "restart required" badge, while cap crossings (e.g. 8 → 3, where 3
// is below the cap) still badge because the pool actually changes.
/// Two over-cap parallelism values (10 and 8) produce the same snapshot for
/// OpenClaw: both clamp to OPENCLAW_MAX_PARALLELISM (5).
#[test]
fn openclaw_above_cap_parallelism_snapshots_equal() {
let mut at_10 = record();
at_10.runtime = Some("openclaw".into());
at_10.agent_command = "openclaw".into();
at_10.parallelism = 10;
let mut at_8 = record();
at_8.runtime = Some("openclaw".into());
at_8.agent_command = "openclaw".into();
at_8.parallelism = 8;
assert_eq!(
snapshot(&at_10, &[], &[], "wss://ws.example", &Default::default()),
snapshot(&at_8, &[], &[], "wss://ws.example", &Default::default()),
"parallelism 10 and 8 both clamp to 5 for OpenClaw — snapshots must be equal, no restart badge"
);
}
/// A cap-crossing edit (8 → 3) produces different snapshots: 8 clamps to 5,
/// but 3 is below the cap and runs as 3 — the pool changes, so the badge fires.
#[test]
fn openclaw_cap_crossing_parallelism_snapshots_differ() {
let mut at_8 = record();
at_8.runtime = Some("openclaw".into());
at_8.agent_command = "openclaw".into();
at_8.parallelism = 8;
let mut at_3 = record();
at_3.runtime = Some("openclaw".into());
at_3.agent_command = "openclaw".into();
at_3.parallelism = 3;
assert_ne!(
snapshot(&at_8, &[], &[], "wss://ws.example", &Default::default()),
snapshot(&at_3, &[], &[], "wss://ws.example", &Default::default()),
"parallelism 8 (clamps to 5) and 3 (runs as 3) must produce different snapshots"
);
}
@@ -663,16 +663,14 @@ pub struct AcpRuntimeCatalogEntry {
/// Whether this entry came from the compiled-in catalog or a user-supplied
/// JSON file in `custom_harnesses/`. The UI uses this to decide editability.
pub source: HarnessSource,
/// Definition-level environment variables for `source: custom` entries.
///
/// Populated from `HarnessDefinition.env` so the edit form can read them
/// back and the user doesn't silently lose env vars when saving. Always
/// empty for `builtin` and `preset` entries (those env values come from the
/// runtime metadata path, not user-editable JSON).
///
/// Skipped in serialization when empty to keep the catalog payload compact.
/// Definition-level env vars for `source: custom` entries; populated from
/// `HarnessDefinition.env` so saves don't silently erase existing vars.
/// Absent for builtin/preset entries. Skipped when empty in serialization.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub definition_env: BTreeMap<String, String>,
/// Spawn-time parallelism cap; absent for uncapped harnesses.
#[serde(skip_serializing_if = "Option::is_none")]
pub max_parallelism: Option<u32>,
}
/// Result of a single install step (CLI or adapter).
+12
View File
@@ -19,6 +19,18 @@ never maintains a rival copy of this table. Setup guidance follows the same
rule: `requires_external_cli` is derived from `KnownAcpRuntime` and projected
to the UI rather than inferred from a runtime ID in a component.
**Second metadata source: command-keyed execution policy.**
`harness_max_parallelism` (`managed_agents/parallelism.rs`) maps the harness's
static command string to a spawn-time cap (`OPENCLAW_MAX_PARALLELISM = 5` for
OpenClaw). This cap is not a `KnownAcpRuntime` field because it applies to
preset harnesses (like OpenClaw) that are not in the builtin catalog. It is
projected onto `AcpRuntimeCatalogEntry.max_parallelism` by all four
catalog-producing constructors (builtin discovery, preset catalog, custom
discovery, custom-save response) using the **static definition command**, not
the resolved `entry.command` (which may be `null` for unavailable entries).
The frontend reads `maxParallelism` from the catalog entry and never keeps a
separate constant.
If you need a new capability fact (a new env key, a native option, a "supports
X" flag): add it to `KnownAcpRuntime` first, expose it on
`AcpRuntimeCatalogEntry`, then project it through the core. Do not shortcut
@@ -4,6 +4,7 @@ import test from "node:test";
import {
DEFAULT_AGENT_PARALLELISM,
resolveAgentParallelism,
parallelismCapHint,
} from "./agentParallelism.ts";
test("parallelism uses the app default only when input and definition omit it", () => {
@@ -18,3 +19,38 @@ test("parallelism uses the app default only when input and definition omit it",
assert.equal(resolveAgentParallelism(undefined, 4), 4);
assert.equal(resolveAgentParallelism(2, 4), 2);
});
// ── parallelismCapHint: persona/instance hint data path ───────────────────────
test("parallelismCapHint returns null when requested is at or below the cap", () => {
assert.equal(parallelismCapHint("OpenClaw", 5, 5), null);
assert.equal(parallelismCapHint("OpenClaw", 5, 3), null);
assert.equal(parallelismCapHint("OpenClaw", 5, 1), null);
});
test("parallelismCapHint returns hint string when requested exceeds cap", () => {
const hint = parallelismCapHint("OpenClaw", 5, 10);
assert.ok(hint !== null, "hint must be non-null when 10 > 5");
assert.ok(hint.includes("OpenClaw"), "hint must include the harness label");
assert.ok(hint.includes("5"), "hint must include the cap value");
});
test("parallelismCapHint uses singular form for cap of 1", () => {
const hint = parallelismCapHint("SomeHarness", 1, 5);
assert.ok(hint !== null);
assert.ok(
hint.includes("conversation") && !hint.includes("conversations"),
"cap=1 must use singular 'conversation'",
);
});
// Stored-10 OpenClaw → Goose: hint clears when the harness has no cap.
// The UI derives: if selectedRuntime.maxParallelism is undefined, hint is null.
// This test validates the helper contract that makes that work.
test("parallelismCapHint returns null when cap equals or exceeds any common parallelism value", () => {
// Simulates an uncapped harness: the caller passes a very large cap
// OR simply doesn't call parallelismCapHint at all (guarded by maxParallelism check).
// When stored=10 and harness switches to goose (no cap), no hint is shown.
assert.equal(parallelismCapHint("Goose", 32, 10), null);
assert.equal(parallelismCapHint("Goose", 32, 32), null);
});
@@ -16,3 +16,24 @@ export function resolveAgentParallelism(
): number {
return input ?? definition ?? DEFAULT_AGENT_PARALLELISM;
}
/**
* Return an explanatory hint string when a harness cap would reduce the
* requested parallelism, or `null` when the value is within the cap.
*
* The hint carries both facts: what was requested and what will run, so users
* understand the effective value without needing to look elsewhere.
*
* @param harnessLabel - Human-readable harness name (e.g. "OpenClaw").
* @param cap - The harness's maximum parallelism (from catalog maxParallelism).
* @param requested - The user's requested parallelism value (132).
* @returns A hint string, or null when requested <= cap.
*/
export function parallelismCapHint(
harnessLabel: string,
cap: number,
requested: number,
): string | null {
if (requested <= cap) return null;
return `${harnessLabel} runs at most ${cap} parallel conversation${cap === 1 ? "" : "s"} — this agent will run ${cap}.`;
}
@@ -19,7 +19,10 @@ import {
isBuzzAgentRuntime,
BUZZ_AGENT_THINKING_EFFORT,
} from "./buzzAgentConfig";
import { EDIT_AGENT_PARALLELISM_HELP } from "../lib/agentParallelism";
import {
EDIT_AGENT_PARALLELISM_HELP,
parallelismCapHint,
} from "../lib/agentParallelism";
import {
deriveNumericDescriptors,
structuredEnvKeys,
@@ -128,6 +131,22 @@ export function EditAgentAdvancedFields({
[hiddenEnvKeys, modelTuningRuntimeId, numericDescriptors],
);
// Harness cap hint: show only when the selected runtime has a cap and the
// current parallelism value exceeds it. Cap and label come from the catalog
// entry — no hardcoded constant in TS.
const parallelismHint = React.useMemo(() => {
if (selectedRuntime?.maxParallelism === undefined || parallelism === "") {
return null;
}
const requested = parseInt(parallelism, 10);
if (Number.isNaN(requested)) return null;
return parallelismCapHint(
selectedRuntime.label,
selectedRuntime.maxParallelism,
requested,
);
}, [selectedRuntime, parallelism]);
return (
<div className="space-y-5 pt-2">
{/* Inherit runtime from template */}
@@ -238,6 +257,11 @@ export function EditAgentAdvancedFields({
<p className="text-xs text-muted-foreground">
{EDIT_AGENT_PARALLELISM_HELP}
</p>
{parallelismHint !== null ? (
<p className="text-xs text-amber-600 dark:text-amber-400">
{parallelismHint}
</p>
) : null}
</div>
{/* Relay URL: intentionally no editor. The legacy per-record relay pin
@@ -11,6 +11,7 @@ import {
import {
AGENT_PARALLELISM_HELP,
AGENT_PARALLELISM_PLACEHOLDER,
parallelismCapHint,
} from "../lib/agentParallelism";
import {
BuzzAgentModelTuningFields,
@@ -102,6 +103,26 @@ export function PersonaAdvancedFields({
],
[hiddenEnvKeys, modelTuningRuntimeId, numericDescriptors],
);
// Persona hint: definitions keep a portable requested value across harnesses.
// When the selected harness has a cap and the draft's parallelism exceeds it,
// explain that the agent will run at the cap — without clamping the stored value.
const personaParallelismHint = React.useMemo(() => {
if (
selectedRuntime?.maxParallelism === undefined ||
behaviorDraft.parallelism === ""
) {
return null;
}
const requested = parseInt(behaviorDraft.parallelism, 10);
if (Number.isNaN(requested)) return null;
return parallelismCapHint(
selectedRuntime.label,
selectedRuntime.maxParallelism,
requested,
);
}, [selectedRuntime, behaviorDraft.parallelism]);
return (
<div className="space-y-5 pt-2">
<CreateAgentRespondToField
@@ -159,6 +180,11 @@ export function PersonaAdvancedFields({
<p className="text-xs text-muted-foreground">
{AGENT_PARALLELISM_HELP}
</p>
{personaParallelismHint !== null ? (
<p className="text-xs text-amber-600 dark:text-amber-400">
{personaParallelismHint}
</p>
) : null}
</div>
</div>
+46
View File
@@ -211,6 +211,52 @@ test("fromRawAcpRuntimeCatalogEntry env round-trips through edit payload shape",
);
});
// ── max_parallelism → maxParallelism mapping ──────────────────────────────────
test("fromRawAcpRuntimeCatalogEntry maps max_parallelism to maxParallelism when present", () => {
const raw = {
id: "openclaw",
label: "OpenClaw",
availability: "not_installed",
command: null,
source: "preset",
default_args: [],
can_auto_install: false,
requires_external_cli: false,
install_hint: "",
install_instructions_url: "",
max_parallelism: 5,
};
const entry = fromRawAcpRuntimeCatalogEntry(raw);
assert.equal(
entry.maxParallelism,
5,
"max_parallelism: 5 must map to maxParallelism: 5",
);
});
test("fromRawAcpRuntimeCatalogEntry omits maxParallelism when max_parallelism is absent", () => {
const raw = {
id: "goose",
label: "Goose",
availability: "available",
command: "goose",
source: "builtin",
default_args: [],
can_auto_install: false,
requires_external_cli: false,
install_hint: "",
install_instructions_url: "",
// No max_parallelism field — uncapped harness.
};
const entry = fromRawAcpRuntimeCatalogEntry(raw);
assert.equal(
entry.maxParallelism,
undefined,
"uncapped harness must have maxParallelism: undefined",
);
});
// ── Teardown ──────────────────────────────────────────────────────────────────
test("teardown — restore Date.now", () => {
+4 -4
View File
@@ -62,8 +62,6 @@ type RawFeedItem = {
created_at: number;
channel_id: string | null;
channel_name: string;
// Native FeedItemInfo.channel_type is Option<String>: serde emits `null`,
// never omits the key.
channel_type: string | null;
tags: string[][];
category: "mention" | "needs_action" | "activity" | "agent_activity";
@@ -206,6 +204,7 @@ export type RawAcpRuntimeCatalogEntry = {
source: "builtin" | "preset" | "custom";
/** Definition-level env vars for `source: custom` entries; absent for builtin/preset. */
definition_env?: Record<string, string>;
max_parallelism?: number;
};
export type {
@@ -761,9 +760,10 @@ export function fromRawAcpRuntimeCatalogEntry(
authStatus: entry.auth_status,
loginHint: entry.login_hint ?? null,
source: entry.source,
// Map definition_env (snake_case from Rust) to definitionEnv (camelCase).
// Absent when empty (Rust serialization skips empty BTreeMap) — default to {}.
definitionEnv: entry.definition_env ?? {},
...(entry.max_parallelism !== undefined && {
maxParallelism: entry.max_parallelism,
}),
};
}
+5 -5
View File
@@ -535,13 +535,13 @@ export type AcpRuntimeCatalogEntry = {
/** "builtin" (compiled in), "preset" (PATH-probed, not editable), or "custom" (user JSON). Controls UI editability. */
source: "builtin" | "preset" | "custom";
/**
* Definition-level environment variables for `source: custom` entries.
*
* Populated by the backend from `HarnessDefinition.env` so the edit form can
* read them back without losing existing env vars on save. Always absent/empty
* for `builtin` and `preset` entries.
* Definition-level env vars for `source: custom` entries. Populated from
* `HarnessDefinition.env` so saves don't erase existing vars. Absent for
* builtin/preset entries.
*/
definitionEnv?: Record<string, string>;
/** Spawn-time parallelism cap; absent for uncapped harnesses. */
maxParallelism?: number;
};
/** An AcpRuntimeCatalogEntry that is confirmed available — command and binaryPath are non-null. */
+23 -14
View File
@@ -543,14 +543,17 @@ mis-tiered (below):
`BUZZ_ACP_MAX_TURN_DURATION`, `BUZZ_ACP_AGENTS` — resolved by the desktop
from the record's `system_prompt` / `idle_timeout_seconds` /
`max_turn_duration_seconds` / `parallelism` (each omitted when null,
matching the local spawn's conditional emission). These are **tier-1 by
local fact, not by choice**: the local spawn writes them before the user
env layer (`runtime.rs:716-729,763` vs `:860`) and none is in
`RESERVED_ENV_KEYS`, so a power user's env override beats them today. A
provider that independently mapped the top-level payload copies after
`launch.env` would invert that — the structured field silently defeating
an override that works locally — which is why the provider MUST NOT remap
them (§Entrypoint mapping table).
matching the local spawn's conditional emission). `BUZZ_ACP_AGENTS` is
the **effective** parallelism: `min(record.parallelism, harness_cap)`
where the cap is harness-specific (e.g. OpenClaw is capped at 5). These
are **tier-1 control-plane** keys: `BUZZ_ACP_AGENTS` is in
`RESERVED_ENV_KEYS` (`env_vars.rs`) so the desktop-resolved effective
value cannot be overridden by a definition env var; the others are
tier-1 by local fact (written before the user env layer). A provider
that independently mapped the top-level payload copies after `launch.env`
would invert the precedence for those remaining keys — the structured
field silently defeating an override that works locally — which is why
the provider MUST NOT remap them (§Entrypoint mapping table).
`BUZZ_ACP_DEDUP` and `BUZZ_ACP_MULTIPLE_EVENT_HANDLING` are **deliberately
unset**: the local spawn writes `queue`/`steer` (`runtime.rs:730-731`), and
@@ -564,11 +567,17 @@ process to sweep.
**Environment precedence (normative) — three tiers, later wins:**
1. **Overridable behavior defaults**`launch.policy_env`. These keys are
deliberately non-reserved (`env_vars.rs:54-57` says so outright: power
users may bypass the dedicated UI fields), and locally the user env is
written after them (`runtime.rs:860` and its comment). A policy-wins
order here would make remote agents ignore overrides local agents honor.
1. **Overridable behavior defaults**`launch.policy_env`. Most keys here
are deliberately non-reserved (`env_vars.rs` documents the narrow set
that IS reserved): power users may bypass the dedicated UI fields for
system prompt, model, idle timeout, etc. Locally the user env is written
after them (`runtime.rs:860` and its comment). A policy-wins order here
would make remote agents ignore overrides local agents honor.
**Exception — `BUZZ_ACP_AGENTS`:** this key IS reserved
(`env_vars.rs:RESERVED_ENV_KEYS`) so the desktop-controlled effective
parallelism (applying any per-harness cap) cannot be bypassed by a
user-supplied definition env var. The reserved-key strip removes any
user copy before serialization, so the tier-1 value survives.
2. **User/layered env**`launch.env`. User `env_vars` need no separate
slot: the descriptor's layering already merged them (global < persona <
agent), so a provider applies `launch.env` and MUST NOT re-merge the
@@ -1081,7 +1090,7 @@ individually:
| `launch.args` | `BUZZ_ACP_AGENT_ARGS`, comma-joined |
| `launch.env`, `launch.policy_env` | verbatim, at their precedence tiers |
| generation token (§K8s Secrets) | `BUZZ_MANAGED_AGENT_START_NONCE` — the lifecycle-frame correlator and the Secret generation are one identity (§Launch data tier 3) |
| `system_prompt`, `idle_timeout_seconds`, `max_turn_duration_seconds`, `parallelism` | **not mapped by the provider** — the desktop resolves these into `launch.policy_env` (`BUZZ_ACP_SYSTEM_PROMPT`, `BUZZ_ACP_IDLE_TIMEOUT`, `BUZZ_ACP_MAX_TURN_DURATION`, `BUZZ_ACP_AGENTS`), because they are tier-1 behavior knobs: locally they are written *before* the user env layer and none is reserved (`runtime.rs:716-729,763` vs `:860`; `env_vars.rs:54-57`), so user env beats them. A provider that mapped the top-level copies after `launch.env` would silently defeat an override that works locally. The top-level fields remain as display/bookkeeping inputs only |
| `system_prompt`, `idle_timeout_seconds`, `max_turn_duration_seconds`, `parallelism` | **not mapped by the provider** — the desktop resolves these into `launch.policy_env` (`BUZZ_ACP_SYSTEM_PROMPT`, `BUZZ_ACP_IDLE_TIMEOUT`, `BUZZ_ACP_MAX_TURN_DURATION`, `BUZZ_ACP_AGENTS`). `BUZZ_ACP_AGENTS` carries the **effective** parallelism (`min(record.parallelism, harness_cap)`), is reserved (`env_vars.rs:RESERVED_ENV_KEYS`), and cannot be overridden by user env. The remaining knobs are tier-1 by local fact (written before user env); a provider that mapped the top-level copies after `launch.env` would silently defeat local overrides. The top-level fields remain as display/bookkeeping inputs only |
| `turn_timeout_seconds` | not mapped — deprecated upstream and ignored; the local spawn also does not emit it |
| `respond_to` | `BUZZ_ACP_RESPOND_TO` |
| `respond_to_allowlist` | `BUZZ_ACP_RESPOND_TO_ALLOWLIST`, comma-joined |