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:
parent
f0290f7eed
commit
a2d89036c2
2 changed files with 141 additions and 26 deletions
|
|
@ -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;
|
||||
}
|
||||
_ => {}
|
||||
|
|
|
|||
|
|
@ -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; } }
|
||||
|
|
|
|||
Loading…
Reference in a new issue