Skip to content

Commit 9939a10

Browse files
committed
feat(desktop): harness-agnostic config bridge
Four-tier config bridge that reads agent configuration from config files (goose YAML, claude JSON, codex TOML), ACP session data, env vars, and Sprout-explicit overrides — surfacing a unified normalized config surface to the desktop UI regardless of runtime. Key changes: - Config bridge module with per-runtime file readers - ACP session config caching for post-spawn config visibility - AgentConfigPanel component with origin badges and tier provenance - Serde internally-tagged enums matching TypeScript discriminated unions - TOCTOU-safe write path with single lock scope
1 parent a101fd6 commit 9939a10

27 files changed

Lines changed: 2487 additions & 41 deletions

File tree

crates/buzz-acp/src/pool.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,16 @@ async fn create_session_and_apply_model(
413413
});
414414
}
415415

416+
// Emit session config for desktop consumption (config bridge tier 1b).
417+
agent.acp.observe(
418+
"session_config_captured",
419+
serde_json::json!({
420+
"configOptions": resp.raw.get("configOptions").cloned().unwrap_or(serde_json::Value::Null),
421+
"modes": resp.raw.get("modes").cloned().unwrap_or(serde_json::Value::Null),
422+
"models": resp.raw.get("models").cloned().unwrap_or(serde_json::Value::Null),
423+
}),
424+
);
425+
416426
// Apply desired_model if set, matching against the fresh session/new response.
417427
if let Some(ref desired) = agent.desired_model {
418428
match resolve_model_switch_method(&resp.raw, desired) {

desktop/scripts/check-file-sizes.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,13 @@ const rules = [
3030
// Do not add to this list; split the file instead. Remove each entry as its
3131
// file is broken up. Tracked as a follow-up.
3232
const overrides = new Map([
33-
["src-tauri/src/commands/agents.rs", 1294],
33+
["src-tauri/src/commands/agents.rs", 1350],
3434
["src-tauri/src/managed_agents/nest.rs", 1420],
3535
["src-tauri/src/managed_agents/runtime.rs", 1940],
3636
["src-tauri/src/managed_agents/personas.rs", 1080],
3737
["src-tauri/src/managed_agents/persona_card.rs", 1050],
3838
["src-tauri/src/huddle/tts.rs", 1364],
39-
["src/shared/api/tauri.ts", 1196],
39+
["src/shared/api/tauri.ts", 1250],
4040
["src-tauri/src/nostr_convert.rs", 1126],
4141
["src/shared/api/relayClientSession.ts", 1022],
4242
["src-tauri/src/migration.rs", 1295],

desktop/src-tauri/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

desktop/src-tauri/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ neteq = { version = "0.8", default-features = false }
5757
serde = { version = "1", features = ["derive"] }
5858
serde_json = "1"
5959
serde_yaml = "0.9"
60+
toml = "0.8"
6061
nostr = { version = "0.44", features = ["nip44"] }
6162
zeroize = "1"
6263
reqwest = { version = "0.13", features = ["json", "query", "stream"] }

desktop/src-tauri/src/app_state.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use tauri::{AppHandle, Manager};
1010
use tokio::sync::Mutex as AsyncMutex;
1111

1212
use crate::huddle::HuddleState;
13+
use crate::managed_agents::config_bridge::SessionConfigCache;
1314
use crate::managed_agents::ManagedAgentProcess;
1415

1516
pub struct AppState {
@@ -33,6 +34,9 @@ pub struct AppState {
3334
pub audio_output_device: Mutex<Option<String>>,
3435
/// Port of the localhost media streaming proxy (set during setup).
3536
pub media_proxy_port: AtomicU16,
37+
/// Cached ACP session config from running agents, keyed by agent pubkey.
38+
/// Populated when the harness emits `session_config_captured` observer events.
39+
pub session_config_cache: Mutex<HashMap<String, SessionConfigCache>>,
3640
/// IOKit power assertion state — prevents idle sleep while agents run.
3741
pub prevent_sleep: Arc<Mutex<crate::prevent_sleep::PreventSleepState>>,
3842
/// In-process mesh-llm node started by Buzz Desktop.
@@ -81,6 +85,7 @@ pub fn build_app_state() -> AppState {
8185
managed_agents_store_lock: Mutex::new(()),
8286
channel_templates_store_lock: Mutex::new(()),
8387
managed_agent_processes: Mutex::new(HashMap::new()),
88+
session_config_cache: Mutex::new(HashMap::new()),
8489
huddle_state: Mutex::new(HuddleState::default()),
8590
app_handle: Mutex::new(None),
8691
audio_output_device: Mutex::new(None),
@@ -105,6 +110,22 @@ impl AppState {
105110
self.huddle_state.lock().map_err(|e| e.to_string())
106111
}
107112

113+
pub fn get_session_cache(&self, pubkey: &str) -> Option<SessionConfigCache> {
114+
self.session_config_cache.lock().ok()?.get(pubkey).cloned()
115+
}
116+
117+
pub fn put_session_cache(&self, pubkey: &str, cache: SessionConfigCache) {
118+
if let Ok(mut map) = self.session_config_cache.lock() {
119+
map.insert(pubkey.to_string(), cache);
120+
}
121+
}
122+
123+
pub fn clear_session_cache(&self, pubkey: &str) {
124+
if let Ok(mut map) = self.session_config_cache.lock() {
125+
map.remove(pubkey);
126+
}
127+
}
128+
108129
/// Emit the current huddle state to the frontend via Tauri event.
109130
///
110131
/// Acquires both locks (app_handle + huddle_state), clones a snapshot,
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
use tauri::{AppHandle, State};
2+
3+
use crate::{
4+
app_state::AppState,
5+
managed_agents::{
6+
config_bridge::{
7+
reader::read_config_surface,
8+
types::{
9+
AcpConfigOptionEntry, AcpConfigOptionValue, AcpModelEntry, ConfigWriteMechanism,
10+
RuntimeConfigSurface, SessionConfigCache, WriteConfigFieldRequest,
11+
WriteConfigResult, WriteConfigTarget,
12+
},
13+
writer::plan_config_write,
14+
},
15+
known_acp_runtime, load_managed_agents, save_managed_agents, sync_managed_agent_processes,
16+
},
17+
};
18+
19+
/// Get the full config surface for a managed agent.
20+
///
21+
/// Returns normalized + advanced config from all available tiers.
22+
/// Pre-spawn agents show config file values with ACP tiers marked as pending.
23+
#[tauri::command]
24+
pub async fn get_agent_config_surface(
25+
pubkey: String,
26+
app: AppHandle,
27+
state: State<'_, AppState>,
28+
) -> Result<RuntimeConfigSurface, String> {
29+
let record = {
30+
let _store_guard = state
31+
.managed_agents_store_lock
32+
.lock()
33+
.map_err(|e| e.to_string())?;
34+
let mut records = load_managed_agents(&app)?;
35+
let mut runtimes = state
36+
.managed_agent_processes
37+
.lock()
38+
.map_err(|e| e.to_string())?;
39+
if sync_managed_agent_processes(&mut records, &mut runtimes) {
40+
save_managed_agents(&app, &records)?;
41+
}
42+
records
43+
.into_iter()
44+
.find(|r| r.pubkey == pubkey)
45+
.ok_or_else(|| format!("agent {pubkey} not found"))?
46+
};
47+
48+
let runtime_meta = known_acp_runtime(&record.agent_command);
49+
let session_cache = state.get_session_cache(&pubkey);
50+
51+
Ok(read_config_surface(
52+
&record,
53+
runtime_meta,
54+
session_cache.as_ref(),
55+
))
56+
}
57+
58+
/// Write a config field value for a managed agent.
59+
///
60+
/// Plans the write mechanism based on the current config surface, then
61+
/// executes: either updating the record (for env var respawn) or returning
62+
/// the mechanism for the frontend to send via observer control (for ACP writes).
63+
#[tauri::command]
64+
pub async fn write_agent_config_field(
65+
request: WriteConfigFieldRequest,
66+
app: AppHandle,
67+
state: State<'_, AppState>,
68+
) -> Result<WriteConfigResult, String> {
69+
let _store_guard = state
70+
.managed_agents_store_lock
71+
.lock()
72+
.map_err(|e| e.to_string())?;
73+
let mut records = load_managed_agents(&app)?;
74+
75+
let record = records
76+
.iter()
77+
.find(|r| r.pubkey == request.pubkey)
78+
.cloned()
79+
.ok_or_else(|| format!("agent {} not found", request.pubkey))?;
80+
81+
let runtime_meta = known_acp_runtime(&record.agent_command);
82+
let session_cache = state.get_session_cache(&request.pubkey);
83+
let surface = read_config_surface(&record, runtime_meta, session_cache.as_ref());
84+
85+
let mut result = plan_config_write(&surface, &request.field);
86+
87+
if !result.success {
88+
return Ok(result);
89+
}
90+
91+
if let ConfigWriteMechanism::RespawnWithEnvVar { ref env_key } = result.mechanism_used {
92+
let record = records
93+
.iter_mut()
94+
.find(|r| r.pubkey == request.pubkey)
95+
.ok_or_else(|| format!("agent {} not found", request.pubkey))?;
96+
97+
match request.value {
98+
Some(ref val) if !val.is_empty() => {
99+
record.env_vars.insert(env_key.clone(), val.clone());
100+
}
101+
_ => {
102+
record.env_vars.remove(env_key);
103+
}
104+
}
105+
106+
if matches!(request.field, WriteConfigTarget::Model) {
107+
record.model = request.value.clone();
108+
}
109+
110+
record.updated_at = crate::util::now_iso();
111+
save_managed_agents(&app, &records)?;
112+
result.requires_restart = true;
113+
}
114+
115+
Ok(result)
116+
}
117+
118+
/// Store a `session_config_captured` observer event payload into the session cache.
119+
///
120+
/// Called by the TypeScript observer relay when it decrypts a `session_config_captured`
121+
/// event from a running agent. The payload contains raw ACP session/new fields.
122+
#[tauri::command]
123+
pub fn put_agent_session_config(
124+
pubkey: String,
125+
payload: serde_json::Value,
126+
app: AppHandle,
127+
state: State<'_, AppState>,
128+
) {
129+
{
130+
let _guard = match state.managed_agents_store_lock.lock() {
131+
Ok(g) => g,
132+
Err(_) => return,
133+
};
134+
match load_managed_agents(&app) {
135+
Ok(records) if records.iter().any(|r| r.pubkey == pubkey) => {}
136+
_ => return,
137+
}
138+
}
139+
140+
let config_options = parse_config_options(payload.get("configOptions"));
141+
let available_modes = parse_modes(&config_options, payload.get("modes"));
142+
let (available_models, current_model) = parse_models(payload.get("models"));
143+
144+
let cache = SessionConfigCache {
145+
config_options,
146+
available_modes,
147+
available_models,
148+
current_model,
149+
goose_native_config: None,
150+
captured_at: crate::util::now_iso(),
151+
};
152+
153+
state.put_session_cache(&pubkey, cache);
154+
}
155+
156+
fn parse_config_options(raw: Option<&serde_json::Value>) -> Vec<AcpConfigOptionEntry> {
157+
let arr = match raw.and_then(|v| v.as_array()) {
158+
Some(a) => a,
159+
None => return Vec::new(),
160+
};
161+
arr.iter()
162+
.filter_map(|opt| {
163+
let config_id = opt
164+
.get("id")
165+
.or_else(|| opt.get("configId"))?
166+
.as_str()?
167+
.to_string();
168+
Some(AcpConfigOptionEntry {
169+
config_id,
170+
category: opt
171+
.get("category")
172+
.and_then(|v| v.as_str())
173+
.map(str::to_string),
174+
display_name: opt
175+
.get("displayName")
176+
.and_then(|v| v.as_str())
177+
.map(str::to_string),
178+
current_value: opt
179+
.get("value")
180+
.or_else(|| opt.get("currentValue"))
181+
.and_then(|v| v.as_str())
182+
.map(str::to_string),
183+
options: parse_option_values(opt.get("options")),
184+
})
185+
})
186+
.collect()
187+
}
188+
189+
fn parse_option_values(raw: Option<&serde_json::Value>) -> Vec<AcpConfigOptionValue> {
190+
let arr = match raw.and_then(|v| v.as_array()) {
191+
Some(a) => a,
192+
None => return Vec::new(),
193+
};
194+
arr.iter()
195+
.filter_map(|o| {
196+
let value = o.get("value").and_then(|v| v.as_str())?.to_string();
197+
Some(AcpConfigOptionValue {
198+
value,
199+
display_name: o
200+
.get("displayName")
201+
.and_then(|v| v.as_str())
202+
.map(str::to_string),
203+
})
204+
})
205+
.collect()
206+
}
207+
208+
fn parse_modes(
209+
config_options: &[AcpConfigOptionEntry],
210+
raw: Option<&serde_json::Value>,
211+
) -> Vec<String> {
212+
if let Some(arr) = raw.and_then(|v| v.as_array()) {
213+
return arr
214+
.iter()
215+
.filter_map(|m| m.as_str().map(str::to_string))
216+
.collect();
217+
}
218+
// Fall back: extract mode options from configOptions with category "mode".
219+
config_options
220+
.iter()
221+
.filter(|o| o.category.as_deref() == Some("mode"))
222+
.flat_map(|o| o.options.iter().map(|v| v.value.clone()))
223+
.collect()
224+
}
225+
226+
fn parse_models(raw: Option<&serde_json::Value>) -> (Vec<AcpModelEntry>, Option<String>) {
227+
let raw = match raw {
228+
Some(v) => v,
229+
None => return (Vec::new(), None),
230+
};
231+
232+
// Object shape: { currentModelId, availableModels: [...] }
233+
if let Some(obj) = raw.as_object() {
234+
let current_model = obj
235+
.get("currentModelId")
236+
.and_then(|v| v.as_str())
237+
.map(str::to_string);
238+
let models = obj
239+
.get("availableModels")
240+
.and_then(|v| v.as_array())
241+
.map(|arr| {
242+
arr.iter()
243+
.filter_map(|m| {
244+
let model_id = m
245+
.get("modelId")
246+
.or_else(|| m.get("id"))
247+
.and_then(|v| v.as_str())?
248+
.to_string();
249+
Some(AcpModelEntry {
250+
model_id,
251+
name: m.get("name").and_then(|v| v.as_str()).map(str::to_string),
252+
description: m
253+
.get("description")
254+
.and_then(|v| v.as_str())
255+
.map(str::to_string),
256+
})
257+
})
258+
.collect()
259+
})
260+
.unwrap_or_default();
261+
return (models, current_model);
262+
}
263+
264+
// Array shape: [{ modelId, isCurrent, ... }]
265+
let arr = match raw.as_array() {
266+
Some(a) => a,
267+
None => return (Vec::new(), None),
268+
};
269+
let mut current_model = None;
270+
let models = arr
271+
.iter()
272+
.filter_map(|m| {
273+
let model_id = m
274+
.get("modelId")
275+
.or_else(|| m.get("id"))
276+
.and_then(|v| v.as_str())?
277+
.to_string();
278+
if m.get("isCurrent")
279+
.and_then(|v| v.as_bool())
280+
.unwrap_or(false)
281+
{
282+
current_model = Some(model_id.clone());
283+
}
284+
Some(AcpModelEntry {
285+
model_id,
286+
name: m.get("name").and_then(|v| v.as_str()).map(str::to_string),
287+
description: m
288+
.get("description")
289+
.and_then(|v| v.as_str())
290+
.map(str::to_string),
291+
})
292+
})
293+
.collect();
294+
(models, current_model)
295+
}

0 commit comments

Comments
 (0)