fix(ai): disable thinking for reasoning models on design turns

Reasoning models (glm-5.x / minimax) burn their whole token budget on hidden
<think> and emit an empty design when thinking is left on (glm-5.2: thinking≈30k,
text=0). design_turn_thinking_mode forces ThinkingMode::Disabled for any model
whose profile is thinking_disabled, applied across all design-capable paths —
the design-agent loop, the builtin tool-executing chat loop, and sub-agent
spawns. Claude (thinking productive) keeps the chat default.
This commit is contained in:
Fini 2026-07-02 21:21:33 +08:00
parent 4be2355174
commit a4c71ea2c3
4 changed files with 106 additions and 9 deletions

View file

@ -22,8 +22,11 @@ mod launch;
pub(crate) use launch::builtin_provider_with_tools;
pub use launch::{drain_new_chat_request, drain_stop_request, launch_if_pending};
pub(crate) use launch::{provider_for_selected_model, selected_cli_model_id};
// Sub-agent launcher (Task 3.1) reuses the design-toolset provider builder.
pub(crate) use launch::launch_design::builtin_provider_with_design_tools;
// Sub-agent launcher (Task 3.1) reuses the design-toolset provider builder
// and the design-turn thinking policy.
pub(crate) use launch::launch_design::{
builtin_provider_with_design_tools, design_turn_thinking_mode,
};
/// Pump the in-flight turn's deltas into the trailing assistant message, then
/// execute any pending canvas tool calls against the live editor state.

View file

