mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
6df7eba24d
commit
6c40ce394f
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 (1–32).
|
||||
* @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>
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user