feat(config-bridge): resolve persona values at call site and replace badges with provenance sentences

Phase 1: Resolve all three persona fields (prompt, model, provider) at
the get_agent_config_surface call site before calling the reader. Inject
resolved values into the record where absent, then post-process the
surface to re-tag injected fields from BuzzExplicit to PersonaDefault.
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 <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-06-16 19:49:04 -04:00
co-authored by Will Pfleger
parent fba912fc48
commit 940608f50c
5 changed files with 255 additions and 137 deletions
+80 -12
View File
@@ -6,13 +6,15 @@ use crate::{
config_bridge::{
reader::read_config_surface,
types::{
AcpConfigOptionEntry, AcpConfigOptionValue, AcpModelEntry, ConfigWriteMechanism,
RuntimeConfigSurface, SessionConfigCache, WriteConfigFieldRequest,
WriteConfigResult, WriteConfigTarget,
AcpConfigOptionEntry, AcpConfigOptionValue, AcpModelEntry, ConfigOrigin,
ConfigWriteMechanism, RuntimeConfigSurface, SessionConfigCache,
WriteConfigFieldRequest, WriteConfigResult, WriteConfigTarget,
},
writer::plan_config_write,
},
known_acp_runtime, load_managed_agents, save_managed_agents, sync_managed_agent_processes,
known_acp_runtime, load_managed_agents, load_personas,
resolve_effective_prompt_model_provider, save_managed_agents,
sync_managed_agent_processes,
},
};
@@ -20,13 +22,15 @@ use crate::{
///
/// Returns normalized + advanced config from all available tiers.
/// Pre-spawn agents show config file values with ACP tiers marked as pending.
/// Persona-sourced values are resolved here (call-site) and re-tagged as
/// `PersonaDefault` after the reader produces the surface.
#[tauri::command]
pub async fn get_agent_config_surface(
pubkey: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<RuntimeConfigSurface, String> {
let record = {
let mut record = {
let _store_guard = state
.managed_agents_store_lock
.lock()
@@ -45,14 +49,74 @@ pub async fn get_agent_config_surface(
.ok_or_else(|| format!("agent {pubkey} not found"))?
};
let runtime_meta = known_acp_runtime(&record.agent_command);
let session_cache = state.get_session_cache(&pubkey);
// Resolve persona values at the call site (not inside the reader).
// Track which fields were absent on the record so we can re-tag them
// as PersonaDefault after the reader produces the surface.
let personas = load_personas(&app).unwrap_or_default();
let had_prompt = record.system_prompt.is_some()
|| record.env_vars.contains_key("BUZZ_ACP_SYSTEM_PROMPT");
let had_model = record.model.is_some();
Ok(read_config_surface(
&record,
runtime_meta,
session_cache.as_ref(),
))
let runtime_meta = known_acp_runtime(&record.agent_command);
let provider_env_key = runtime_meta
.and_then(|m| m.provider_env_var)
.unwrap_or("");
let had_provider = record.env_vars.contains_key(provider_env_key);
let (persona_prompt, persona_model, persona_provider) =
resolve_effective_prompt_model_provider(
record.persona_id.as_deref(),
&personas,
record.system_prompt.clone(),
record.model.clone(),
);
// Inject resolved persona values into the record where absent.
if !had_prompt {
if let Some(ref p) = persona_prompt {
record
.env_vars
.insert("BUZZ_ACP_SYSTEM_PROMPT".to_string(), p.clone());
}
}
if !had_model {
record.model = persona_model;
}
if !had_provider && !provider_env_key.is_empty() {
if let Some(ref prov) = persona_provider {
record
.env_vars
.insert(provider_env_key.to_string(), prov.clone());
}
}
let session_cache = state.get_session_cache(&pubkey);
let mut surface = read_config_surface(&record, runtime_meta, session_cache.as_ref());
// Re-tag persona-sourced fields from BuzzExplicit to PersonaDefault.
if !had_prompt {
if let Some(ref mut field) = surface.normalized.system_prompt {
if field.origin == ConfigOrigin::BuzzExplicit {
field.origin = ConfigOrigin::PersonaDefault;
}
}
}
if !had_model {
if let Some(ref mut field) = surface.normalized.model {
if field.origin == ConfigOrigin::BuzzExplicit {
field.origin = ConfigOrigin::PersonaDefault;
}
}
}
if !had_provider {
if let Some(ref mut field) = surface.normalized.provider {
if field.origin == ConfigOrigin::BuzzExplicit {
field.origin = ConfigOrigin::PersonaDefault;
}
}
}
Ok(surface)
}
/// Write a config field value for a managed agent.
@@ -60,6 +124,10 @@ pub async fn get_agent_config_surface(
/// Plans the write mechanism based on the current config surface, then
/// executes: either updating the record (for env var respawn) or returning
/// the mechanism for the frontend to send via observer control (for ACP writes).
///
// TODO: When inline editing lands, this function needs the same persona
// injection as get_agent_config_surface — without it, plan_config_write
// won't see persona-sourced fields and will return "field not available".
#[tauri::command]
pub async fn write_agent_config_field(
request: WriteConfigFieldRequest,
@@ -579,4 +579,121 @@ mod tests {
// the actual file in a unit test, just verify the override fields are populated
// when we manually construct the scenario via build_model_field.
}
// ── Persona resolution integration tests ────────────────────────────
//
// These simulate the call-site pattern in agent_config.rs:
// 1. Inject persona-resolved values into the record (as if absent)
// 2. Call read_config_surface (reader tags them BuzzExplicit)
// 3. Re-tag injected fields to PersonaDefault
//
// This exercises the same logic path as get_agent_config_surface without
// requiring Tauri AppHandle/State infrastructure.
#[test]
fn persona_model_injection_produces_persona_default_origin() {
let mut record = test_record();
// Simulate: record has no model, persona provides one.
// The call-site injects it before calling the reader.
record.model = Some("persona-model".to_string());
let runtime = test_runtime();
let mut surface = read_config_surface(&record, Some(runtime), None);
// Reader sees injected model as BuzzExplicit.
let model = surface.normalized.model.as_ref().unwrap();
assert_eq!(model.value.as_deref(), Some("persona-model"));
assert_eq!(model.origin, ConfigOrigin::BuzzExplicit);
// Call-site re-tags (simulating had_model == false).
if let Some(ref mut field) = surface.normalized.model {
if field.origin == ConfigOrigin::BuzzExplicit {
field.origin = ConfigOrigin::PersonaDefault;
}
}
let model = surface.normalized.model.unwrap();
assert_eq!(model.value.as_deref(), Some("persona-model"));
assert_eq!(model.origin, ConfigOrigin::PersonaDefault);
}
#[test]
fn persona_provider_injection_produces_persona_default_origin() {
let mut record = test_record();
// Simulate: record has no provider env var, persona provides one.
// The call-site injects it as GOOSE_PROVIDER before calling the reader.
record
.env_vars
.insert("GOOSE_PROVIDER".to_string(), "anthropic".to_string());
let runtime = test_runtime();
let mut surface = read_config_surface(&record, Some(runtime), None);
// Reader sees injected provider as BuzzExplicit.
let provider = surface.normalized.provider.as_ref().unwrap();
assert_eq!(provider.value.as_deref(), Some("anthropic"));
assert_eq!(provider.origin, ConfigOrigin::BuzzExplicit);
// Call-site re-tags (simulating had_provider == false).
if let Some(ref mut field) = surface.normalized.provider {
if field.origin == ConfigOrigin::BuzzExplicit {
field.origin = ConfigOrigin::PersonaDefault;
}
}
let provider = surface.normalized.provider.unwrap();
assert_eq!(provider.value.as_deref(), Some("anthropic"));
assert_eq!(provider.origin, ConfigOrigin::PersonaDefault);
}
#[test]
fn persona_system_prompt_injection_produces_persona_default_origin() {
let mut record = test_record();
// Simulate: record has no system_prompt, persona provides one via env var.
// The call-site injects it as BUZZ_ACP_SYSTEM_PROMPT before calling the reader.
record.env_vars.insert(
"BUZZ_ACP_SYSTEM_PROMPT".to_string(),
"You are a helpful assistant.".to_string(),
);
let runtime = test_runtime();
let mut surface = read_config_surface(&record, Some(runtime), None);
// Reader sees injected prompt as BuzzExplicit.
let prompt = surface.normalized.system_prompt.as_ref().unwrap();
assert_eq!(
prompt.value.as_deref(),
Some("You are a helpful assistant.")
);
assert_eq!(prompt.origin, ConfigOrigin::BuzzExplicit);
// Call-site re-tags (simulating had_prompt == false).
if let Some(ref mut field) = surface.normalized.system_prompt {
if field.origin == ConfigOrigin::BuzzExplicit {
field.origin = ConfigOrigin::PersonaDefault;
}
}
let prompt = surface.normalized.system_prompt.unwrap();
assert_eq!(
prompt.value.as_deref(),
Some("You are a helpful assistant.")
);
assert_eq!(prompt.origin, ConfigOrigin::PersonaDefault);
}
#[test]
fn explicit_record_model_not_retagged_when_already_present() {
let mut record = test_record();
// Record already has its own model — persona resolution should NOT re-tag.
record.model = Some("explicit-model".to_string());
let runtime = test_runtime();
let surface = read_config_surface(&record, Some(runtime), None);
// had_model == true, so no re-tagging occurs. Origin stays BuzzExplicit.
let model = surface.normalized.model.unwrap();
assert_eq!(model.value.as_deref(), Some("explicit-model"));
assert_eq!(model.origin, ConfigOrigin::BuzzExplicit);
}
}
@@ -17,8 +17,9 @@ pub enum ConfigOrigin {
/// Read from harness config file on disk (tier 2b, lowest precedence).
ConfigFile,
/// Value inherited from persona defaults.
/// Forward slot — not yet populated by any reader. Will be wired when
/// persona pack config resolution is added to `read_config_surface`.
/// Populated by the `get_agent_config_surface` call site: persona values are
/// resolved before calling the reader, then the surface is post-processed to
/// re-tag injected fields from `BuzzExplicit` to `PersonaDefault`.
PersonaDefault,
}
@@ -1,5 +1,5 @@
import * as React from "react";
import { ChevronDown, ChevronRight, Info } from "lucide-react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { useAgentConfigSurface } from "../hooks";
import { cn } from "@/shared/lib/cn";
@@ -7,9 +7,9 @@ import { Spinner } from "@/shared/ui/spinner";
import type {
ConfigField,
ConfigOrigin,
ConfigWriteMechanism,
NormalizedConfig,
NormalizedField,
ConfigSourceReport,
} from "@/shared/api/types";
type Props = {
@@ -17,68 +17,34 @@ type Props = {
isRunning: boolean;
};
// ── Origin badge ─────────────────────────────────────────────────────────────
// ── Provenance sentence ──────────────────────────────────────────────────────
function originLabel(
function provenanceSentence(
origin: ConfigOrigin,
writeVia: ConfigWriteMechanism,
configFilePath: string | null,
): string {
switch (origin) {
case "buzzExplicit":
return "Buzz";
case "acpConfigOption":
return "ACP";
case "acpNativeRead":
return "ACP";
case "envVar":
return "Env";
case "configFile": {
if (configFilePath) {
const parts = configFilePath.split(/[/\\]/);
return parts[parts.length - 1] ?? configFilePath;
return "Set in Buzz";
case "personaDefault":
return "Inherited from persona";
case "envVar": {
if (writeVia.type === "respawnWithEnvVar") {
return `From environment variable (${writeVia.envKey})`;
}
return "Config";
return "From environment variable";
}
case "personaDefault":
return "Persona";
}
}
function originColorClass(origin: ConfigOrigin): string {
switch (origin) {
case "buzzExplicit":
return "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300";
case "configFile":
return configFilePath
? `From config file (${configFilePath})`
: "From config file";
case "acpConfigOption":
case "acpNativeRead":
return "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300";
case "configFile":
case "personaDefault":
return "bg-muted text-muted-foreground";
case "envVar":
return "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300";
return "From ACP session";
}
}
function OriginBadge({
origin,
configFilePath,
}: {
origin: ConfigOrigin;
configFilePath: string | null;
}) {
return (
<span
className={cn(
"inline-block max-w-[120px] truncate rounded px-1.5 py-0.5 text-[10px] font-medium leading-none",
originColorClass(origin),
)}
title={originLabel(origin, configFilePath)}
>
{originLabel(origin, configFilePath)}
</span>
);
}
// ── Normalized row ────────────────────────────────────────────────────────────
const NORMALIZED_LABELS: Record<keyof NormalizedConfig, string> = {
@@ -107,19 +73,13 @@ function NormalizedRow({
field.origin === "acpNativeRead" || field.origin === "acpConfigOption";
return (
<div className="flex items-center gap-2 py-1 text-sm">
<span className="w-36 shrink-0 text-xs text-muted-foreground">
{label}
</span>
{/* Value area: effective value + strikethrough overridden value */}
<span className="min-w-0 flex-1 truncate font-medium">
<div className="py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-0.5 truncate text-sm font-medium">
{isPreSpawn && isAcpOnly ? (
<em className="font-normal text-muted-foreground not-italic text-xs">
<span className="font-normal text-muted-foreground text-xs">
Available after agent starts
</em>
) : isPreSpawn && field.origin === "configFile" && !field.value ? (
<span className="text-xs text-muted-foreground/70"></span>
</span>
) : (
<>
{field.value ?? <span className="text-muted-foreground"></span>}
@@ -130,25 +90,11 @@ function NormalizedRow({
)}
</>
)}
</span>
{/* Badge area: effective badge + strikethrough overridden badge */}
<span className="flex items-center gap-1 shrink-0">
<OriginBadge origin={field.origin} configFilePath={configFilePath} />
{field.overriddenOrigin && (
<span className="opacity-50 line-through">
<OriginBadge
origin={field.overriddenOrigin}
configFilePath={configFilePath}
/>
</span>
)}
</span>
{!field.isWritable && (
<span title="Read-only — edit this field directly in the config file">
<Info className="h-3 w-3 shrink-0 text-muted-foreground/50" />
</span>
</div>
{field.value && (
<div className="mt-0.5 text-[11px] text-muted-foreground/70">
{provenanceSentence(field.origin, field.writeVia, configFilePath)}
</div>
)}
</div>
);
@@ -164,53 +110,22 @@ function AdvancedRow({
configFilePath: string | null;
}) {
return (
<div className="flex items-center gap-2 py-1 text-sm">
<span className="w-36 shrink-0 text-xs text-muted-foreground">
{field.label}
</span>
<span className="min-w-0 flex-1 truncate font-medium font-mono text-xs">
<div className="py-2">
<div className="text-xs text-muted-foreground">{field.label}</div>
<div className="mt-0.5 truncate text-sm font-medium font-mono">
{field.value ?? (
<span className="not-italic text-muted-foreground"></span>
<span className="font-sans text-muted-foreground"></span>
)}
</span>
<OriginBadge origin={field.origin} configFilePath={configFilePath} />
{!field.isWritable && (
<span title="Read-only — edit this field directly in the config file">
<Info className="h-3 w-3 shrink-0 text-muted-foreground/50" />
</span>
</div>
{field.value && (
<div className="mt-0.5 text-[11px] text-muted-foreground/70">
{provenanceSentence(field.origin, field.writeVia, configFilePath)}
</div>
)}
</div>
);
}
// ── Sources footer ────────────────────────────────────────────────────────────
const STATUS_ICON: Record<string, string> = {
available: "✓",
pending: "⏳",
notApplicable: "—",
};
function SourcesFooter({ sources }: { sources: ConfigSourceReport }) {
const tiers = [
{ label: "Config file", status: sources.configFile },
{ label: "ACP native", status: sources.acpNative },
{ label: "ACP config", status: sources.acpConfigOptions },
{ label: "Env vars", status: sources.envVars },
] as const;
return (
<p className="mt-3 text-[10px] text-muted-foreground/70">
{tiers.map((tier, i) => (
<React.Fragment key={tier.label}>
{i > 0 && <span className="mx-1.5">|</span>}
{tier.label} <span>{STATUS_ICON[tier.status] ?? tier.status}</span>
</React.Fragment>
))}
</p>
);
}
// ── Main component ────────────────────────────────────────────────────────────
export function AgentConfigPanel({ pubkey, isRunning: _isRunning }: Props) {
@@ -302,8 +217,6 @@ export function AgentConfigPanel({ pubkey, isRunning: _isRunning }: Props) {
)}
</div>
)}
<SourcesFooter sources={sources} />
</div>
);
}
@@ -14,6 +14,7 @@ import {
MessageSquare,
Pencil,
Server,
Settings,
Terminal,
UserMinus,
UserPlus,
@@ -22,6 +23,7 @@ import {
import { toast } from "sonner";
import { MemorySection } from "@/features/agent-memory/ui/MemorySection";
import { AgentConfigPanel } from "@/features/agents/ui/AgentConfigPanel";
import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge";
import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore";
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
@@ -231,6 +233,23 @@ export function ProfileSummaryView({
{metadataFields.length > 0 ? (
<ProfileFieldGroup fields={metadataFields} />
) : null}
{isBot && isOwner === true && managedAgent !== undefined ? (
<section className="space-y-2">
<div className="flex items-center gap-2 px-1">
<Settings className="h-3.5 w-3.5 text-muted-foreground" />
<h4 className="text-xs font-medium text-muted-foreground">
Configuration
</h4>
</div>
<div className="rounded-2xl bg-muted/20 px-4 py-3">
<AgentConfigPanel
pubkey={managedAgent.pubkey}
isRunning={managedAgent.status === "running"}
/>
</div>
</section>
) : null}
</div>
);
}