@ -147,8 +147,15 @@ pub fn launch_if_pending(
// executes each call via the session's tool channel.
if let Some((provider, tool_rx)) = builtin_provider_with_tools(host) {
let system_prompt = build_agent_system_prompt(host.editor_state());
// This builtin agent loop carries the full canvas toolset (`batch_design`
// included), so a design request runs *here* for an API-key model like
// glm-5.2 (experimental flag off → no design-agent loop, builtin → no CLI
// provider). Reasoning models burn their whole budget on hidden `<think>`
// and draw nothing with thinking left on (glm-5.2 measured thinking≈30k /
// text=0 → empty Frame). Force it off for `thinking_disabled` models, same
// as the design-agent loop. Resolved before the `&mut` borrow below.
let thinking = launch_design::design_turn_thinking_mode(host);
let chat = &mut host.editor_state_mut().chat;
let thinking = chat.thinking_mode;
let effort = chat.effort_level;
let attachments = std::mem::take(&mut chat.pending_attachments);
let req = ChatRequest {

View file

@ -9,7 +9,7 @@ use std::sync::mpsc::Receiver;
use std::sync::Arc;
use op_ai::chat_history::{trim_chat_history, DEFAULT_MAX_CHARS, DEFAULT_MAX_MESSAGES};
use op_ai::chat_provider::{ChatProvider, ChatRequest};
use op_ai::chat_provider::{ChatProvider, ChatRequest, ThinkingMode};
use op_editor_host_core::chat::ChatSession;
use op_editor_host_core::design::DesignSession;
use op_host_native::WidgetHostNative;
@ -77,6 +77,50 @@ pub(crate) fn builtin_provider_with_design_tools(
Some((Box::new(provider), tool_rx))
}
/// Effective thinking mode for a design turn (design-agent loop and
/// orchestrator sub-agent spawns alike).
///
/// The design pipeline is structured generation, not free chat: reasoning
/// models whose profile marks `thinking_disabled` (glm-5.x / minimax / …)
/// burn their whole token budget on hidden `<think>` and emit an *empty*
/// design when thinking is left on — glm-5.2 measured at thinking≈30k /
/// text=0 → nothing drawn. Force those to `Disabled`; the wire layer
/// (`chat_builtin_http`) then sends `thinking:{type:"disabled"}`. Claude and
/// other non-`thinking_disabled` models keep the chat's default — they use
/// thinking productively without starving content.
pub(crate) fn design_turn_thinking_mode(host: &WidgetHostNative) -> ThinkingMode {
let state = host.editor_state();
let model = state
.chat
.selected_model_entry()
.and_then(|e| e.builtin_provider_id.as_deref())
.and_then(|id| {
state
.editor_ui
.agent_settings
.builtin_agents
.iter()
.find(|a| a.id == id)
.map(|a| a.model.as_str())
});
resolve_design_thinking(model, state.chat.thinking_mode)
}
/// Pure decision behind [`design_turn_thinking_mode`]: a model whose profile
/// is `thinking_disabled` is forced to `Disabled` for the design turn;
/// everything else (unknown model included → keep the user's choice) keeps the
/// chat default. Split out so the policy is unit-testable without a host.
fn resolve_design_thinking(model: Option<&str>, chat_default: ThinkingMode) -> ThinkingMode {
let thinking_disabled = model
.map(|m| op_orchestrator::resolve_model_profile(m).thinking_disabled)
.unwrap_or(false);
if thinking_disabled {
ThinkingMode::Disabled
} else {
chat_default
}
}
/// Launch the design-agent tool-loop turn when the flag is ON and a
/// built-in design provider is available. Returns true when the turn was
/// launched; false when the flag is OFF or no builtin is ready (caller
@ -106,8 +150,11 @@ pub(super) fn launch_design_loop_turn(
DEFAULT_MAX_MESSAGES,
DEFAULT_MAX_CHARS,
);
// Force thinking off for reasoning models that would otherwise emit an
// empty design (see `design_turn_thinking_mode`). Resolved before the
// `&mut` borrow below.
let thinking = design_turn_thinking_mode(host);
let chat = &mut host.editor_state_mut().chat;
let thinking = chat.thinking_mode;
let effort = chat.effort_level;
let attachments = std::mem::take(&mut chat.pending_attachments);
let req = ChatRequest {
@ -131,6 +178,45 @@ pub(super) fn launch_design_loop_turn(
mod tests {
use super::*;
// ── design-turn thinking policy ───────────────────────────────────────
#[test]
fn reasoning_model_forces_thinking_off_for_design() {
// glm-5.2 is `thinking_disabled` in the profile: with thinking left on
// it burns its budget on `<think>` and draws nothing. The design turn
// must override the chat default to `Disabled` regardless of choice.
assert_eq!(
resolve_design_thinking(Some("glm-5.2"), ThinkingMode::Adaptive),
ThinkingMode::Disabled
);
assert_eq!(
resolve_design_thinking(Some("MiniMax-M3"), ThinkingMode::Enabled),
ThinkingMode::Disabled
);
}
#[test]
fn claude_keeps_chat_default_for_design() {
// Claude is NOT `thinking_disabled` — it uses thinking productively
// without starving content, so the design turn keeps the user's choice.
assert_eq!(
resolve_design_thinking(Some("claude-opus-4"), ThinkingMode::Adaptive),
ThinkingMode::Adaptive
);
assert_eq!(
resolve_design_thinking(Some("claude-sonnet-4-6"), ThinkingMode::Enabled),
ThinkingMode::Enabled
);
}
#[test]
fn unknown_or_absent_model_keeps_chat_default() {
// No selected builtin → keep the user's choice (don't silently disable).
assert_eq!(
resolve_design_thinking(None, ThinkingMode::Enabled),
ThinkingMode::Enabled
);
}
// ── loop_enabled OR-gate: 4 combinations ──────────────────────────────
#[test]
fn loop_enabled_both_off_is_false() {

View file

@ -197,10 +197,11 @@ pub(crate) fn launch_sub_agents(
};
let system_prompt = build_sub_agent_prompt(&spec);
let (thinking, effort) = {
let chat = &host.editor_state().chat;
(chat.thinking_mode, chat.effort_level)
};
// Same design-turn thinking policy as the single design-agent loop:
// reasoning models that would burn their budget on `<think>` and draw
// nothing are forced thinking-off (see `design_turn_thinking_mode`).
let thinking = chat_session::design_turn_thinking_mode(host);
let effort = host.editor_state().chat.effort_level;
let req = ChatRequest {
system_prompt,
user_message: spec.prompt.clone(),