Watch
1
0
Fork
You've already forked souveraine
0

feat(settings): per-agent model field — Settings follows the active agent

Casey reported model changes in Settings not taking effect on the agent
he meant. Root cause: Settings only ever edited the global
[bifrost] primary_model, and a save silently pushed that global change
onto whatever agent the open chat happened to be — never the agent the
user thought he was configuring, and nothing at all with no chat open.

- New AgModel field in the Agent settings category, backed by
  ActiveAgentSettings (id, name, model) loaded from the active agent's
  agent.json on Settings entry — so the field shows and edits THIS
  agent's llm model, and follows agent switches.
- On save, an AgModel change is pushed to that agent via the backend's
  update_agent_model (also refreshes cache + SQLite mirror).
- bifrost.primary_model is now purely the new-agent default; the
  confusing primary_model -> active-agent cross-push is removed.
This commit is contained in:
Fimeg 2026-05-17 18:31:23 -04:00
commit a2d89036c2
2 changed files with 141 additions and 26 deletions

View file

@ -906,6 +906,11 @@ impl App {
};
let original_snapshot = if save_and_go { Some(view.original.clone()) } else { None };
let config_snapshot = if save_and_go { Some(view.config.clone()) } else { None };
let agent_model_change = if save_and_go && view.agent_model_dirty() {
view.active_agent.as_ref().map(|a| (a.id.clone(), a.model.clone()))
} else {
None
};
// view's borrow on self.settings ends here (NLL),
// allowing self access below.
@ -920,6 +925,9 @@ impl App {
self.sync_settings_fields(orig, &cfg).await;
}
}
if let Some((id, model)) = agent_model_change {
self.push_agent_model(&id, &model).await;
}
if let Some(name) = outfit {
self.dispatch(TuiEvent::OutfitChanged(name));
} else {
@ -941,14 +949,22 @@ impl App {
Ok(()) => {
let original = view.original.clone();
let saved = view.config.clone();
let agent_model_change = if view.agent_model_dirty() {
view.active_agent.as_ref().map(|a| (a.id.clone(), a.model.clone()))
} else {
None
};
let mut live = self.config.write().await;
*live = saved.clone();
view.mode = crate::ui::settings::SettingsMode::Status {
msg: "saved".to_string(),
is_error: false,
};
// Diff known fields and push changes to SQLite.
// Diff known config fields (e.g. the new-agent default).
self.sync_settings_fields(&original, &saved).await;
if let Some((id, model)) = agent_model_change {
self.push_agent_model(&id, &model).await;
}
}
Err(e) => {
view.mode = crate::ui::settings::SettingsMode::Status {
@ -969,36 +985,64 @@ impl App {
}
}
/// Resolve the active agent into [`ActiveAgentSettings`] for the Settings
/// screen — id, display name, and current model read from its on-disk
/// `agent.json`. `None` when there is no backend to save through, or the
/// agent / its record can't be resolved; the per-agent fields are then
/// hidden rather than shown un-saveable.
fn active_agent_settings(&self) -> Option<crate::ui::settings::ActiveAgentSettings> {
self.chat.as_ref()?; // a backend is required to persist the change
let id = self.agent_id_by_name(&self.agent_pref)?;
let model = Self::agent_model_from_disk(&id)?;
Some(crate::ui::settings::ActiveAgentSettings {
id,
name: self.agent_pref.clone(),
model: model.clone(),
model_original: model,
})
}
/// Read an agent's current llm model handle from its on-disk record at
/// `~/.souveraine/server/agents/{id}/agent.json`.
fn agent_model_from_disk(agent_id: &str) -> Option<String> {
let path = dirs::home_dir()?
.join(".souveraine/server/agents")
.join(agent_id)
.join("agent.json");
let content = std::fs::read_to_string(path).ok()?;
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
json.get("llm_config")?.get("model")?.as_str().map(str::to_string)
}
/// Push a per-agent model change to the agent's record through the active
/// backend (which also refreshes the in-memory cache and SQLite mirror).
async fn push_agent_model(&self, agent_id: &str, model: &str) {
let Some(chat) = &self.chat else {
tracing::warn!(agent = %agent_id, "settings: no backend — agent model change not applied");
return;
};
match chat.backend.update_agent_model(agent_id, model).await {
Ok(()) => tracing::info!(agent = %agent_id, model = %model, "settings: agent model updated"),
Err(e) => tracing::warn!(agent = %agent_id, error = %e, "settings: agent model update failed"),
}
}
/// Diff fields between `original` (config snapshot at Settings entry) and
/// `saved` (what the user just saved), then push changed fields to every
/// data store that shadows them (SQLite agent records, etc.).
/// data store that shadows them.
///
/// Idempotent — unchanged fields produce no writes. Only pushes fields
/// that are known to have a shadow; add new mappings by extending this
/// method. See `docs/audit/config-settings-sync.md`.
/// Idempotent — unchanged fields produce no writes. Add new mappings by
/// extending this method. See `docs/audit/config-settings-sync.md`.
///
/// Note: `bifrost.primary_model` is the substrate-wide default for *new*
/// agents — it deliberately does NOT mutate an existing agent's model.
/// Per-agent model changes go through the Agent category's `model` field
/// (`AgModel`) and [`push_agent_model`].
async fn sync_settings_fields(&self, original: &ConsciousnessConfig, saved: &ConsciousnessConfig) {
let mut changed: Vec<&'static str> = Vec::new();
// ── primary_model ────────────────────────────────────────────────
if original.bifrost.primary_model != saved.bifrost.primary_model {
changed.push("bifrost.primary_model");
if let Some(chat) = &self.chat {
let new_model = saved.bifrost.primary_model.clone();
if let Err(e) = chat.backend.update_agent_model(&chat.agent_id, &new_model).await {
tracing::warn!(
agent = %chat.agent_id,
error = %e,
"failed to sync primary_model to SQLite"
);
} else {
tracing::info!(
agent = %chat.agent_id,
from = %original.bifrost.primary_model,
to = %new_model,
"synced primary_model to SQLite"
);
}
}
changed.push("bifrost.primary_model (new-agent default)");
}
if !changed.is_empty() {
@ -1248,6 +1292,12 @@ impl App {
view.set_expressions_path(expr_path);
}
}
// Per-agent settings follow the active agent — load its model
// so the Agent category edits this agent and tracks switches.
let active = self.active_agent_settings();
if let Some(view) = self.settings.as_mut() {
view.set_active_agent(active);
}
self.current_screen = Screen::Settings;
}
_ => {}

View file

@ -105,6 +105,7 @@ impl Category {
pub enum FieldLoc {
// Agent
AgSystemPrompt,
AgModel,
// Bifrost
BfBaseUrl,
BfApiKey,
@ -196,7 +197,7 @@ pub enum FieldLoc {
impl FieldLoc {
pub fn category(&self) -> Category {
match self {
FieldLoc::AgSystemPrompt => Category::Agent,
FieldLoc::AgSystemPrompt | FieldLoc::AgModel => Category::Agent,
FieldLoc::BfBaseUrl | FieldLoc::BfApiKey | FieldLoc::BfVirtualKey | FieldLoc::BfPrimaryModel | FieldLoc::BfTimeoutSecs => Category::Bifrost,
FieldLoc::ScN1Enabled | FieldLoc::ScN1Trigger | FieldLoc::ScN1Every | FieldLoc::ScN1Secs
| FieldLoc::ScInboxEnabled | FieldLoc::ScModel | FieldLoc::ScMaxTokens
@ -222,6 +223,7 @@ impl FieldLoc {
pub fn key(&self) -> &'static str {
match self {
FieldLoc::AgSystemPrompt => "system_prompt",
FieldLoc::AgModel => "model",
FieldLoc::BfBaseUrl => "base_url",
FieldLoc::BfApiKey => "api_key",
FieldLoc::BfVirtualKey => "virtual_key",
@ -300,6 +302,7 @@ impl FieldLoc {
pub fn label(&self) -> &'static str {
match self {
FieldLoc::AgSystemPrompt => "platform prompt",
FieldLoc::AgModel => "agent model",
FieldLoc::BfBaseUrl => "endpoint",
FieldLoc::BfApiKey => "API key",
FieldLoc::BfVirtualKey => "virtual key",
@ -377,7 +380,7 @@ impl FieldLoc {
pub fn applies_live(&self) -> bool {
matches!(
self,
FieldLoc::PrAtmosphere | FieldLoc::PrOutfit | FieldLoc::BfPrimaryModel
FieldLoc::PrAtmosphere | FieldLoc::PrOutfit | FieldLoc::BfPrimaryModel | FieldLoc::AgModel
)
}
}
@ -466,6 +469,22 @@ pub enum SettingsMode {
// ── Main state ──────────────────────────────────────────────────────────────
/// The active agent's per-agent settings, loaded into the view so the Agent
/// category edits *this agent* — not substrate-wide config. `None` when
/// Settings is opened with no resolvable agent / backend (the per-agent
/// fields are then simply not shown).
#[derive(Debug, Clone, Default)]
pub struct ActiveAgentSettings {
/// Agent id — the save target.
pub id: String,
/// Display name, so the field shows which agent is being edited.
pub name: String,
/// Editable working copy of the agent's llm model handle.
pub model: String,
/// The model as loaded — for save-time diffing.
pub model_original: String,
}
pub struct SettingsView {
/// Working copy of config — all edits go here.
pub config: ConsciousnessConfig,
@ -493,6 +512,10 @@ pub struct SettingsView {
/// Atmosphere-derived colour palette. Used for coloured values and accents.
pub palette: ChatPalette,
/// The agent the Settings screen is editing per-agent fields for. Set by
/// App on entry from the active agent, so Settings follows agent switches.
pub active_agent: Option<ActiveAgentSettings>,
}
impl SettingsView {
@ -510,9 +533,26 @@ impl SettingsView {
models_rx: None,
models_fetching: false,
palette: ChatPalette::default(),
active_agent: None,
}
}
/// Load the active agent's per-agent settings into the view. Called by App
/// on Settings entry so the Agent category edits the agent we're working
/// as — and follows when the user switches agents. `None` clears it.
pub fn set_active_agent(&mut self, agent: Option<ActiveAgentSettings>) {
self.active_agent = agent;
}
/// Whether the active agent's model was changed and needs pushing back to
/// the agent record on save.
pub fn agent_model_dirty(&self) -> bool {
self.active_agent
.as_ref()
.map(|a| a.model != a.model_original)
.unwrap_or(false)
}
/// Called each tick. Drains the model fetch receiver if one is pending.
pub fn poll_models_rx(&mut self) {
if let Some(rx) = self.models_rx.as_mut() {
@ -562,6 +602,20 @@ impl SettingsView {
match cat {
Category::Agent => {
out.push((AgSystemPrompt, EditableValue::OptionalText(self.config.agent.system_prompt.clone())));
// Per-agent model — edits the active agent's llm_config.model,
// not the substrate-wide bifrost.primary_model default.
if let Some(agent) = &self.active_agent {
if self.available_models.is_empty() {
out.push((AgModel, EditableValue::Text(agent.model.clone())));
} else {
let mut variants = self.available_models.clone();
if !agent.model.is_empty() && !variants.contains(&agent.model) {
variants.insert(0, agent.model.clone());
}
let idx = variants.iter().position(|m| m == &agent.model).unwrap_or(0);
out.push((AgModel, EditableValue::EnumVariant { index: idx, variants }));
}
}
}
Category::Bifrost => {
out.push((BfBaseUrl, EditableValue::Text(self.config.bifrost.base_url.clone())));
@ -792,6 +846,17 @@ impl SettingsView {
self.config.agent.system_prompt = v;
}
}
AgModel => {
if let Some(agent) = &mut self.active_agent {
match value {
EditableValue::Text(v) => agent.model = v,
EditableValue::EnumVariant { index, variants } => {
if let Some(m) = variants.get(index) { agent.model = m.clone(); }
}
_ => {}
}
}
}
BfBaseUrl => { if let EditableValue::Text(v) = value { self.config.bifrost.base_url = v; } }
BfApiKey => { if let EditableValue::Text(v) = value { self.config.bifrost.api_key = v; } }
BfVirtualKey => { if let EditableValue::Text(v) = value { self.config.bifrost.virtual_key = v; } }