Merge pull request #1206 from Hy4ri/feat/reasoning-composer-correctness

feat(chat): make reasoning control acknowledged and route-honest
This commit is contained in:
m57 authored and GitHub committed 2026-09-21 12:40:54 +03:00
1 parent 0a323deae5
commit 29fdb52978
20 files changed
+773 -54

No files matched your search

@@ -10,6 +10,8 @@ import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.ModelCatalogStore
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.toAny
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
@@ -18,6 +20,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement
import java.util.concurrent.ConcurrentHashMap
/**
@@ -58,6 +61,7 @@ class ChatModelSwitchDelegate(
{ refresh -> ModelCatalogStore.shared.ensureLoaded(forceRefresh = refresh) },
private val getPinnedModels: () -> List<PinnedModel> = { AuthManager.getPinnedModels() },
private val savePinnedModels: (List<PinnedModel>) -> Unit = { AuthManager.savePinnedModels(it) },
private val wsRequest: (suspend (method: String, params: Map<String, Any>) -> Any?)? = null,
) {
private val pendingModelSwitchRequests = ConcurrentHashMap<String, ActiveModelSwitch>()
private val pendingFastSwitchRequests = ConcurrentHashMap<String, ActiveFastSwitch>()
@@ -67,6 +71,8 @@ class ChatModelSwitchDelegate(
private var lastConfirmedModel: String? = null
private var unconfirmedTargetModel: String? = null
private var modelSwitchSequence: Long = 0L
private var reasoningOpSequence: Long = 0L
private var reasoningJob: Job? = null
private var cachedModelOptions: List<ModelProvider> = emptyList()
private var cachedModelOptionsScope: DataScope? = dataScopeFlow.value
private var modelOptionsJob: Job? = null
@@ -460,22 +466,111 @@ class ChatModelSwitchDelegate(
optimisticPreviousModel = null
lastConfirmedModel = null
unconfirmedTargetModel = null
reasoningOpSequence++
reasoningJob?.cancel()
reasoningJob = null
uiState.update {
it.copy(
pendingReasoningLevel = null,
reasoningWireLevel = null,
)
}
}
fun setReasoningLevel(level: String?) {
uiState.update { it.copy(reasoningLevel = level) }
val sessionId = runtimeSessionId() ?: return
fun setReasoningLevel(
level: String?,
scopeName: String = "session",
enforceCapabilities: Boolean = true,
) {
if (level == null) return
scope.launch(ioDispatcher) {
wsSend(
WsMethods.CONFIG_SET,
mapOf(
"key" to "reasoning",
"value" to level,
"session_id" to sessionId,
),
) { id -> trackRequest(id, WsMethods.CONFIG_SET) }
val currentPending = uiState.value.pendingReasoningLevel
if (currentPending != null) return // One reasoning write at a time
val sessionId = runtimeSessionId() ?: return
if (sessionId.isBlank()) return
if (scopeName != "session" && scopeName != "global") return
// Capability validation
if (enforceCapabilities) {
val caps = uiState.value.currentModelCapabilities
if (caps?.reasoning == false) return
if (level == "none" && caps?.can_disable_reasoning == false) return
}
val opSeq = ++reasoningOpSequence
val capturedScope = dataScopeFlow.value
// The old wire value belongs to the previous requested effort. Clear it
// before starting the RPC so neither possible ordering can lie:
// ACK first -> wire stays unknown until session.info arrives.
// session.info first -> its fresh wire survives the later ACK.
uiState.update {
it.copy(
pendingReasoningLevel = level,
reasoningWireLevel = null,
)
}
reasoningJob?.cancel()
reasoningJob =
scope.launch(ioDispatcher) {
try {
val params =
mapOf(
"key" to "reasoning",
"value" to level,
"session_id" to sessionId,
"scope" to scopeName,
)
val result =
if (wsRequest != null) {
wsRequest.invoke(WsMethods.CONFIG_SET, params)
} else {
wsSend(WsMethods.CONFIG_SET, params) { reqId ->
trackRequest(reqId, WsMethods.CONFIG_SET)
}
null
}
if (opSeq == reasoningOpSequence && capturedScope == dataScopeFlow.value) {
val ackLevel =
if (wsRequest != null) {
// HermesWsClient.request() completes with the RAW JsonElement.
// Tests and a few injected callers may provide an already-decoded map.
val map =
when (result) {
is JsonElement -> result.toAny() as? Map<*, *>
is Map<*, *> -> result
else -> null
}
val resKey = map?.get("key") as? String
val resVal = map?.get("value") as? String
if (resKey != "reasoning" || resVal.isNullOrEmpty()) {
throw IllegalStateException("Invalid reasoning config.set response")
}
resVal
} else {
level
}
uiState.update { state ->
state.copy(
reasoningLevel = ackLevel,
pendingReasoningLevel = null,
)
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
if (opSeq == reasoningOpSequence && capturedScope == dataScopeFlow.value) {
uiState.update { state ->
state.copy(
pendingReasoningLevel = null,
errorMessage = "Could not confirm reasoning change: ${e.message ?: e.toString()}",
)
}
}
}
}
}
fun getModelCapabilities(
@@ -808,6 +808,8 @@ fun ChatScreen(
currentSessionModel = state.currentSessionModel,
showModelProvider = state.showModelProvider,
reasoningLevel = state.reasoningLevel,
reasoningWireLevel = state.reasoningWireLevel,
pendingReasoningLevel = state.pendingReasoningLevel,
onModelTap = { viewModel.openModelPicker() },
onReasoningTap = { level -> viewModel.setReasoningLevel(level) },
canDisableReasoning = state.currentModelCapabilities?.can_disable_reasoning,
@@ -27,6 +27,7 @@ import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.data.ws.toAny
import com.m57.hermescontrol.data.ws.toJsonElement
import com.m57.hermescontrol.notification.captureTurnBoundary
import com.m57.hermescontrol.notification.correlationScopeId
@@ -45,6 +46,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.decodeFromJsonElement
@@ -56,6 +58,42 @@ import java.util.concurrent.ConcurrentHashMap
private const val TAG = "ChatViewModel"
private const val MESSAGE_PAGE_SIZE = 150
private val REASONING_EFFORT_LEVELS =
setOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
private data class ReasoningSlashArgs(
val value: String,
val scopeName: String?,
)
/** Mirrors Desktop's reasoningSlashParams(): flags may appear in any order. */
private fun parseReasoningSlashArgs(arg: String): ReasoningSlashArgs? {
var scopeName: String? = null
val values = mutableListOf<String>()
arg
.trim()
.split(Regex("\\s+"))
.filter { it.isNotBlank() }
.forEach { part ->
when (part.lowercase()) {
"--global", "-g", "global" -> scopeName = "global"
"--session", "-s", "session" -> scopeName = "session"
else -> values += part
}
}
if (values.isEmpty()) return null
return ReasoningSlashArgs(values.joinToString(" "), scopeName)
}
private fun rpcResultMap(result: Any?): Map<*, *>? =
when (result) {
is JsonElement -> result.toAny() as? Map<*, *>
is Map<*, *> -> result
else -> null
}
private data class PreparedAttachment(
val attachment: Attachment,
val encodedFile: File,
@@ -145,6 +183,10 @@ data class ChatUiState(
val currentModelCapabilities: ModelCapabilities? = null,
// Reasoning effort level for the current session
val reasoningLevel: String? = null,
// Gateway-reported effective effort on the active route (clamped level, e.g. "max" when requested was "ultra")
val reasoningWireLevel: String? = null,
// In-flight reasoning effort currently applying via config.set
val pendingReasoningLevel: String? = null,
// Fast mode / Priority processing state for the current session
val fastMode: Boolean = false,
val isFastModeChanging: Boolean = false,
@@ -479,6 +521,7 @@ class ChatViewModel(
handleSlashCommand = { cmd -> handleSlashCommand(cmd) },
fetchContextUsage = { fetchContextUsage() },
onModelSwitchInitiated = { onModelSwitchInitiated() },
wsRequest = { method, params -> wsClient.request(method, params).await() },
).apply {
attachScopeObserver(viewModelScope)
}
@@ -788,7 +831,11 @@ class ChatViewModel(
}
is WsEvent.SessionInfo -> {
handleSessionInfo(event.data)
val current = runtimeSessionId ?: _uiState.value.currentSessionId
// Ignore session.info events clearly tagged for a different session
if (event.sessionId == null || current == null || event.sessionId == current) {
handleSessionInfo(event.data)
}
}
is WsEvent.MessageToken -> {
@@ -1178,6 +1225,7 @@ class ChatViewModel(
val model = info["model"] as? String
val provider = info["provider"] as? String
val reasoningEffort = info["reasoning_effort"] as? String
val reasoningEffortWire = info["reasoning_effort_wire"] as? String
val terminalBackend = info["terminal_backend"] as? String
val serviceTier = (info["service_tier"] as? String)?.trim()?.lowercase()
val fastFlag =
@@ -1209,14 +1257,24 @@ class ChatViewModel(
modelSwitchDelegate.onModelConfirmed(newModelLabel)
}
_uiState.update { state ->
val newEffort =
if (reasoningEffort != null) {
if (reasoningEffort.isEmpty()) null else reasoningEffort
} else {
state.reasoningLevel
}
val newWire =
if (reasoningEffortWire != null) {
if (reasoningEffortWire.isEmpty()) null else reasoningEffortWire
} else if (newEffort != state.reasoningLevel || modelSwapped) {
null
} else {
state.reasoningWireLevel
}
state.copy(
currentSessionModel = newModelLabel ?: state.currentSessionModel,
reasoningLevel =
if (reasoningEffort.isNullOrEmpty()) {
null
} else {
reasoningEffort
},
reasoningLevel = newEffort,
reasoningWireLevel = newWire,
fastMode = fastFlag ?: state.fastMode,
isFastModeChanging = if (fastFlag != null) false else state.isFastModeChanging,
terminalBackend = terminalBackend ?: state.terminalBackend,
@@ -1388,6 +1446,7 @@ class ChatViewModel(
val model = infoMap?.get("model") as? String
val provider = infoMap?.get("provider") as? String
val reasoningEffort = infoMap?.get("reasoning_effort") as? String
val reasoningEffortWire = infoMap?.get("reasoning_effort_wire") as? String
val terminalBackend = infoMap?.get("terminal_backend") as? String
val serviceTier = (infoMap?.get("service_tier") as? String)?.trim()?.lowercase()
val fastFlag =
@@ -1416,6 +1475,12 @@ class ChatViewModel(
} else {
reasoningEffort
},
reasoningWireLevel =
if (reasoningEffortWire.isNullOrEmpty()) {
null
} else {
reasoningEffortWire
},
fastMode = fastFlag ?: false,
isFastModeChanging = false,
terminalBackend = terminalBackend ?: it.terminalBackend,
@@ -1705,7 +1770,8 @@ class ChatViewModel(
}
private fun canSubmitMessage(): Boolean =
wsClient.connectionStatus.value == ConnectionStatus.CONNECTED &&
_uiState.value.pendingReasoningLevel == null &&
wsClient.connectionStatus.value == ConnectionStatus.CONNECTED &&
(
(_uiState.value.isSessionReady && runtimeSessionId != null) ||
(
@@ -2127,6 +2193,10 @@ class ChatViewModel(
modelSwitchDelegate.handleModelSwitch(command)
}
is SlashResult.ReasoningSwitch -> {
handleReasoningSlashCommand(result.level)
}
is SlashResult.Update -> {
openUpdateConfirm()
}
@@ -2174,6 +2244,97 @@ class ChatViewModel(
submitPrompt(arg, queued = true)
}
private fun handleReasoningSlashCommand(arg: String) {
val sessionId = runtimeSessionId
if (sessionId.isNullOrBlank()) {
addAssistantMessage("Reasoning controls require an active session.")
return
}
val parsed = parseReasoningSlashArgs(arg)
// Bare `/reasoning` is a status query, matching Desktop/TUI semantics.
if (parsed == null) {
viewModelScope.launch(ioDispatcher) {
try {
val result =
wsClient
.request(
WsMethods.CONFIG_GET,
mapOf(
"key" to "reasoning",
"session_id" to sessionId,
),
).await()
val map = rpcResultMap(result) ?: error("Invalid reasoning config.get response")
val value = (map["value"] as? String)?.takeIf { it.isNotBlank() } ?: "unknown"
val display = (map["display"] as? String)?.takeIf { it.isNotBlank() } ?: "unknown"
addAssistantMessage("reasoning: $value · display $display")
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
addAssistantMessage("Could not read reasoning status: ${e.message ?: e.toString()}")
}
}
return
}
val value = parsed.value.trim().lowercase()
// Effort changes use the acknowledged delegate so the composer shows
// pending state and prompt submission remains gated until config.set
// succeeds. A global preference may target a future model, so do not
// reject it based on only the current model's capabilities.
if (value in REASONING_EFFORT_LEVELS) {
if (parsed.scopeName != "global") {
val caps = _uiState.value.currentModelCapabilities
if (caps?.reasoning == false) {
addAssistantMessage("Reasoning is not supported for the current model.")
return
}
if (value == "none" && caps?.can_disable_reasoning == false) {
addAssistantMessage("Reasoning cannot be disabled for this model (always on).")
return
}
}
modelSwitchDelegate.setReasoningLevel(
value,
scopeName = parsed.scopeName ?: "session",
enforceCapabilities = parsed.scopeName != "global",
)
return
}
// Display controls (`show`/`hide`/`full`/`clamp` and backend aliases)
// and future config.set reasoning values go through the canonical
// backend parser instead of a client-maintained allowlist. Invalid
// values therefore surface the real 4002 error instead of being eaten
// locally. Scope flags are forwarded exactly like Desktop.
viewModelScope.launch(ioDispatcher) {
try {
val params =
buildMap<String, Any> {
put("key", "reasoning")
put("value", parsed.value)
put("session_id", sessionId)
parsed.scopeName?.let { put("scope", it) }
}
val result = wsClient.request(WsMethods.CONFIG_SET, params).await()
val map = rpcResultMap(result) ?: error("Invalid reasoning config.set response")
val responseKey = map["key"] as? String
val responseValue = map["value"] as? String
if (responseKey != "reasoning" || responseValue.isNullOrBlank()) {
error("Invalid reasoning config.set response")
}
addAssistantMessage("reasoning: $responseValue")
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
addAssistantMessage("Could not change reasoning: ${e.message ?: e.toString()}")
}
}
}
// ── Side Questions via /btw (issue #1015) ─────────────────────────────
fun dismissBtw() {
@@ -2962,6 +3123,8 @@ class ChatViewModel(
currentSessionModel = null,
currentModelCapabilities = null,
reasoningLevel = null,
reasoningWireLevel = null,
pendingReasoningLevel = null,
fastMode = false,
isFastModeChanging = false,
terminalBackend = null,
@@ -42,6 +42,11 @@ class SlashCommandDispatcher {
SlashResult.ModelSwitch
}
"/reasoning" -> {
val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim()
SlashResult.ReasoningSwitch(level = arg)
}
"/update" -> {
SlashResult.Update
}
@@ -103,6 +108,16 @@ sealed class SlashResult {
*/
data object ModelSwitch : SlashResult()
/**
* Handle `/reasoning` through the gateway's first-class config RPCs rather
* than command.dispatch (which 4018s on it). [level] intentionally carries
* the raw argument tail: it may be an effort, a display word
* (`show`/`hide`/`full`/`clamp`), or include `--global` / `--session`.
*/
data class ReasoningSwitch(
val level: String,
) : SlashResult()
/**
* Trigger the backend update via the REST action API
* (`POST /api/hermes/update`, the System screen's flow) and track it in
@@ -101,12 +101,16 @@ fun ChatInputBar(
isFastModeChanging: Boolean = false,
onToggleFastMode: () -> Unit = {},
showModelProvider: Boolean = false,
reasoningWireLevel: String? = null,
pendingReasoningLevel: String? = null,
) {
// Allow sending while the agent is mid-turn or awaiting approval: the
// gateway's prompt.submit busy-input policy queues it as the next turn
// (tui_gateway/server.py:_handle_busy_submit), so the message is never
// dropped. Slash commands were already allowed; regular prompts now are too.
val canSend = ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady)
val canSend =
pendingReasoningLevel == null &&
ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady)
// Attachment tray state
var showAttachmentTray by remember { mutableStateOf(false) }
@@ -329,6 +333,9 @@ fun ChatInputBar(
isFastModeChanging = isFastModeChanging,
onToggleFastMode = onToggleFastMode,
showModelProvider = showModelProvider,
reasoningWireLevel = reasoningWireLevel,
pendingReasoningLevel = pendingReasoningLevel,
isSessionReady = isSessionReady,
)
AttachmentTray(
@@ -104,6 +104,9 @@ fun ComposerToolbar(
isFastModeChanging: Boolean = false,
onToggleFastMode: () -> Unit = {},
showModelProvider: Boolean = false,
reasoningWireLevel: String? = null,
pendingReasoningLevel: String? = null,
isSessionReady: Boolean = true,
) {
var showReasoningMenu by remember { mutableStateOf(false) }
val palette = composerPalette()
@@ -179,11 +182,12 @@ fun ComposerToolbar(
}
Box {
val reasoningInteractive = isConnected && isSessionReady && pendingReasoningLevel == null
Row(
modifier =
Modifier
.fillMaxHeight()
.clickable(enabled = isConnected) { showReasoningMenu = true }
.clickable(enabled = reasoningInteractive) { showReasoningMenu = true }
.padding(start = 6.dp, end = 12.dp)
.testTag("reasoning_chip"),
verticalAlignment = Alignment.CenterVertically,
@@ -191,7 +195,7 @@ fun ComposerToolbar(
) {
val levelColor =
palette.onControlVariant.copy(
alpha = if (isConnected) 1f else 0.38f,
alpha = if (reasoningInteractive) 1f else 0.38f,
)
if (fastMode) {
Icon(
@@ -201,12 +205,26 @@ fun ComposerToolbar(
modifier = Modifier.size(14.dp),
)
}
if (pendingReasoningLevel != null) {
CircularProgressIndicator(
modifier = Modifier.size(12.dp),
strokeWidth = 1.5.dp,
color = MaterialTheme.colorScheme.primary,
)
}
Text(
text =
if (reasoningDisabledForModel) {
if (fastMode) stringResource(R.string.chat_fast_mode_label) else "No reasoning"
} else {
val rLabel = buildReasoningLabel(reasoningLevel)
val targetEffort = pendingReasoningLevel ?: reasoningLevel
val wire = if (pendingReasoningLevel != null) null else reasoningWireLevel
val rLabel =
buildReasoningLabel(
level = targetEffort,
wireLevel = wire,
defaultLabel = stringResource(R.string.chat_reasoning_default),
)
if (fastMode) {
"${stringResource(
R.string.chat_fast_mode_label,
@@ -332,29 +350,57 @@ fun ComposerToolbar(
allLevels.forEach { (level, label) ->
val isNone = level == "none"
val noneDisabled = isNone && (canDisable == false || reasoningDisabledForModel)
val isSelected = (pendingReasoningLevel ?: reasoningLevel) == level
val isClampedTarget =
isSelected && reasoningWireLevel != null &&
!reasoningWireLevel.equals(level, ignoreCase = true)
DropdownMenuItem(
text = {
Text(
text = label,
fontWeight =
if (reasoningLevel == level) {
MaterialTheme.typography.bodyMedium.fontWeight
} else {
null
},
color =
when {
noneDisabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
reasoningLevel == level -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurface
},
)
Column {
Text(
text = label,
fontWeight =
if (isSelected) {
MaterialTheme.typography.bodyMedium.fontWeight
} else {
null
},
color =
when {
noneDisabled -> {
MaterialTheme.colorScheme.onSurface.copy(
alpha = 0.38f,
)
}
isSelected -> {
MaterialTheme.colorScheme.primary
}
else -> {
MaterialTheme.colorScheme.onSurface
}
},
)
if (isClampedTarget) {
Text(
text =
stringResource(
R.string.chat_reasoning_clamped_desc,
label,
buildReasoningLabel(reasoningWireLevel, defaultLabel = ""),
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
)
}
}
},
onClick = {
showReasoningMenu = false
onReasoningSelected(level)
},
enabled = !noneDisabled && !reasoningDisabledForModel,
enabled = !noneDisabled && !reasoningDisabledForModel && pendingReasoningLevel == null,
)
}
}
@@ -501,16 +547,37 @@ private const val CUSTOM_PROVIDER_PREFIX = "custom:"
* "xhigh", "max", "ultra", or null for model default.
* @return Display string such as "None", "Low", "XHigh", "Ultra", etc.
*/
private fun buildReasoningLabel(level: String?): String =
when (level) {
null -> "Med"
"none" -> "None"
"minimal" -> "Minimal"
"low" -> "Low"
"medium" -> "Med"
"high" -> "High"
"xhigh" -> "XHigh"
"max" -> "Max"
"ultra" -> "Ultra"
else -> level
fun buildReasoningLabel(
level: String?,
wireLevel: String? = null,
defaultLabel: String = "Default",
): String {
val reqLabel =
when (level) {
null -> defaultLabel
"none" -> "None"
"minimal" -> "Minimal"
"low" -> "Low"
"medium" -> "Med"
"high" -> "High"
"xhigh" -> "XHigh"
"max" -> "Max"
"ultra" -> "Ultra"
else -> level
}
if (wireLevel != null && !wireLevel.equals(level, ignoreCase = true) && level != "none") {
val wireLabel =
when (wireLevel) {
"minimal" -> "Minimal"
"low" -> "Low"
"medium" -> "Med"
"high" -> "High"
"xhigh" -> "XHigh"
"max" -> "Max"
"ultra" -> "Ultra"
else -> wireLevel
}
return "$reqLabel→$wireLabel"
}
return reqLabel
}