mirror of
https://github.com/block/buzz.git
synced 2026-11-05 17:01:35 +00:00
ACP harnesses that name a session from the first text they receive all land in the same place: every managed Buzz agent opens with the identical `[Base] You are operating inside the Buzz platform…` framing, so the harness session list shows a wall of indistinguishable rows. Because sessions are keyed per channel, one agent active in several channels produces several of them. This sends the name out of band instead. `session/new` carries `_meta.sessionTitle` with `Agent · #channel`, composed from the agent's `display_name` (or its unique `name` handle) and the channel it is serving. The prompt is untouched — no tokens spent, no perturbation of the prompt contract, and nothing new for the desktop observer's section parsing to handle. The mechanism is harness-agnostic: Buzz sends the field on every ACP `session/new` regardless of which harness is behind it, and adapters that don't read it ignore it per spec. ## Inert until a consuming adapter ships ACP adapters ignore `_meta` members they do not recognize, so against an adapter with no reader a Buzz session gets no title and nothing else changes. Three adapter halves consume it — Codex, Goose, and Claude Code (linked below); this half and each reader are only useful together, and each reader lands independently. No version floor is added. `codex_adapter_is_outdated_with_path` already gates codex-acp on major version `>= 1` (`desktop/src-tauri/src/managed_agents/discovery.rs:1276-1284`) and this feature needs nothing above that — an older adapter is not broken by the extra member, it simply ignores it. ## What changes **`crates/buzz-acp`** owns sanitization and composition. `sanitize_session_title` collapses whitespace, drops control characters, and caps at `SESSION_TITLE_MAX_CHARS` (80) by character, not byte, so a multi-byte character cannot be split. `compose_session_title` truncates only the channel part against that cap, so the agent name always survives; when the agent name alone fills the cap the channel is dropped rather than the name. `session_new_full` sets `_meta.sessionTitle` when a title exists and omits `_meta` entirely when it does not, since an adapter may distinguish an absent member from a null one. **`desktop/src-tauri`** only resolves and exports. `resolve_session_title` picks `display_name` or falls back to `name`, and `spawn_agent_child` writes it to `BUZZ_ACP_SESSION_TITLE` — or removes the variable when neither candidate yields anything printable. DMs, unresolved channels, and heartbeat sessions get the bare agent name with no channel suffix. ## Four properties that are easy to remove by accident **Control characters are stripped at the desktop boundary, not in the harness.** An interior NUL cannot cross the environment boundary at all — `Command::env` fails the entire spawn rather than passing it through. Deferring the strip to `buzz-acp` would let a corrupted display name turn display chrome into a spawn failure. A display name that is *only* control characters falls back to `name`. **The title is hashed into `spawn_config_hash`.** Without it, renaming an agent left the running process with a stale title and no restart badge. The hash runs the same `resolve_session_title` the spawn writes, and skips it when a user env override shadows `BUZZ_ACP_SESSION_TITLE` — spawn writes the title *before* the layered user env, so the override is what actually runs, and it already reaches the hash through `descriptor.env`. Hashing the record-derived value under an override would badge a rename that changes nothing. **One channel resolve serves both consumers.** `resolve_new_session_channel_context` returns `(is_dm, title_channel)` from a single metadata lookup, feeding both the canvas block's DM check and the title. `ChannelInfoResolver` caches only `Some`, so two independent calls against an unresolvable channel pay the full `fetch_channel_info` retry sequence twice — two timeouts plus a retry delay each — directly in front of `session/new`, precisely when the relay is already degraded. **The `"unknown"` channel name is treated as absent.** `fetch_channel_info` substitutes the literal `"unknown"` for a metadata event with no `name` tag. Composing that sentinel would title every unnamed channel `Agent · #unknown`, reintroducing the exact collision the suffix exists to remove while naming a channel something it isn't. The startup cache already refuses `channel_type == "unknown"` for the same reason. Closes #2334 Related — the adapter halves that consume `_meta.sessionTitle`: - [codex-acp#338](https://github.com/agentclientprotocol/codex-acp/pull/338) — Codex - [aaif-goose/goose#10712](https://github.com/aaif-goose/goose/pull/10712) — Goose - [claude-agent-acp#920](https://github.com/agentclientprotocol/claude-agent-acp/pull/920) — Claude Code --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
161 lines
7.8 KiB
Rust
161 lines
7.8 KiB
Rust
//! Spawn-time config hash for the restart-required badge.
|
|
//!
|
|
//! [`spawn_config_hash`] digests the *effective spawned values* — what a
|
|
//! process launch of `record` would actually receive — so the UI can compare
|
|
//! a running process's hash (stamped on [`super::ManagedAgentProcess`] at
|
|
//! spawn) against a recomputation from current disk state and show a
|
|
//! "restart required" badge only when a restart would change what runs.
|
|
//!
|
|
//! Scope rules (decided in #centralize-personas-and-agents, revised in PR
|
|
//! #1602 review):
|
|
//! - Inputs mirror what a start would actually run: the start/restore paths
|
|
//! re-snapshot the linked persona's prompt/model/provider/env onto the
|
|
//! record immediately before spawning (`start_local_agent_with_preflight`,
|
|
//! `restore_managed_agents_on_launch`), so persona edits to those fields DO
|
|
//! apply on a plain restart and are hashed via the same prospective
|
|
//! re-snapshot. Harness command, args/mcp, env layering, and the record
|
|
//! fields the spawn env writes read are hashed as spawn resolves them.
|
|
//! - The relay URL is hashed in resolved form (`effective_agent_relay_url`):
|
|
//! every record spawns against the active workspace relay (legacy per-record
|
|
//! pins are ignored), so a workspace relay change means a restart would
|
|
//! change what runs.
|
|
//! - Channel membership is not an input: agents pick up channel changes live
|
|
//! (#1468), never via restart.
|
|
//!
|
|
//! The hash never crosses a process or persistence boundary, so
|
|
//! `DefaultHasher` (not stable across Rust releases) is sufficient.
|
|
|
|
use std::hash::{DefaultHasher, Hash, Hasher};
|
|
|
|
use super::{
|
|
effective_config::{resolve_effective_config, EffectiveConfigResult},
|
|
known_acp_runtime, normalize_agent_args,
|
|
persona_events::preview_prospective_persona_snapshot,
|
|
runtime::{resolve_session_title, SESSION_TITLE_ENV_VAR},
|
|
types::{AgentDefinition, ManagedAgentRecord, TeamRecord},
|
|
GlobalAgentConfig,
|
|
};
|
|
|
|
/// Resolve the current instructions for this instance's deployment-time team binding.
|
|
/// A deleted team deliberately degrades to no team section.
|
|
pub(crate) fn effective_team_instructions(
|
|
record: &ManagedAgentRecord,
|
|
teams: &[TeamRecord],
|
|
) -> Option<String> {
|
|
teams
|
|
.iter()
|
|
.find(|team| Some(team.id.as_str()) == record.team_id.as_deref())
|
|
.and_then(|team| team.instructions.as_deref())
|
|
.map(str::trim)
|
|
.filter(|instructions| !instructions.is_empty())
|
|
.map(str::to_string)
|
|
}
|
|
|
|
/// Digest the effective spawn configuration of `record` under the current
|
|
/// `personas`, resolving a blank record relay against `workspace_relay`.
|
|
/// Pure — no `AppHandle`, no disk, no keyring.
|
|
pub(crate) fn spawn_config_hash(
|
|
record: &ManagedAgentRecord,
|
|
personas: &[AgentDefinition],
|
|
teams: &[TeamRecord],
|
|
workspace_relay: &str,
|
|
global: &GlobalAgentConfig,
|
|
) -> u64 {
|
|
// Prospective re-snapshot: apply the same `apply_persona_snapshot` the
|
|
// start/restore paths run right before spawning, so the hash covers what a
|
|
// restart would actually run. Idempotent, so the spawn-time stamp
|
|
// (post-snapshot record) and later recomputes (persisted record) agree
|
|
// when nothing changed. The persona env itself reaches the hash through
|
|
// the descriptor's layered env below; `persona_source_version` is set on
|
|
// the clone but is not a hash input.
|
|
let record = preview_prospective_persona_snapshot(record, personas);
|
|
let record = &record;
|
|
|
|
// Resolve command, args, and env via the single typed descriptor — same path
|
|
// as spawn_agent_child. Dangling harness id falls back to the infallible
|
|
// record_agent_command (no-op: a dangling harness can't be spawned, so the
|
|
// hash never matters for that agent).
|
|
let descriptor =
|
|
crate::managed_agents::resolve_effective_harness_descriptor(record, personas, global)
|
|
.unwrap_or_else(|_| {
|
|
let cmd = crate::managed_agents::record_agent_command(record, personas);
|
|
let args = normalize_agent_args(&cmd, record.agent_args.clone());
|
|
crate::managed_agents::readiness::EffectiveHarnessDescriptor {
|
|
command: cmd,
|
|
args,
|
|
env: Default::default(),
|
|
}
|
|
});
|
|
let runtime_meta = known_acp_runtime(&descriptor.command);
|
|
|
|
let mut hasher = DefaultHasher::new();
|
|
|
|
// Harness identity and derivations (live-persona-resolved, like spawn).
|
|
record.acp_command.hash(&mut hasher);
|
|
descriptor.command.hash(&mut hasher);
|
|
descriptor.args.hash(&mut hasher);
|
|
runtime_meta
|
|
.and_then(|r| r.mcp_command)
|
|
.unwrap_or("")
|
|
.hash(&mut hasher);
|
|
|
|
// Effective env layering (baked floor → runtime metadata → definition env
|
|
// → global → persona → agent). BTreeMap iteration is ordered, deterministic.
|
|
descriptor.env.hash(&mut hasher);
|
|
|
|
// Record fields the spawn env writes read directly. The relay is hashed
|
|
// resolved: every record spawns on the workspace relay (legacy pins
|
|
// ignored), so a workspace relay change must trip the badge.
|
|
crate::relay::effective_agent_relay_url(&record.relay_url, workspace_relay).hash(&mut hasher);
|
|
// Team instructions use the same resolver as spawn.
|
|
effective_team_instructions(record, teams).hash(&mut hasher);
|
|
// Prompt, model, and provider all come from ONE `resolve_effective_config`
|
|
// call — the SAME resolve `spawn_agent_child` performs for the env write,
|
|
// so env write and this badge cannot disagree. An orphaned link (missing
|
|
// definition) hashes as if all three were absent: `spawn_agent_child`
|
|
// refuses to spawn an orphan regardless, so this is a display-only
|
|
// convenience, not the spawn gate.
|
|
let (resolved_prompt, resolved_model, resolved_provider) =
|
|
match resolve_effective_config(record, personas, global) {
|
|
EffectiveConfigResult::Resolved(cfg) => {
|
|
(cfg.system_prompt.value, cfg.model.value, cfg.provider.value)
|
|
}
|
|
EffectiveConfigResult::OrphanedInstance { .. } => (None, None, None),
|
|
};
|
|
resolved_prompt.hash(&mut hasher);
|
|
resolved_model.hash(&mut hasher);
|
|
resolved_provider.hash(&mut hasher);
|
|
// Session title: the same resolve `spawn_agent_child` performs for its env
|
|
// write, so a rename raises the restart badge. Skipped when a user env
|
|
// override shadows it — spawn writes the title BEFORE the user env layer,
|
|
// so the override is what actually runs, and it already reaches this hash
|
|
// through `descriptor.env` above. Hashing the record-derived value under an
|
|
// override would badge a rename that changes nothing.
|
|
let effective_session_title = (!descriptor.env.contains_key(SESSION_TITLE_ENV_VAR))
|
|
.then(|| resolve_session_title(record.display_name.as_deref(), &record.name))
|
|
.flatten();
|
|
effective_session_title.hash(&mut hasher);
|
|
record.auth_tag.hash(&mut hasher);
|
|
record.respond_to.as_str().hash(&mut hasher);
|
|
// The allowlist is hashed as the env receives it: spawn sets
|
|
// BUZZ_ACP_RESPOND_TO_ALLOWLIST only in allowlist mode, and normalized
|
|
// (trim/lowercase/dedup via `validate_respond_to_allowlist`) — so edits
|
|
// that don't survive normalization, or edits while another mode is
|
|
// active, must not badge. A list spawn would reject hashes raw: the
|
|
// stamped hash comes from a successful spawn, so any invalid edit
|
|
// correctly compares unequal.
|
|
if record.respond_to == super::types::RespondTo::Allowlist {
|
|
super::types::validate_respond_to_allowlist(&record.respond_to_allowlist)
|
|
.unwrap_or_else(|_| record.respond_to_allowlist.clone())
|
|
.hash(&mut hasher);
|
|
}
|
|
record.idle_timeout_seconds.hash(&mut hasher);
|
|
record.max_turn_duration_seconds.hash(&mut hasher);
|
|
record.parallelism.hash(&mut hasher);
|
|
|
|
hasher.finish()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|