Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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 .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"crates/rho": "1.33.1",
"crates/rho-sdk": "1.17.3",
"crates/rho-sdk": "1.18.0",
"crates/rho-providers": "0.18.2",
"crates/rho-tools": "0.14.0"
}
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/rho-providers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ repository = "https://github.com/matthewyjiang/rho"
readme = "README.md"

[dependencies]
rho-sdk = { version = "1.17.3", path = "../rho-sdk" }
rho-sdk = { version = "1.18.0", path = "../rho-sdk" }
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json", "stream"] }
serde = { version = "1", features = ["derive"] }
Expand Down
2 changes: 1 addition & 1 deletion crates/rho-sdk/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "rho-sdk"
version = "1.17.3"
version = "1.18.0"
edition = "2021"
rust-version = "1.86"
description = "Embeddable, headless agent runtime for Rho"
Expand Down
10 changes: 10 additions & 0 deletions crates/rho-sdk/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,16 @@ impl Session {
self.core.commit(history)
}

/// Replaces committed history while the session is idle.
///
/// Hosts use this after durable persistence fails following
/// [`Self::append_message`], so model-visible history stays aligned with
/// storage instead of remaining one message ahead of a failed snapshot.
pub fn replace_history(&self, history: Vec<Message>) -> Result<Revision, Error> {
let _inactive = self.core.lock_inactive()?;
self.core.commit(history)
}

pub fn reset(&self) -> Result<(), Error> {
let _inactive = self.core.lock_inactive()?;
let system_prompt = match &self.core.runtime().system_prompt {
Expand Down
20 changes: 20 additions & 0 deletions crates/rho-sdk/src/session_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,23 @@ async fn stale_finalization_cannot_clear_a_newer_run_owner() {
assert!(!session.is_running());
assert_eq!(session.state(), SessionState::Idle);
}

#[tokio::test]
async fn replace_history_restores_model_visible_state_after_a_failed_host_persist() {
let runtime = Rho::builder()
.provider(ScriptedProvider::new(identity(), []))
.build()
.unwrap();
let session = runtime.session(SessionOptions::default()).await.unwrap();
let before = session.history();

session
.append_message(crate::model::Message::user_text(
"notice that never persisted",
))
.unwrap();
assert_eq!(session.history().len(), before.len() + 1);

session.replace_history(before.clone()).unwrap();
assert_eq!(session.history(), before);
}
2 changes: 1 addition & 1 deletion crates/rho-tools/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ document-pdf = ["dep:flate2", "dep:lopdf", "dep:pdf-inspector"]
document-spreadsheets = ["dep:calamine", "dep:zip"]

[dependencies]
rho-sdk = { version = "1.17.3", path = "../rho-sdk" }
rho-sdk = { version = "1.18.0", path = "../rho-sdk" }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Expand Down
2 changes: 1 addition & 1 deletion crates/rho/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ name = "rho"
path = "src/main.rs"

[dependencies]
rho-sdk = { version = "1.17.3", path = "../rho-sdk" }
rho-sdk = { version = "1.18.0", path = "../rho-sdk" }
rho-providers = { version = "0.18.2", path = "../rho-providers", default-features = false }
rho-tools = { version = "0.14.0", path = "../rho-tools", package = "rho-agent-tools" }
tokio = { version = "1", features = ["full"] }
Expand Down
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
22 changes: 21 additions & 1 deletion crates/rho/src/app/config_repository.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
use std::path::PathBuf;
#[cfg(test)]
use std::sync::Arc;
use std::{cell::Cell, sync::Arc};

use crate::config::Config;

#[cfg(test)]
thread_local! {
/// When set, the next [`ConfigRepository::save`] fails after load/mutation so
Comment thread
pullfrog[bot] marked this conversation as resolved.
Outdated
/// callers can exercise durable-save rollback without OS-specific FS locks.
static FAIL_NEXT_SAVE: Cell<bool> = const { Cell::new(false) };
}

/// Loads and persists the application configuration at one configured path.
#[derive(Clone, Debug)]
pub(crate) struct ConfigRepository {
Expand All @@ -30,6 +37,13 @@ impl ConfigRepository {
})
}

/// Fail the next `save` once. Used by tests that need load+mutate to succeed
/// and only the durable write to error.
#[cfg(test)]
pub(crate) fn fail_next_save_for_tests() {
FAIL_NEXT_SAVE.with(|flag| flag.set(true));
}

pub(crate) fn configured_path(&self) -> anyhow::Result<PathBuf> {
self.path
.clone()
Expand All @@ -42,6 +56,12 @@ impl ConfigRepository {
}

pub(crate) fn save(&self, config: &Config) -> anyhow::Result<()> {
#[cfg(test)]
{
if FAIL_NEXT_SAVE.with(|flag| flag.replace(false)) {
anyhow::bail!("injected config save failure");
}
}
config.save(self.path.clone())
}

Expand Down
59 changes: 50 additions & 9 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 @@ -713,13 +714,45 @@ impl InteractiveRuntime {
model: String,
display: String,
) -> anyhow::Result<()> {
self.sessions
.session()
.append_message(Message::user_text(model))?;
self.sessions
.save_snapshot(&[Message::user_text(display)])?;
self.refresh_context_usage();
Ok(())
let session = self.sessions.session();
let history_before = session.history();
session.append_message(Message::user_text(model))?;

let save_result = {
#[cfg(test)]
{
if advisor::take_fail_next_advisor_notice_snapshot_save_for_tests() {
Err(anyhow::anyhow!(
"injected advisor switch notice snapshot save failure"
))
} else {
self.sessions.save_snapshot(&[Message::user_text(display)])
}
}
#[cfg(not(test))]
{
self.sessions.save_snapshot(&[Message::user_text(display)])
}
};

match save_result {
Ok(()) => {
self.refresh_context_usage();
Ok(())
}
Err(error) => {
// Append already advanced model-visible history. Roll it back so a
// failed durable write cannot leave the live session describing a
// notice the host never persisted.
if let Err(rollback_error) = self.sessions.session().replace_history(history_before)
{
return Err(error.context(format!(
"failed to roll back live history after snapshot save failure: {rollback_error}"
)));
}
Err(error)
}
}
}

pub(crate) async fn shutdown(&mut self) {
Expand Down Expand Up @@ -889,3 +922,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
}
97 changes: 80 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,116 @@ use super::super::{

use super::InteractiveRuntime;

#[cfg(test)]
thread_local! {
/// When set, the next advisor notice appends model-visible history, then
/// fails snapshot persistence so rollback must cover the partial commit.
static FAIL_NEXT_ADVISOR_NOTICE_SNAPSHOT_SAVE: 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_SNAPSHOT_SAVE.with(|flag| flag.set(true));
}

#[cfg(test)]
pub(super) fn take_fail_next_advisor_notice_snapshot_save_for_tests() -> bool {
FAIL_NEXT_ADVISOR_NOTICE_SNAPSHOT_SAVE.with(|flag| flag.replace(false))
}

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();
let history_before = self.sessions.history();
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. Also restore model-visible history when a
// partial append-before-save left a notice in place.
store.set_model(previous_model);
self.tools.set_advisor_registered(previous_registered);
if self.sessions.history() != history_before {
let _ = self.sessions.session().replace_history(history_before);
}
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> {
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
Loading
Loading