1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
//! Conversation provider/model switches on a live interactive runtime.
//!
//! Replacing the provider is a multi-step transition: hand off the session,
//! rebuild compaction, tell the model when the conversation model changed, and
//! keep MCP sampling on the live selection. Post-replace failures roll the
//! provider back so `Err` means the active provider is unchanged whenever
//! restore itself succeeds.
use std::sync::Arc;
use rho_sdk::{provider::ModelProvider, Error};
use super::{
active_run_disposition, startup, ActiveRunCommand, ActiveRunDisposition, InteractiveRuntime,
};
impl InteractiveRuntime {
pub(crate) fn replace_provider(
&mut self,
provider: Arc<dyn ModelProvider>,
reasoning: rho_sdk::ReasoningLevel,
auth: &str,
) -> Result<rho_sdk::model::handoff::HandoffReport, Error> {
if self.runs.is_active() {
debug_assert_eq!(
active_run_disposition(ActiveRunCommand::ReplaceProvider),
ActiveRunDisposition::DeferUntilFinished
);
return Err(Error::SessionBusy);
}
self.runs.begin_provider_switch()?;
// Capture prior identity so post-replace failures can roll back and keep
// `Err` meaning "active provider unchanged" for callers.
let previous_provider = Arc::clone(self.provider.provider());
let previous_reasoning = self.provider.reasoning();
let previous_prompt_model =
crate::model_identity::PromptModel::from_sdk_identity(&previous_provider.identity());
// A first selection on an empty session is not a switch: the system
// prompt has yet to be built and will name the chosen model itself.
let session_started = !self.history().is_empty();
let report = match self
.provider
.replace(self.sessions.session(), provider, reasoning)
{
Ok(report) => report,
Err(error) => {
self.runs.finish_transition();
return Err(error);
}
};
if let Err(error) = self.refresh_compaction() {
let error = self.fail_after_provider_restore(
previous_provider,
previous_reasoning,
error,
RestoreCompaction::Skip,
);
self.runs.finish_transition();
return Err(error);
}
let identity = self.provider.provider().identity();
let current_prompt_model = crate::model_identity::PromptModel::from_sdk_identity(&identity);
// The system prompt named the model this session started on and then
// stayed fixed, so a later switch has to reach the model as context.
// Owned here (not in the TUI) so every conversation model change is
// honest, and a failed notice rolls the provider back.
if session_started && current_prompt_model != previous_prompt_model {
let (context, display) = crate::prompt::model_switch_context(
crate::prompt::ModelSwitchKind::Conversation,
¤t_prompt_model,
);
if let Err(error) = self.append_user_context_with_display(context, display) {
let error = Error::InvalidConfiguration {
message: format!(
"could not record the conversation model switch for the model: {error}"
),
};
// Compaction was rebuilt for the new provider; put it back with
// the restored provider, or report that rollback is incomplete.
let error = self.fail_after_provider_restore(
previous_provider,
previous_reasoning,
error,
RestoreCompaction::Required,
);
self.runs.finish_transition();
return Err(error);
}
}
if let Some(manager) = self.tools.subagents() {
manager.update_selection(&identity.provider, &identity.model, reasoning, auth);
}
// MCP sampling must follow the user's current model, never the one that
// happened to be selected when the servers connected.
startup::bind_mcp_sampling(
&self.mcp_sampling,
self.provider.provider(),
self.sessions.session().id(),
self.workspace.root(),
);
self.invalidate_live_context();
self.runs.finish_transition();
Ok(report)
}
/// Rolls the provider back after a post-replace step failed, optionally
/// rebuilding compaction for the restored provider.
///
/// Always returns an error for the caller to surface. When restore succeeds,
/// that error is `primary` (active provider unchanged). When restore or the
/// optional compaction rebuild fails, the error describes the incomplete
/// rollback.
fn fail_after_provider_restore(
&mut self,
previous_provider: Arc<dyn ModelProvider>,
previous_reasoning: rho_sdk::ReasoningLevel,
primary: Error,
compaction: RestoreCompaction,
) -> Error {
if let Err(rollback_error) = self.provider.replace(
self.sessions.session(),
previous_provider,
previous_reasoning,
) {
return Error::InvalidConfiguration {
message: format!(
"{primary}; also failed to restore the previous provider: {rollback_error}"
),
};
}
if matches!(compaction, RestoreCompaction::Required) {
if let Err(refresh_error) = self.refresh_compaction() {
return Error::InvalidConfiguration {
message: format!(
"{primary}; could not restore compaction for the previous provider: {refresh_error}"
),
};
}
}
primary
}
}
/// Whether a failed post-replace step must rebuild compaction after the
/// provider is restored.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RestoreCompaction {
/// Compaction never left the previous provider (e.g. the first rebuild
/// failed before the new one was installed).
Skip,
/// Compaction was rebuilt for the rejected provider and must follow the
/// restore.
Required,
}