Skip to content

Commit ac3a45e

Browse files
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7wpfleger96
andcommitted
feat(config-bridge): resolve persona config in a shared helper and replace badges with provenance sentences
Persona-linked agents had their inherited model/prompt/provider invisible in the config panel, and the source indicators (checkmark/hourglass icons, colored badges, footer) were undecipherable. Phase 1: Resolve all three persona fields (prompt, model, provider) in a single resolve_config_surface helper called by both get_agent_config_surface (read) and write_agent_config_field (write). The helper injects resolved values into the record where absent, calls the reader, then re-tags the injected fields from BuzzExplicit to PersonaDefault. The re-tag is triple- gated so a value the user set explicitly in Buzz is never re-tagged. Sharing the helper keeps the read and write surfaces identical, so plan_config_write never returns "field not available" for a persona-sourced field. Reader stays untouched (pure tier-merge function). Phase 2: Add AgentConfigPanel to ProfileSummaryView in the profile pop-out, gated on isBot && isOwner && managedAgent defined. Phase 3: Remove SourcesFooter and colored OriginBadge pills. Replace with gray inline provenance sentences below each value ("Set in Buzz", "Inherited from persona", "From environment variable", etc). No action clauses. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
1 parent dcb241e commit ac3a45e

6 files changed

Lines changed: 416 additions & 139 deletions

File tree

desktop/src-tauri/src/commands/agent_config.rs

Lines changed: 227 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,100 @@ use crate::{
66
config_bridge::{
77
reader::read_config_surface,
88
types::{
9-
AcpConfigOptionEntry, AcpConfigOptionValue, AcpModelEntry, ConfigWriteMechanism,
10-
RuntimeConfigSurface, SessionConfigCache, WriteConfigFieldRequest,
11-
WriteConfigResult, WriteConfigTarget,
9+
AcpConfigOptionEntry, AcpConfigOptionValue, AcpModelEntry, ConfigOrigin,
10+
ConfigWriteMechanism, NormalizedField, RuntimeConfigSurface, SessionConfigCache,
11+
WriteConfigFieldRequest, WriteConfigResult, WriteConfigTarget,
1212
},
1313
writer::plan_config_write,
1414
},
15-
known_acp_runtime, load_managed_agents, save_managed_agents, sync_managed_agent_processes,
15+
known_acp_runtime, load_managed_agents, load_personas,
16+
resolve_effective_prompt_model_provider, save_managed_agents, sync_managed_agent_processes,
17+
KnownAcpRuntime, ManagedAgentRecord, PersonaRecord,
1618
},
1719
};
1820

