Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
a122aa6
feat(config): apply edit tool and advisor changes mid-session
matthewyjiang Aug 10, 2026
7d028e7
feat(config): add auto edit tool preference by provider
matthewyjiang Aug 10, 2026
df7e296
feat(config): prefer str_replace for xai in auto edit tool
matthewyjiang Aug 10, 2026
ccd343f
docs(config): explain auto edit tool first-party harness defaults
matthewyjiang Aug 10, 2026
fe0120c
fix(tui): speed up tool expand without rebuilding history suffix
matthewyjiang Aug 10, 2026
8ed0e8f
refactor(config): consolidate mid-session edit tool apply path
matthewyjiang Aug 10, 2026
ce6c51b
fix(config): harden mid-session advisor and edit-tool transitions
matthewyjiang Aug 10, 2026
701b345
test(tui): cover Auto edit tool handoff on provider switch
matthewyjiang Aug 10, 2026
062df1c
fix(ci): satisfy rustfmt and clippy on edit-tool branch
matthewyjiang Aug 10, 2026
fcb25aa
fix(runtime): roll back history when advisor notice persistence fails
matthewyjiang Aug 10, 2026
b6c57e5
fix(tui): harden edit-tool switch, history cache, and syntax restart …
matthewyjiang Aug 10, 2026
4a244e1
build(sdk): cut 1.18.0 for Session::replace_history
matthewyjiang Aug 10, 2026
b657548
fix(ci): refresh downstream fixture lockfile for rho-sdk 1.18.0
matthewyjiang Aug 10, 2026
f4ba0a0
fix(tui): align edit-tool rollback notices across histories
matthewyjiang Aug 10, 2026
81cf87d
fix(test): make edit-tool save-failure coverage OS-independent
matthewyjiang Aug 10, 2026
c660b97
fix(test): scope config save-failure injection to the repository
matthewyjiang Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/rho/src/app/automation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,7 @@ async fn run_session_with_output(
workspace,
workspace_policy: AppPolicy::for_mode(startup.config.permission_mode),
approval_session: startup.approval_session.clone(),
system_prompt: system_prompt.for_advisor_mode(tool_set.advisor_registered()),
system_prompt,
reasoning: sdk_options.runtime.reasoning,
service_tier: sdk_options.runtime.service_tier,
compaction,
Expand Down
13 changes: 11 additions & 2 deletions crates/rho/src/app/interactive_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ use {

#[path = "interactive_runtime_advisor.rs"]
mod advisor;
#[path = "interactive_runtime_edit_tool.rs"]
pub(crate) mod edit_tool;
#[path = "interactive_runtime_hooks.rs"]
mod session_hooks;
#[path = "interactive_runtime_startup.rs"]
Expand All @@ -29,7 +31,6 @@ use super::{
policy::AppPolicy,
provider_controller::ProviderController,
runtime_builder::{build_compaction, build_runtime, RuntimeBuildOptions},
tools_prompt::SystemPromptVariants,
};

pub(crate) use super::interactive_run_controller::{
Expand Down Expand Up @@ -72,7 +73,7 @@ pub(crate) struct InteractiveRuntime {
mcp_report: crate::tools::mcp::McpSessionReport,
plugins_report: crate::plugins::PluginLoadReport,
workspace: Workspace,
system_prompt: SystemPromptVariants,
system_prompt: rho_sdk::SystemPrompt,
compaction: CompactionConfig,
context_window: Option<u64>,
usage_recording: rho_sdk::ProviderRequestUsageRecording,
Expand Down Expand Up @@ -889,3 +890,11 @@ impl InteractiveRuntime {
#[cfg(test)]
#[path = "interactive_runtime_tests.rs"]
mod tests;

/// Test factory for TUI seams that need a live edit-capable runtime.
#[cfg(test)]
pub(crate) async fn test_edit_tool_runtime(
edit_tool: crate::config::EditTool,
) -> InteractiveRuntime {
tests::edit_tool_runtime(edit_tool).await
}
89 changes: 72 additions & 17 deletions crates/rho/src/app/interactive_runtime_advisor.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
//! Advisor mode as a runtime state transition.
//!
//! Advisor mode changes the tool list and the system prompt, neither of which
//! the SDK can swap on a live runtime. Turning it on or off therefore rebuilds
//! the runtime and rebinds the session, the same move a permission-mode change
//! makes, so the change lands on the next turn and the session ID and history
//! survive it.
//! Advisor mode changes the advertised tool list, which the SDK cannot swap on
//! a live runtime. Turning it on or off therefore rebuilds the runtime and
//! rebinds the session so the change lands on the next turn. The session ID and
//! history survive it.
//!
//! The system prompt stays fixed for prompt-cache stability. The model learns
//! about the tool list change from an appended context notice (with the tool
//! schema when enabling) rather than a rewritten system prompt.

use std::sync::Arc;

Expand All @@ -19,56 +22,108 @@ use super::super::{

use super::InteractiveRuntime;

#[cfg(test)]
thread_local! {
static FAIL_NEXT_ADVISOR_NOTICE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}

#[cfg(test)]
pub(crate) fn fail_next_advisor_switch_notice_for_tests() {
FAIL_NEXT_ADVISOR_NOTICE.with(|flag| flag.set(true));
}

impl InteractiveRuntime {
/// The system prompt for the tools this run currently offers.
/// Fixed system prompt for this session.
///
/// Mid-session tool list changes keep this value stable and tell the model
/// through appended context instead.
pub(super) fn active_system_prompt(&self) -> SystemPrompt {
self.system_prompt
.for_advisor_mode(self.tools.advisor_registered())
self.system_prompt.clone()
}

/// Applies an advisor mode or advisor model change to the next turn.
///
/// `model` is the advisor model to use, or `None` when advisor mode is off
/// or has no model yet; those are the same thing to the executor. The live
/// tool reads the new model at once. Registering or removing the `advisor`
/// tool also changes the tool list and the system prompt, so it needs the
/// same runtime rebuild as a permission-mode change; the session ID and
/// history survive it.
/// tool rebuilds the runtime without rewriting the system prompt, then
/// appends a context notice. Returns display text for a transcript notice
/// when the tool list changed.
pub(crate) async fn set_advisor(
&mut self,
model: Option<InternalAgentModelConfig>,
) -> anyhow::Result<()> {
) -> anyhow::Result<Option<String>> {
let Some(store) = self.tools.advisor().cloned() else {
return Ok(());
return Ok(None);
};
let registered = model.is_some();
if registered == self.tools.advisor_registered() {
store.set_model(model);
return Ok(());
return Ok(None);
}
if self.runs.is_active() {
anyhow::bail!("advisor mode cannot change while a run is active");
}

// The model lands only after the rebuild succeeds, so a failed
// transition leaves both the tool list and the store untouched.
let previous_registered = self.tools.advisor_registered();
let previous_model = store.model();
self.tools.set_advisor_registered(registered);
match self.rebind_current_session().await {
Ok(()) => {
store.set_model(model);
Ok(())
match self.append_advisor_switch_notice(registered) {
Ok(display) => Ok(Some(display)),
Err(error) => {
// Mirror edit-tool: a notice failure must not leave the
// session advertising a tool list the model was never
// told about.
store.set_model(previous_model);
self.tools.set_advisor_registered(previous_registered);
let _ = self.rebind_current_session().await;
Err(error)
}
}
}
Err(error) => {
self.tools.set_advisor_registered(!registered);
self.tools.set_advisor_registered(previous_registered);
Err(error)
}
}
}

fn append_advisor_switch_notice(&mut self, enabled: bool) -> anyhow::Result<String> {
#[cfg(test)]
if FAIL_NEXT_ADVISOR_NOTICE.with(std::cell::Cell::get) {
Comment thread
pullfrog[bot] marked this conversation as resolved.
Outdated
FAIL_NEXT_ADVISOR_NOTICE.with(|flag| flag.set(false));
anyhow::bail!("injected advisor switch notice failure");
}
let (model, display) = if enabled {
let spec = self
.tools
.specs()
.into_iter()
.find(|spec| spec.name == crate::tools::advisor::TOOL_NAME)
.ok_or_else(|| {
anyhow::anyhow!("advisor tool is missing after it was registered")
})?;
crate::prompt::advisor_enabled_context(&spec)
} else {
crate::prompt::advisor_disabled_context()
};
self.append_user_context_with_display(model, display.clone())?;
Ok(display)
}

/// Rebuilds the SDK runtime around the current tools and prompt, then
/// rebinds the live session onto it. The live runtime is replaced only
/// after the replacement is ready, so a failure leaves the session intact.
async fn rebind_current_session(&mut self) -> anyhow::Result<()> {
///
/// Callers that change the advertised tool list should keep the system
/// prompt fixed for prompt-cache stability and tell the model about the
/// change with an appended context message instead.
pub(super) async fn rebind_current_session(&mut self) -> anyhow::Result<()> {
let snapshot = self.sessions.session().snapshot();
let replacement_runtime = build_runtime(RuntimeBuildOptions {
provider: Arc::clone(self.provider.provider()),
Expand Down
78 changes: 78 additions & 0 deletions crates/rho/src/app/interactive_runtime_edit_tool.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//! File edit tool as a runtime state transition.
//!
//! Swapping the advertised edit surface changes the tool list, which the SDK
//! cannot hot-swap on a live runtime. The change rebuilds the runtime and
//! rebinds the session so it lands on the next turn. Session ID and history
//! survive it.
//!
//! The system prompt stays fixed and format-agnostic. The model learns about the
//! new surface from an appended context notice that carries the live schema.

use super::InteractiveRuntime;

/// Result of a successful mid-session edit-tool switch.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct EditToolChange {
pub(crate) previous: rho_tools::EditFormat,
pub(crate) display: String,
}

impl InteractiveRuntime {
/// Swaps the advertised file edit tool for the next turn.
///
/// Keeps the system prompt fixed for prompt-cache stability, rebuilds the
/// runtime tool list, and appends a model-facing schema notice when the
/// surface actually changes. Returns [`None`] when this run has no edit tool
/// or the selection is already active.
pub(crate) async fn set_edit_tool(
&mut self,
edit_tool: rho_tools::EditFormat,
max_output_bytes: usize,
) -> anyhow::Result<Option<EditToolChange>> {
if self.runs.is_active() {
anyhow::bail!("edit tool cannot change while a run is active");
}
let Some(previous) = self.tools.set_edit_tool(edit_tool, max_output_bytes) else {
return Ok(None);
};
if let Err(error) = self.rebind_current_session().await {
let _ = self.tools.set_edit_tool(previous, max_output_bytes);
return Err(error);
}
match self.append_edit_tool_switch_notice(previous, edit_tool) {
Ok(display) => Ok(Some(EditToolChange { previous, display })),
Err(error) => {
// Best-effort restore so a notice failure does not leave the
// session advertising a tool the model was never told about.
let _ = self.tools.set_edit_tool(previous, max_output_bytes);
let _ = self.rebind_current_session().await;
Err(error)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

fn append_edit_tool_switch_notice(
&mut self,
previous: rho_tools::EditFormat,
current: rho_tools::EditFormat,
) -> anyhow::Result<String> {
let spec = self
.tools
.specs()
.into_iter()
.find(|spec| spec.name == current.tool_name())
.ok_or_else(|| {
anyhow::anyhow!(
"edit tool `{}` is missing after the mid-session switch",
current.tool_name()
)
})?;
let (model, display) = crate::prompt::edit_tool_switch_context(previous, current, &spec);
self.append_user_context_with_display(model, display.clone())?;
Ok(display)
}

pub(crate) fn tool_specs(&self) -> Vec<rho_sdk::model::ToolSpec> {
self.tools.specs()
}
}
2 changes: 1 addition & 1 deletion crates/rho/src/app/interactive_runtime_startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ pub(super) async fn initialize(
approval_session: approval_handler
.clone()
.map(rho_sdk::ApprovalSession::from_shared),
system_prompt: system_prompt.for_advisor_mode(tools.advisor_registered()),
system_prompt: system_prompt.clone(),
reasoning: sdk_options.runtime.reasoning,
service_tier: sdk_options.runtime.service_tier,
compaction: compaction.clone(),
Expand Down
Loading
Loading