use std::{future::Future, sync::Arc};
use agent_client_protocol::schema::v1::PromptResponse;
use rho_sdk::{model::Message, SessionOptions, UserInput};
use serde::{Deserialize, Serialize};
use super::{
build_session,
commands::{AgentTask, CommandRejected},
model_scoped_config, session_config, session_options_for_id, split_assembly, AcpClientPort,
AcpStartup, SessionHost, StoredSession,
};
use crate::{
agent::{AgentCatalog, AgentDefinition, AgentRuntimeSpec},
app::{
agent_binding::{
AgentBinder, AgentInvocation, AgentRole, BoundAgent, BoundRuntime, ModelOverride,
},
session_assembly::SessionAssembly,
},
config::Config,
};
const MODEL_OVERRIDE_METADATA: &str = "rho.acp.agent_model_override";
#[derive(Deserialize, Serialize)]
struct SavedModelOverride {
provider: String,
model: String,
auth: String,
}
pub(super) fn decorate_model_override(
snapshot: rho_sdk::SessionSnapshot,
auth: &str,
) -> rho_sdk::SessionSnapshot {
if !snapshot.metadata().contains_key(MODEL_OVERRIDE_METADATA) {
return snapshot;
}
let identity = snapshot.provider();
let saved = SavedModelOverride {
provider: identity.provider.clone(),
model: identity.model.clone(),
auth: auth.to_owned(),
};
snapshot.with_metadata(
MODEL_OVERRIDE_METADATA,
serde_json::to_string(&saved).expect("model selection contains only strings"),
)
}
impl SessionHost {
pub(super) fn validate_model_override_switch(&self, config: &Config) -> anyhow::Result<()> {
if !self
.built
.session
.snapshot()
.metadata()
.contains_key(MODEL_OVERRIDE_METADATA)
{
return Ok(());
}
AgentBinder::bind_with_model_override(
Arc::new(self.agent.definition().clone()),
AgentInvocation {
role: AgentRole::Delegated,
available_tools: self.agent.rho_capabilities().cloned().unwrap_or_default(),
},
config,
Some(ModelOverride::Saved {
provider: &config.provider,
model: &config.model,
auth: &config.auth,
}),
)?;
Ok(())
}
pub(super) fn persist_model_override(&self) -> anyhow::Result<()> {
let Some(stored) = self.storage.file() else {
return Ok(());
};
let snapshot = self.durable_snapshot();
if snapshot.metadata().contains_key(MODEL_OVERRIDE_METADATA) {
stored.save_snapshot(&snapshot, &[])?;
}
Ok(())
}
pub(super) async fn run_as_agent(
&mut self,
display: &UserInput,
task: AgentTask,
startup: &AcpStartup,
client: &dyn AcpClientPort,
) -> anyhow::Result<PromptResponse> {
let catalog = AgentCatalog::discover(self.storage.cwd())?;
let definition = catalog.find(&task.agent_id)?.definition.clone();
let model_override = task.model_override.clone();
let cwd = self.storage.cwd().to_path_buf();
let added_dirs = self.added_dirs.clone();
self.run_as_agent_with(
display,
task,
startup,
client,
|base| {
bind_delegated_agent(
definition,
model_override.as_deref().map(ModelOverride::Reference),
startup,
base,
)
},
async |agent, config, id| {
build_session(startup, &agent, &config, &cwd, &added_dirs, |_| {
Ok(session_options_for_id(id))
})
.await
},
)
.await
}
async fn run_as_agent_with<Build, BuildFuture>(
&mut self,
display: &UserInput,
task: AgentTask,
startup: &AcpStartup,
client: &dyn AcpClientPort,
bind: impl FnOnce(&Config) -> anyhow::Result<BoundAgent>,
build: Build,
) -> anyhow::Result<PromptResponse>
where
Build: FnOnce(BoundAgent, Config, rho_sdk::SessionId) -> BuildFuture,
BuildFuture: Future<Output = anyhow::Result<SessionAssembly>>,
{
let has_history = self
.built
.session
.history()
.iter()
.any(|message| match message {
Message::System(_) => false,
Message::User(_)
| Message::Assistant(_)
| Message::EnrichedAssistant(_)
| Message::AbortedAssistant(_)
| Message::ToolResult(_) => true,
});
if self.completed_runs != 0 || has_history {
return Err(CommandRejected(
"/agent:<name> starts a fresh session only; create a new session".into(),
)
.into());
}
let mut base = model_scoped_config(&self.current_model(), &startup.config);
base.permission_mode = self.permission.mode;
base.reasoning = self.built.session.reasoning_level();
let bound = bind(&base)?;
let config = match bound.runtime() {
BoundRuntime::Rho { config, .. } => session_config(config, &self.host_mcp_servers),
BoundRuntime::ClaudeCli { .. }
| BoundRuntime::Cursor { .. }
| BoundRuntime::Antigravity { .. } => {
return Err(CommandRejected(format!(
"{} runs on {}, which an ACP session cannot host",
bound.id(),
bound.definition().runtime.runtime()
))
.into());
}
};
let id = self.built.session.id().clone();
let saved_override = task
.model_override
.as_ref()
.map(|_| {
serde_json::to_string(&SavedModelOverride {
provider: config.provider.clone(),
model: config.model.clone(),
auth: config.auth.clone(),
})
})
.transpose()?;
let mut assembly = build(bound.clone(), config.clone(), id).await?;
if let Some(saved) = saved_override {
let snapshot = assembly
.built
.session
.snapshot()
.with_metadata(MODEL_OVERRIDE_METADATA, saved);
match assembly
.built
.runtime
.rebind_session(SessionOptions::from_snapshot(snapshot))
.await
{
Ok(session) => assembly.built.session = session,
Err(error) => {
assembly.built.teardown().await;
return Err(error.into());
}
}
}
let initial_snapshot = task.model_override.as_ref().map(|_| {
super::persist::decorate(
&assembly.built.prompt,
&assembly.built.tools.todo_state(),
assembly.built.tools.code_mode(),
&self.added_dirs,
&config.auth,
assembly.built.session.snapshot(),
)
});
let rebound = self.stored().and_then(|stored| {
stored.rebind_fresh_delegated_agent(
bound.id().as_str(),
&bound.fingerprint().to_string(),
initial_snapshot.as_ref(),
)
});
if let Err(error) = rebound {
assembly.built.teardown().await;
return Err(error);
}
let (built, permission) = split_assembly(assembly, config.permission_mode);
let old = std::mem::replace(&mut self.built, built);
old.teardown().await;
self.permission = permission;
self.auth = config.auth.clone();
tracing::debug!(previous = %self.agent.id(), agent = %bound.id(), "rebound ACP agent");
self.agent = bound;
self.built.tools.bind_recall(self.storage.file());
self.refresh_advertisements(&startup.config, client).await;
self.run_turn(display, task.input, Vec::new(), client).await
}
}
pub(super) fn loaded_agent(
startup: &AcpStartup,
stored: &StoredSession,
config: Config,
catalog: impl FnOnce() -> anyhow::Result<AgentCatalog>,
) -> anyhow::Result<(BoundAgent, Config)> {
loaded_agent_with(startup, stored, config, catalog, bind_delegated_agent)
}
fn loaded_agent_with(
startup: &AcpStartup,
stored: &StoredSession,
config: Config,
catalog: impl FnOnce() -> anyhow::Result<AgentCatalog>,
bind: impl FnOnce(
AgentDefinition,
Option<ModelOverride<'_>>,
&AcpStartup,
&Config,
) -> anyhow::Result<BoundAgent>,
) -> anyhow::Result<(BoundAgent, Config)> {
let Some(stored_id) = stored.delegated_agent_id()? else {
return Ok((startup.agent.clone(), config));
};
let definition = catalog()?.find(&stored_id)?.definition.clone();
stored.validate_agent_definition_identity(&definition)?;
let model_override = stored
.session_metadata(MODEL_OVERRIDE_METADATA)?
.map(|encoded| serde_json::from_str::<SavedModelOverride>(&encoded))
.transpose()?;
let selection = model_override.as_ref().map(|saved| ModelOverride::Saved {
provider: &saved.provider,
model: &saved.model,
auth: &saved.auth,
});
let bound = bind(definition, selection, startup, &config)?;
let config = bound
.rho_config()
.cloned()
.ok_or_else(|| anyhow::anyhow!("agent '{stored_id}' is not a Rho agent"))?;
if let Some(saved) = model_override {
anyhow::ensure!(
(config.provider.as_str(), config.model.as_str(), config.auth.as_str())
== (saved.provider.as_str(), saved.model.as_str(), saved.auth.as_str()),
"agent '{stored_id}': saved model override {}/{} with auth '{}' resolved to {}/{} with auth '{}'; refusing to change the resumed selection",
saved.provider, saved.model, saved.auth, config.provider, config.model, config.auth,
);
}
Ok((bound, config))
}
fn bind_delegated_agent(
definition: AgentDefinition,
model_override: Option<ModelOverride<'_>>,
startup: &AcpStartup,
base: &Config,
) -> anyhow::Result<BoundAgent> {
match &definition.runtime {
AgentRuntimeSpec::Rho { .. } => {}
AgentRuntimeSpec::ClaudeCli(_)
| AgentRuntimeSpec::Cursor(_)
| AgentRuntimeSpec::Antigravity(_) => {
return Err(CommandRejected(format!(
"{} runs on {}, which an ACP session cannot host",
definition.id,
definition.runtime.runtime()
))
.into());
}
}
AgentBinder::bind_with_model_override(
Arc::new(definition),
AgentInvocation {
role: AgentRole::Delegated,
available_tools: startup
.agent
.rho_capabilities()
.cloned()
.unwrap_or_default(),
},
base,
model_override,
)
}
#[cfg(test)]
#[path = "session_host_agent_tests.rs"]
mod tests;