21+
/// Resolve the config surface with persona values applied.
22+
///
23+
/// Both the read path (`get_agent_config_surface`) and the write path
24+
/// (`write_agent_config_field`) must see the same surface, so this is the
25+
/// single place persona resolution happens. The pipeline: resolve the linked
26+
/// persona's prompt/model/provider, inject each into the record only where the
27+
/// record lacks its own value, let `read_config_surface` tag those injected
28+
/// fields `BuzzExplicit`, then re-tag exactly the injected fields to
29+
/// `PersonaDefault`.
30+
///
31+
/// The re-tag is triple-gated — a field is re-tagged only when (a) the record
32+
/// did not already have it (`!had_*`), (b) the surface produced the field, and
33+
/// (c) the reader tagged it `BuzzExplicit`. A value the user set explicitly in
34+
/// Buzz keeps `had_* == true` and is never re-tagged.
35+
fn resolve_config_surface(
36+
mut record: ManagedAgentRecord,
37+
personas: &[PersonaRecord],
38+
runtime_meta: Option<&KnownAcpRuntime>,
39+
session_cache: Option<&SessionConfigCache>,
40+
) -> RuntimeConfigSurface {
41+
let had_prompt =
42+
record.system_prompt.is_some() || record.env_vars.contains_key("BUZZ_ACP_SYSTEM_PROMPT");
43+
let had_model = record.model.is_some();
44+
45+
let provider_env_key = runtime_meta.and_then(|m| m.provider_env_var).unwrap_or("");
46+
let had_provider = record.env_vars.contains_key(provider_env_key);
47+
48+
let (persona_prompt, persona_model, persona_provider) = resolve_effective_prompt_model_provider(
49+
record.persona_id.as_deref(),
50+
personas,
51+
record.system_prompt.clone(),
52+
record.model.clone(),
53+
);
54+
55+
// Inject resolved persona values into the record where absent.
56+
if !had_prompt {
57+
if let Some(p) = persona_prompt {
58+
record
59+
.env_vars
60+
.insert("BUZZ_ACP_SYSTEM_PROMPT".to_string(), p);
61+
}
62+
}
63+
if !had_model {
64+
record.model = persona_model;
65+
}
66+
if !had_provider && !provider_env_key.is_empty() {
67+
if let Some(prov) = persona_provider {
68+
record.env_vars.insert(provider_env_key.to_string(), prov);
69+
}
70+
}
71+
72+
let mut surface = read_config_surface(&record, runtime_meta, session_cache);
73+
74+
// Re-tag persona-sourced fields from BuzzExplicit to PersonaDefault.
75+
if !had_prompt {
76+
retag_persona_default(&mut surface.normalized.system_prompt);
77+
}
78+
if !had_model {
79+
retag_persona_default(&mut surface.normalized.model);
80+
}
81+
if !had_provider && !provider_env_key.is_empty() {
82+
retag_persona_default(&mut surface.normalized.provider);
83+
}
84+
85+
surface
86+
}
87+
88+
/// Re-tag a field's origin from `BuzzExplicit` to `PersonaDefault`, leaving any
89+
/// other origin untouched. No-op when the field is absent.
90+
fn retag_persona_default(field: &mut Option<NormalizedField>) {
91+
if let Some(field) = field {
92+
if field.origin == ConfigOrigin::BuzzExplicit {
93+
field.origin = ConfigOrigin::PersonaDefault;
94+
}
95+
}
96+
}
97+
1998
/// Get the full config surface for a managed agent.
2099
///
21100
/// Returns normalized + advanced config from all available tiers.
22101
/// Pre-spawn agents show config file values with ACP tiers marked as pending.
102+
/// Persona-sourced values are resolved by `resolve_config_surface`.
23103
#[tauri::command]
24104
pub async fn get_agent_config_surface(
25105
pubkey: String,
@@ -45,11 +125,13 @@ pub async fn get_agent_config_surface(
45125
.ok_or_else(|| format!("agent {pubkey} not found"))?
46126
};
47127

128+
let personas = load_personas(&app).unwrap_or_default();
48129
let runtime_meta = known_acp_runtime(&record.agent_command);
49130
let session_cache = state.get_session_cache(&pubkey);
50131

51-
Ok(read_config_surface(
52-
&record,
132+
Ok(resolve_config_surface(
133+
record,
134+
&personas,
53135
runtime_meta,
54136
session_cache.as_ref(),
55137
))
@@ -60,6 +142,10 @@ pub async fn get_agent_config_surface(
60142
/// Plans the write mechanism based on the current config surface, then
61143
/// executes: either updating the record (for env var respawn) or returning
62144
/// the mechanism for the frontend to send via observer control (for ACP writes).
145+
///
146+
/// Uses the same persona-resolved surface as `get_agent_config_surface` so
147+
/// `plan_config_write` sees persona-sourced fields and never returns
148+
/// "field not available" for a value inherited from the linked persona.
63149
#[tauri::command]
64150
pub async fn write_agent_config_field(
65151
request: WriteConfigFieldRequest,
@@ -78,9 +164,10 @@ pub async fn write_agent_config_field(
78164
.cloned()
79165
.ok_or_else(|| format!("agent {} not found", request.pubkey))?;
80166

167+
let personas = load_personas(&app).unwrap_or_default();
81168
let runtime_meta = known_acp_runtime(&record.agent_command);
82169
let session_cache = state.get_session_cache(&request.pubkey);
83-
let surface = read_config_surface(&record, runtime_meta, session_cache.as_ref());
170+
let surface = resolve_config_surface(record, &personas, runtime_meta, session_cache.as_ref());
84171

85172
let mut result = plan_config_write(&surface, &request.field);
86173

@@ -293,3 +380,136 @@ fn parse_models(raw: Option<&serde_json::Value>) -> (Vec<AcpModelEntry>, Option<
293380
.collect();
294381
(models, current_model)
295382
}
383+
384+
#[cfg(test)]
385+
mod tests {
386+
use std::collections::BTreeMap;
387+
388+
use super::*;
389+
use crate::managed_agents::{BackendKind, RespondTo};
390+
391+
fn goose_runtime() -> &'static KnownAcpRuntime {
392+
&KnownAcpRuntime {
393+
id: "goose",
394+
label: "Goose",
395+
commands: &["goose"],
396+
aliases: &[],
397+
avatar_url: "",
398+
mcp_command: None,
399+
mcp_hooks: false,
400+
underlying_cli: None,
401+
cli_install_commands: &[],
402+
adapter_install_commands: &[],
403+
install_instructions_url: "",
404+
cli_install_hint: "",
405+
adapter_install_hint: "",
406+
skill_dir: None,
407+
supports_acp_model_switching: false,
408+
model_env_var: Some("GOOSE_MODEL"),
409+
provider_env_var: Some("GOOSE_PROVIDER"),
410+
provider_locked: false,
411+
default_env: &[],
412+
config_file_path: Some("~/.config/goose/config.yaml"),
413+
config_file_format: Some("yaml"),
414+
supports_acp_native_config: true,
415+
thinking_env_var: Some("GOOSE_THINKING_EFFORT"),
416+
}
417+
}
418+
419+
fn agent_record() -> ManagedAgentRecord {
420+
ManagedAgentRecord {
421+
pubkey: "agent".to_string(),
422+
name: "Agent".to_string(),
423+
persona_id: Some("persona-1".to_string()),
424+
private_key_nsec: "".to_string(),
425+
auth_tag: None,
426+
relay_url: "ws://localhost:3000".to_string(),
427+
avatar_url: None,
428+
acp_command: "buzz-acp".to_string(),
429+
agent_command: "goose".to_string(),
430+
agent_args: vec![],
431+
mcp_command: "".to_string(),
432+
turn_timeout_seconds: 300,
433+
idle_timeout_seconds: None,
434+
max_turn_duration_seconds: None,
435+
parallelism: 1,
436+
system_prompt: None,
437+
model: None,
438+
mcp_toolsets: None,
439+
env_vars: BTreeMap::new(),
440+
start_on_app_launch: false,
441+
runtime_pid: None,
442+
backend: BackendKind::Local,
443+
backend_agent_id: None,
444+
provider_binary_path: None,
445+
persona_team_dir: None,
446+
persona_name_in_team: None,
447+
created_at: "".to_string(),
448+
updated_at: "".to_string(),
449+
last_started_at: None,
450+
last_stopped_at: None,
451+
last_exit_code: None,
452+
last_error: None,
453+
respond_to: RespondTo::OwnerOnly,
454+
respond_to_allowlist: vec![],
455+
relay_mesh: None,
456+
}
457+
}
458+
459+
fn persona_with_model(model: &str) -> PersonaRecord {
460+
PersonaRecord {
461+
id: "persona-1".to_string(),
462+
display_name: "Persona".to_string(),
463+
avatar_url: None,
464+
system_prompt: "You are a persona.".to_string(),
465+
runtime: None,
466+
model: Some(model.to_string()),
467+
provider: None,
468+
name_pool: Vec::new(),
469+
is_builtin: false,
470+
is_active: true,
471+
source_team: None,
472+
source_team_persona_slug: None,
473+
env_vars: BTreeMap::new(),
474+
created_at: "".to_string(),
475+
updated_at: "".to_string(),
476+
}
477+
}
478+
479+
/// The write path must see a persona-inherited model. Without persona
480+
/// resolution `surface.normalized.model` would be `None` and
481+
/// `plan_config_write` would return "field not available for this runtime".
482+
#[test]
483+
fn write_path_sees_persona_sourced_model_field() {
484+
let record = agent_record();
485+
let personas = vec![persona_with_model("persona-model")];
486+
487+
let surface = resolve_config_surface(record, &personas, Some(goose_runtime()), None);
488+
489+
let model = surface.normalized.model.as_ref().expect("model resolved");
490+
assert_eq!(model.value.as_deref(), Some("persona-model"));
491+
assert_eq!(model.origin, ConfigOrigin::PersonaDefault);
492+
493+
let result = plan_config_write(&surface, &WriteConfigTarget::Model);
494+
assert!(result.success, "write plan failed: {:?}", result.error);
495+
assert!(matches!(
496+
result.mechanism_used,
497+
ConfigWriteMechanism::RespawnWithEnvVar { .. }
498+
));
499+
}
500+
501+
/// A model the user set explicitly in Buzz must never be re-tagged to
502+
/// `PersonaDefault`, even when the linked persona also has a model.
503+
#[test]
504+
fn explicit_record_model_outranks_persona_and_keeps_buzz_explicit_origin() {
505+
let mut record = agent_record();
506+
record.model = Some("explicit-model".to_string());
507+
let personas = vec![persona_with_model("persona-model")];
508+
509+
let surface = resolve_config_surface(record, &personas, Some(goose_runtime()), None);
510+
511+
let model = surface.normalized.model.as_ref().expect("model resolved");
512+
assert_eq!(model.value.as_deref(), Some("explicit-model"));
513+
assert_eq!(model.origin, ConfigOrigin::BuzzExplicit);
514+
}
515+
}

0 commit comments

Comments
 (0)