Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,16 @@ memory_path = "~/.codewhale/memory.md"
[speech]
# output_dir = "./speech"

# ─────────────────────────────────────────────────────────────────────────────────
# Reasoning-only recovery
# ─────────────────────────────────────────────────────────────────────────────────
# When a reasoning model returns only hidden thinking without any answer text
# or tool call, the engine can re-request the answer automatically.
# Set max_reprompts = 0 to disable automatic recovery entirely.
# [reasoning_only]
# max_reprompts = 2
# reprompt_message = "So, what's up ? Keep running !"

# Native tool catalog controls (#2076). By default only the core tool surface
# is loaded into the model context; less common native tools are discoverable
# through ToolSearch and loaded on first use.
Expand Down
56 changes: 56 additions & 0 deletions crates/tui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ pub(crate) use codewhale_config::{ConfigApiKeyValueKind, classify_config_api_key
pub const DEFAULT_ZAI_PROVIDER_MAX_CONCURRENCY: usize = 3;
pub const MAX_PROVIDER_REQUEST_CONCURRENCY: usize = 64;

/// Default maximum number of automatic re-requests when a reasoning model
/// returns only hidden thinking without any answer text or tool call.
pub const DEFAULT_REASONING_ONLY_REPROMPTS: u32 = 2;

/// Default custom message sent to the model on each re-request.
/// Used when the user does not set `[reasoning_only] reprompt_message`.
pub const DEFAULT_REASONING_ONLY_REPROMPT_MESSAGE: &str = "So, what's up ? Keep running !";

pub fn default_stop_words() -> Vec<String> {
["stop", "wait", "pause"]
.into_iter()
Expand Down Expand Up @@ -2307,6 +2315,24 @@ pub struct GoalConfig {
pub continuation_delay_seconds: Option<u64>,
}

/// Reasoning-only recovery controls (`[reasoning_only]` table in config.toml).
/// When the model returns only hidden reasoning (thinking) without any answer
/// text or tool call, the engine can re-request the answer automatically.
#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
#[serde(default)]
pub struct ReasoningOnlyConfig {
/// Maximum number of automatic re-requests when the model returns only
/// reasoning without any answer or tool call.
/// Defaults to 2. Set to 0 to disable automatic recovery.
pub max_reprompts: Option<u32>,
/// Optional custom message sent to the model on each re-request.
/// When set, the engine inserts this as a user message before re-issuing
/// the request, nudging the model to produce an actual answer.
/// When unset (default), the engine uses the built-in default message
/// ("So, what's up ? Keep running !").
pub reprompt_message: Option<String>,
}

/// One configurable footer item.
///
/// Order in the user's `Vec<StatusItem>` is preserved: items in the left
Expand Down Expand Up @@ -3178,6 +3204,12 @@ pub struct Config {
#[serde(default)]
pub goal: Option<GoalConfig>,

/// Reasoning-only recovery controls. When absent, the engine uses the
/// built-in default (2 retries, no custom message). Configure with
/// `[reasoning_only] max_reprompts` and/or `[reasoning_only] reprompt_message`.
#[serde(default)]
pub reasoning_only: Option<ReasoningOnlyConfig>,

/// User-level memory (#489). Default behaviour is **opt-in**:
/// loading + injection happens only when `[memory] enabled = true` or
/// `DEEPSEEK_MEMORY=on` is set. The surviving store is the native
Expand Down Expand Up @@ -7190,6 +7222,29 @@ impl Config {
.min(crate::goal_loop::MAX_GOAL_CONTINUATION_DELAY_SECONDS)
}

/// Maximum number of automatic re-requests when the model returns only
/// reasoning without any answer or tool call. Defaults to 2.
/// Set via `[reasoning_only] max_reprompts` in config.toml.
#[must_use]
pub fn reasoning_only_max_reprompts(&self) -> u32 {
self.reasoning_only
.as_ref()
.and_then(|cfg| cfg.max_reprompts)
.unwrap_or(DEFAULT_REASONING_ONLY_REPROMPTS)
}

/// Optional custom message sent to the model on each re-request when the
/// model returns only reasoning without any answer or tool call.
/// Set via `[reasoning_only] reprompt_message` in config.toml.
/// When unset, the built-in default is used.
#[must_use]
pub fn reasoning_only_reprompt_message(&self) -> &str {
self.reasoning_only
.as_ref()
.and_then(|cfg| cfg.reprompt_message.as_deref())
.unwrap_or(DEFAULT_REASONING_ONLY_REPROMPT_MESSAGE)
}

/// Resolve the explicit local-memory backend.
#[must_use]
pub fn memory_backend(&self) -> MemoryBackend {
Expand Down Expand Up @@ -10582,6 +10637,7 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
runtime_thread_inference_unrelated: override_cfg.runtime_thread_inference_unrelated
|| base.runtime_thread_inference_unrelated,
mini_window: override_cfg.mini_window.or(base.mini_window),
reasoning_only: override_cfg.reasoning_only.or(base.reasoning_only),
title: override_cfg.title.or(base.title),
}
}
Expand Down
56 changes: 56 additions & 0 deletions crates/tui/src/config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,62 @@ continuation_delay_seconds = 999999999
Ok(())
}

#[test]
fn reasoning_only_config_loads_from_table() -> Result<()> {
// Absent table → built-in defaults.
let config: Config = toml::from_str("")?;
assert_eq!(
config.reasoning_only_max_reprompts(),
crate::config::DEFAULT_REASONING_ONLY_REPROMPTS
);
assert_eq!(
config.reasoning_only_reprompt_message(),
crate::config::DEFAULT_REASONING_ONLY_REPROMPT_MESSAGE
);

// Explicit max_reprompts override.
let config: Config = toml::from_str(
r#"
[reasoning_only]
max_reprompts = 10
"#,
)?;
assert_eq!(config.reasoning_only_max_reprompts(), 10);
assert_eq!(
config.reasoning_only_reprompt_message(),
crate::config::DEFAULT_REASONING_ONLY_REPROMPT_MESSAGE
);

// Explicit reprompt_message override.
let config: Config = toml::from_str(
r#"
[reasoning_only]
max_reprompts = 5
reprompt_message = "tu n'as rien a dire"
"#,
)?;
assert_eq!(config.reasoning_only_max_reprompts(), 5);
assert_eq!(
config.reasoning_only_reprompt_message(),
"tu n'as rien a dire"
);

// 0 = disable automatic recovery.
let config: Config = toml::from_str(
r#"
[reasoning_only]
max_reprompts = 0
"#,
)?;
assert_eq!(config.reasoning_only_max_reprompts(), 0);
assert_eq!(
config.reasoning_only_reprompt_message(),
crate::config::DEFAULT_REASONING_ONLY_REPROMPT_MESSAGE
);

Ok(())
}

#[test]
fn modelstudio_coding_plan_mode_resolves_the_official_chat_base_url() {
// The picker represents Coding Plan as the primary Model Studio provider
Expand Down
16 changes: 14 additions & 2 deletions crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,16 @@ pub struct EngineConfig {
/// immediately; positive values opt coordinator goals into a cancellable
/// quiet period (#5508).
pub goal_continuation_delay_seconds: u64,
/// Maximum number of automatic re-requests when the model returns only
/// reasoning without any answer or tool call. Defaults to 2.
/// Resolved from `[reasoning_only] max_reprompts` in config.toml.
pub reasoning_only_max_reprompts: u32,
/// Optional custom message sent to the model on each re-request when the
/// model returns only reasoning without any answer or tool call.
/// When set, the engine inserts this as a user message before re-issuing
/// the request, nudging the model to produce an actual answer.
/// Resolved from `[reasoning_only] reprompt_message` in config.toml.
pub reasoning_only_reprompt_message: Option<String>,
/// Tool restriction from custom slash command frontmatter.
/// `None` means the current turn may use the normal tool set.
pub allowed_tools: Option<Vec<String>>,
Expand Down Expand Up @@ -557,6 +567,10 @@ impl Default for EngineConfig {
goal_status: GoalStatus::Active,
goal_max_continuations: crate::goal_loop::DEFAULT_MAX_GOAL_CONTINUATIONS,
goal_continuation_delay_seconds: 0,
reasoning_only_max_reprompts: crate::config::DEFAULT_REASONING_ONLY_REPROMPTS,
reasoning_only_reprompt_message: Some(
crate::config::DEFAULT_REASONING_ONLY_REPROMPT_MESSAGE.to_string(),
),
allowed_tools: None,
disallowed_tools: None,
max_tool_calls: None,
Expand Down Expand Up @@ -7729,8 +7743,6 @@ use self::tool_catalog::{
};
pub(crate) use self::tool_execution::emit_tool_audit;
use self::tool_preparation::{prepare_tool_call, reprepare_tool_call_after_hook};
#[cfg(test)]
use self::turn_loop::MAX_REASONING_ONLY_REPROMPTS;
use crate::tools::js_execution::execute_js_execution_tool;

#[cfg(test)]
Expand Down
12 changes: 8 additions & 4 deletions crates/tui/src/core/engine/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20116,10 +20116,12 @@ async fn reasoning_only_clean_stop_is_retried_and_recovers() {
.expect("terminal TurnComplete");
assert_eq!(status, TurnOutcomeStatus::Completed);

// With the default reprompt message configured, the engine emits a status
// notice saying it sent a reprompt message rather than staying silent.
let recovery = events
.iter()
.filter_map(|event| match event {
Event::Status { message } if message.contains("re-requesting the answer") => {
Event::Status { message } if message.contains("sending reprompt message") => {
Some(message.clone())
}
_ => None,
Expand Down Expand Up @@ -20152,10 +20154,12 @@ async fn reasoning_only_length_stop_fails_without_retry() {
1,
"a length stop must not be retried"
);
// Neither the old silent-retry message nor the new reprompt message should
// appear: a length stop must not retry at all.
assert!(
!events.iter().any(|event| matches!(
event,
Event::Status { message } if message.contains("re-requesting the answer")
Event::Status { message } if message.contains("reprompt")
)),
"a length stop must not announce a retry: {events:?}"
);
Expand All @@ -20177,8 +20181,8 @@ async fn reasoning_only_forever_is_bounded_then_fails() {

assert_eq!(
model.calls.load(std::sync::atomic::Ordering::SeqCst),
1 + super::MAX_REASONING_ONLY_REPROMPTS as usize,
"reasoning-only retries are bounded by MAX_REASONING_ONLY_REPROMPTS"
1 + crate::config::DEFAULT_REASONING_ONLY_REPROMPTS as usize,
"reasoning-only retries are bounded by DEFAULT_REASONING_ONLY_REPROMPTS"
);
let status = events
.iter()
Expand Down
38 changes: 29 additions & 9 deletions crates/tui/src/core/engine/turn_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2324,7 +2324,7 @@ impl Engine {
false,
)
&& !stop_reason_is_output_limit(stop_reason.as_deref())
&& reasoning_only_reprompts < MAX_REASONING_ONLY_REPROMPTS
&& reasoning_only_reprompts < self.config.reasoning_only_max_reprompts
{
// Reasoning-only, clean stop: recover instead of dead-ending
// the turn. Nothing was persisted for this response (a bare
Expand All @@ -2334,13 +2334,38 @@ impl Engine {
// because retrying would only reproduce it.
reasoning_only_reprompts += 1;
let attempt = reasoning_only_reprompts;
let max_reprompts = self.config.reasoning_only_max_reprompts;

// If a custom reprompt message is configured, insert it as
// a runtime-generated user message to nudge the model.
if let Some(ref msg) = self.config.reasoning_only_reprompt_message
&& !msg.is_empty()
{
let reprompt = self.runtime_text_message_with_turn_metadata(
msg.clone(),
UserInputProvenance::Runtime,
);
self.add_session_message(reprompt).await;
let _ = self
.tx_event
.send(Event::status(format!(
"Model returned only reasoning; sending reprompt message ({attempt}/{max_reprompts})"
)))
.await;
crate::logging::warn(format!(
"Model returned only reasoning with no answer or tool call (attempt {attempt}/{max_reprompts}); sending reprompt message"
));
turn_error = None;
continue;
}

crate::logging::warn(format!(
"Model returned only reasoning with no answer or tool call (attempt {attempt}/{MAX_REASONING_ONLY_REPROMPTS}); re-requesting the answer"
"Model returned only reasoning with no answer or tool call (attempt {attempt}/{max_reprompts}); re-requesting the answer"
));
let _ = self
.tx_event
.send(Event::status(format!(
"Model returned only reasoning; re-requesting the answer ({attempt}/{MAX_REASONING_ONLY_REPROMPTS})"
"Model returned only reasoning; re-requesting the answer ({attempt}/{max_reprompts})"
)))
.await;
turn_error = None;
Expand Down Expand Up @@ -5699,12 +5724,7 @@ fn resolve_tool_definition<'a>(
/// point the turn truly ends; emitting it earlier (at the persist site) would
/// show a spurious terminal error immediately before the turn resumed for a
/// steer or a sub-agent completion.
/// Ceiling on reasoning-only auto re-requests within a single turn. A reasoning
/// model that answers with only hidden reasoning is recovered up to this many
/// times before the turn fails honestly; the prefix is cached, so each retry is
/// cheap, and the bound keeps a persistently-answerless model from looping.
pub(super) const MAX_REASONING_ONLY_REPROMPTS: u32 = 2;

//
/// Whether a provider stop reason names an output-length cap. Re-requesting
/// after one only reproduces it, so those fail honestly (the user needs a
/// larger max-tokens or a shorter turn) rather than retry.
Expand Down
6 changes: 6 additions & 0 deletions crates/tui/src/exec_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,12 @@ pub(crate) async fn run_exec_agent(
goal_status: crate::tools::goal::GoalStatus::Active,
goal_max_continuations: execution_config.goal_max_continuations(),
goal_continuation_delay_seconds: execution_config.goal_continuation_delay_seconds(),
reasoning_only_max_reprompts: execution_config.reasoning_only_max_reprompts(),
reasoning_only_reprompt_message: Some(
execution_config
.reasoning_only_reprompt_message()
.to_string(),
),
allowed_tools: allowed_tools.clone(),
disallowed_tools: disallowed_tools.clone(),
max_tool_calls,
Expand Down
10 changes: 5 additions & 5 deletions crates/tui/src/plugins/marketplace/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,16 +213,16 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real.json");
std::fs::write(&real, "{}").unwrap();
let link = dir.path().join("link.json");
let _link = dir.path().join("link.json");
#[cfg(unix)]
std::os::unix::fs::symlink(&real, &link).unwrap();
std::os::unix::fs::symlink(&real, &_link).unwrap();
#[cfg(not(unix))]
let link = real.clone();
let _link = real.clone();

let error = load_catalog_document("test", dir.path(), link.to_str().unwrap())
let _error = load_catalog_document("test", dir.path(), _link.to_str().unwrap())
.expect_err("symlink document must be refused");
#[cfg(unix)]
assert!(error.contains("symlink"), "{error}");
assert!(_error.contains("symlink"), "{_error}");
}

#[test]
Expand Down
1 change: 1 addition & 0 deletions crates/tui/src/runtime_api/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11202,6 +11202,7 @@ async fn marketplace_add_rejects_symlink_documents_over_http() -> Result<()> {
fs::create_dir_all(&catalog_dir)?;
let real = catalog_dir.join("real.json");
fs::write(&real, r#"{"plugins":[]}"#)?;
#[cfg(unix)]
let link = catalog_dir.join("link.json");
#[cfg(unix)]
std::os::unix::fs::symlink(&real, &link)?;
Expand Down
4 changes: 4 additions & 0 deletions crates/tui/src/runtime_threads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8087,6 +8087,10 @@ impl RuntimeThreadManager {
goal_status,
goal_max_continuations: cfg.goal_max_continuations(),
goal_continuation_delay_seconds: cfg.goal_continuation_delay_seconds(),
reasoning_only_max_reprompts: cfg.reasoning_only_max_reprompts(),
reasoning_only_reprompt_message: Some(
cfg.reasoning_only_reprompt_message().to_string(),
),
allowed_tools: isolated_chat.then(Vec::new),
disallowed_tools: None,
max_tool_calls: None,
Expand Down
2 changes: 2 additions & 0 deletions crates/tui/src/tui/ui/frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,8 @@ pub(crate) fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
goal_status: app.goal.status,
goal_max_continuations: config.goal_max_continuations(),
goal_continuation_delay_seconds: config.goal_continuation_delay_seconds(),
reasoning_only_max_reprompts: config.reasoning_only_max_reprompts(),
reasoning_only_reprompt_message: Some(config.reasoning_only_reprompt_message().to_string()),
locale_tag: app.ui_locale.tag().to_string(),
workshop: {
crate::tools::large_output_router::WorkshopConfig::install_active(
Expand Down
Loading
Loading