fix: heartbeat hygiene, /model multi-tenant guard, RigAdapter model override

Three follow-up fixes for multi-tenant isolation:

1. Multi-user heartbeat now runs memory hygiene per user before each
   heartbeat check, matching single-user heartbeat behavior.

2. /model command in multi-tenant mode only persists to per-user
   settings (selected_model) without calling set_model() on the shared
   LlmProvider. The per-request model_override in the dispatcher reads
   from the same setting. Added multi_tenant flag to AgentConfig
   (auto-detected from GATEWAY_USER_TOKENS).

3. RigAdapter now supports per-request model overrides by injecting the
   model name into rig-core's additional_params. OpenAI/Anthropic/Ollama
   API servers use last-key-wins for duplicate JSON keys, so the override
   takes effect via serde's flatten serialization order.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
2026-03-23 23:29:26 -07:00
co-authored by Claude Opus 4.6
parent af5daca0d9
commit 9ff4af5734
6 changed files with 86 additions and 33 deletions
+25 -12
View File
@@ -663,19 +663,32 @@ impl Agent {
}
}
match self.llm().set_model(requested) {
Ok(()) => {
// Persist the model choice so it survives restarts.
self.persist_selected_model(requested).await;
Ok(SubmissionResult::response(format!(
"Switched model to: {}",
requested
)))
if self.config.multi_tenant {
// Multi-tenant: only persist to per-user settings.
// Do NOT call set_model() on the shared provider — that
// would change the default for all users. The per-request
// model_override in the dispatcher reads from the same
// "selected_model" setting and applies it per-user.
self.persist_selected_model(requested).await;
Ok(SubmissionResult::response(format!(
"Model preference set to: {} (per-user)",
requested
)))
} else {
match self.llm().set_model(requested) {
Ok(()) => {
// Persist the model choice so it survives restarts.
self.persist_selected_model(requested).await;
Ok(SubmissionResult::response(format!(
"Switched model to: {}",
requested
)))
}
Err(e) => Ok(SubmissionResult::error(format!(
"Failed to switch model: {}",
e
))),
}
Err(e) => Ok(SubmissionResult::error(format!(
"Failed to switch model: {}",
e
))),
}
}
}
+3
View File
@@ -1277,6 +1277,7 @@ mod tests {
auto_approve_tools: false,
default_timezone: "UTC".to_string(),
max_tokens_per_job: 0,
multi_tenant: false,
},
deps,
Arc::new(ChannelManager::new()),
@@ -2146,6 +2147,7 @@ mod tests {
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
max_tokens_per_job: 0,
multi_tenant: false,
},
deps,
Arc::new(ChannelManager::new()),
@@ -2268,6 +2270,7 @@ mod tests {
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
max_tokens_per_job: 0,
multi_tenant: false,
},
deps,
Arc::new(ChannelManager::new()),
+17
View File
@@ -572,6 +572,23 @@ pub fn spawn_multi_user_heartbeat(
for user_id in &user_ids {
let workspace = Arc::new(Workspace::new_with_db(user_id, store.clone()));
// Run memory hygiene per user (same as single-user heartbeat).
let hygiene_ws = Arc::clone(&workspace);
let hygiene_cfg = hygiene_config.clone();
let hygiene_user = user_id.clone();
tokio::spawn(async move {
let report =
crate::workspace::hygiene::run_if_due(&hygiene_ws, &hygiene_cfg).await;
if report.had_work() {
tracing::info!(
user_id = hygiene_user,
daily_logs_deleted = report.daily_logs_deleted,
conversation_docs_deleted = report.conversation_docs_deleted,
"multi-user heartbeat: memory hygiene deleted stale documents"
);
}
});
let mut runner = HeartbeatRunner::new(
config.clone(),
hygiene_config.clone(),
+1
View File
@@ -785,6 +785,7 @@ mod tests {
auto_approve_tools: true,
default_timezone: "UTC".to_string(),
max_tokens_per_job,
multi_tenant: false,
};
let cm = Arc::new(ContextManager::new(5));
let llm: Arc<dyn LlmProvider> = Arc::new(StubLlm);