feat(chat): subtle tool-call divider every 5 calls per turn (#769)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-02 15:22:29 +03:00
1 parent 1e8c3629f0
commit ec72ed4733
5 files changed
+276 -21

No files matched your search

@@ -0,0 +1,110 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Every [TOOL_CALL_DIVIDER_INTERVAL]th tool call within a single turn renders a
* subtle divider between the tool bubble and the next bubble, labeled with the
* per-turn call count against the real per-turn budget (e.g. `5/90`) so the
* user can see how much of the turn's tool budget has been burned (issue #767).
*
* The divider is pure derived state: it is computed from the in-memory message
* list at render time and is never persisted, so scrolling back through old
* history shows the same milestones the live stream did.
*/
const val TOOL_CALL_DIVIDER_INTERVAL = 5
/**
* Returns the message indices that complete a tool-call milestone — a [MessageRole.TOOL]
* message whose per-turn tool-call count is a multiple of [TOOL_CALL_DIVIDER_INTERVAL] —
* mapped to the per-turn count at that point.
*
* The counter resets at each [MessageRole.USER] message (a new turn), so
* milestones read `5, 10, 15...` within a turn and restart at `5` on the next.
*/
fun toolCallMilestones(messages: List<ChatMessage>): Map<Int, Int> {
val milestones = mutableMapOf<Int, Int>()
var toolCount = 0
messages.forEachIndexed { index, message ->
when (message.role) {
MessageRole.USER -> toolCount = 0
MessageRole.TOOL -> {
toolCount += 1
if (toolCount % TOOL_CALL_DIVIDER_INTERVAL == 0) {
milestones[index] = toolCount
}
}
else -> Unit
}
}
return milestones
}
/**
* Divider label: `count/maxPerTurn` when the real budget is known (fetched from
* the backend config), bare `count` when it isn't — never a hardcoded default.
*/
fun toolCallDividerLabel(
count: Int,
maxPerTurn: Int?,
): String =
if (maxPerTurn != null && maxPerTurn > 0) {
"$count/$maxPerTurn"
} else {
count.toString()
}
/**
* Subtle "beat counter" divider: a hairline, the per-turn count vs budget
* (e.g. `5/90`), and a hairline. Rendered between bubbles after every 5th
* tool call of the current turn.
*
* @param maxPerTurn the real per-turn tool-call budget (agent.max_turns from
* the backend config), or null when it could not be fetched — the label then
* degrades to the bare count.
*/
@Composable
fun ToolCallDivider(
count: Int,
maxPerTurn: Int? = null,
modifier: Modifier = Modifier,
) {
val lineColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.18f)
val labelColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
Row(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 40.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
HorizontalDivider(
modifier = Modifier.weight(1f),
thickness = 0.5.dp,
color = lineColor,
)
Text(
text = toolCallDividerLabel(count, maxPerTurn),
fontSize = 10.sp,
fontWeight = FontWeight.Medium,
color = labelColor,
modifier = Modifier.padding(horizontal = 8.dp),
)
HorizontalDivider(
modifier = Modifier.weight(1f),
thickness = 0.5.dp,
color = lineColor,
)
}
}
@@ -504,6 +504,7 @@ fun ChatScreen(
searchMatchIndices = state.searchMatchIndices, searchMatchIndices = state.searchMatchIndices,
typingEffectEnabled = state.typingEffectEnabled, typingEffectEnabled = state.typingEffectEnabled,
typingEffectDelayMs = state.typingEffectDelayMs, typingEffectDelayMs = state.typingEffectDelayMs,
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
isLoading = state.isLoading, isLoading = state.isLoading,
isLoadingOlder = state.isLoadingOlder, isLoadingOlder = state.isLoadingOlder,
isDark = isDark, isDark = isDark,
@@ -44,7 +44,10 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
@@ -67,6 +70,12 @@ data class ChatUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isLoadingOlder: Boolean = false, val isLoadingOlder: Boolean = false,
val hasOlderMessages: Boolean = false, val hasOlderMessages: Boolean = false,
/**
* Real per-turn tool-call budget (agent.max_turns) from GET /api/config.
* Null when it could not be fetched — the tool-call dividers then degrade
* to a bare count instead of a hardcoded default.
*/
val maxToolCallsPerTurn: Int? = null,
/** Standalone streaming message — rendered after the main list. */ /** Standalone streaming message — rendered after the main list. */
val streamingMessage: ChatMessage? = null, val streamingMessage: ChatMessage? = null,
val errorMessage: String? = null, val errorMessage: String? = null,
@@ -281,6 +290,7 @@ class ChatViewModel(
init { init {
refreshSettings() refreshSettings()
refreshMaxToolCallsPerTurn()
connectWebSocket(setLoading = false) connectWebSocket(setLoading = false)
viewModelScope.launch { viewModelScope.launch {
@@ -1326,6 +1336,32 @@ class ChatViewModel(
} }
} }
/**
* Fetch the real per-turn tool-call budget (`agent.max_turns` — falling back
* to the legacy top-level `max_turns`) from GET /api/config so the chat's
* tool-call dividers can render `count/max` against the actual backend
* limit. Never hardcoded. Null on failure — the divider degrades to a
* bare count.
*/
private fun refreshMaxToolCallsPerTurn() {
viewModelScope.launch(Dispatchers.IO) {
try {
val response = ApiClient.hermesApi.getConfig()
if (!response.isSuccessful) return@launch
val config = response.body() ?: return@launch
val agent = config["agent"] as? JsonObject
val max =
(agent?.get("max_turns")?.jsonPrimitive?.intOrNull)
?: config["max_turns"]?.jsonPrimitive?.intOrNull
if (max != null && max > 0) {
_uiState.update { it.copy(maxToolCallsPerTurn = max) }
}
} catch (e: Exception) {
Log.e(TAG, "Failed to fetch max tool calls per turn", e)
}
}
}
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
private fun parseCommandCatalog(map: Map<*, *>): CommandCatalog? = private fun parseCommandCatalog(map: Map<*, *>): CommandCatalog? =
try { try {
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.ui.chat.components package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -22,6 +23,8 @@ import com.m57.hermescontrol.ui.chat.ChatViewModel
import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.ImageViewerModel import com.m57.hermescontrol.ui.chat.ImageViewerModel
import com.m57.hermescontrol.ui.chat.MessageRole import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.ToolCallDivider
import com.m57.hermescontrol.ui.chat.toolCallMilestones
import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.EmptyState
/** /**
@@ -39,6 +42,7 @@ fun ChatMessageList(
searchMatchIndices: List<Int>, searchMatchIndices: List<Int>,
typingEffectEnabled: Boolean, typingEffectEnabled: Boolean,
typingEffectDelayMs: Int, typingEffectDelayMs: Int,
maxToolCallsPerTurn: Int? = null,
isLoading: Boolean, isLoading: Boolean,
isLoadingOlder: Boolean, isLoadingOlder: Boolean,
isDark: Boolean, isDark: Boolean,
@@ -62,6 +66,7 @@ fun ChatMessageList(
) )
} }
} else { } else {
val toolMilestones = toolCallMilestones(messages)
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -90,27 +95,33 @@ fun ChatMessageList(
val isLastMessage = index == messages.lastIndex val isLastMessage = index == messages.lastIndex
val isAssistant = message.role == MessageRole.ASSISTANT val isAssistant = message.role == MessageRole.ASSISTANT
if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming && Column {
lastAnimatedMessageId != message.id if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming &&
) { lastAnimatedMessageId != message.id
StreamingBubbleWithTypingEffect( ) {
streaming = message, StreamingBubbleWithTypingEffect(
typingDelayMs = typingEffectDelayMs, streaming = message,
isDark = isDark, typingDelayMs = typingEffectDelayMs,
onAnimationComplete = { isDark = isDark,
onLastAnimatedMessageIdChange(message.id) onAnimationComplete = {
}, onLastAnimatedMessageIdChange(message.id)
) },
} else { )
ChatBubble( } else {
message = message, ChatBubble(
isDarkTheme = isDark, message = message,
searchQuery = if (isSearchActive) searchQuery else "", isDarkTheme = isDark,
isCurrentMatch = isCurrentMatch, searchQuery = if (isSearchActive) searchQuery else "",
onRespondApproval = viewModel::respondToApproval, isCurrentMatch = isCurrentMatch,
onOpenAttachment = viewModel::openAttachment, onRespondApproval = viewModel::respondToApproval,
onImageClick = onImageClick, onOpenAttachment = viewModel::openAttachment,
) onImageClick = onImageClick,
)
}
// Subtle beat counter after every 5th tool call (issue #767).
toolMilestones[index]?.let { count ->
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
}
} }
} }
@@ -0,0 +1,97 @@
package com.m57.hermescontrol.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatListEntriesTest {
private fun toolMessage() = ChatMessage(role = MessageRole.TOOL, content = "")
private fun textMessage() = ChatMessage(role = MessageRole.ASSISTANT, content = "hello")
private fun userMessage() = ChatMessage(role = MessageRole.USER, content = "hi")
@Test
fun emptyListHasNoMilestones() {
assertTrue(toolCallMilestones(emptyList()).isEmpty())
}
@Test
fun noToolMessagesHasNoMilestones() {
val messages = List(8) { textMessage() }
assertTrue(toolCallMilestones(messages).isEmpty())
}
@Test
fun fewerThanFiveToolCallsHasNoMilestones() {
val messages = List(4) { toolMessage() }
assertTrue(toolCallMilestones(messages).isEmpty())
}
@Test
fun fifthToolCallIsAMilestone() {
val messages = List(5) { toolMessage() }
assertEquals(mapOf(4 to 5), toolCallMilestones(messages))
}
@Test
fun milestoneAtEveryFifthToolCallWithinTurn() {
val messages = List(12) { toolMessage() }
assertEquals(mapOf(4 to 5, 9 to 10), toolCallMilestones(messages))
}
@Test
fun counterResetsAtEachUserMessage() {
val messages =
listOf(
userMessage(), // index 0 — turn 1 starts
toolMessage(), // 1
toolMessage(), // 2
toolMessage(), // 3
toolMessage(), // 4
toolMessage(), // 5 ← 5th tool call of turn 1
userMessage(), // 6 — turn 2 starts, counter resets
toolMessage(), // 7
toolMessage(), // 8
toolMessage(), // 9
toolMessage(), // 10
toolMessage(), // 11 ← 5th tool call of turn 2 (not 10)
)
assertEquals(mapOf(5 to 5, 11 to 5), toolCallMilestones(messages))
}
@Test
fun nonToolMessagesDoNotCountAndShiftIndices() {
val messages =
listOf(
textMessage(), // index 0
toolMessage(), // 1
toolMessage(), // 2
textMessage(), // 3
toolMessage(), // 4
toolMessage(), // 5
textMessage(), // 6
toolMessage(), // 7 ← 5th tool call
)
assertEquals(mapOf(7 to 5), toolCallMilestones(messages))
}
@Test
fun toolMessagesWithoutToolNameStillCount() {
val messages = List(5) { ChatMessage(role = MessageRole.TOOL, content = "", toolName = null) }
assertEquals(mapOf(4 to 5), toolCallMilestones(messages))
}
@Test
fun labelIncludesRealMaxWhenKnown() {
assertEquals("5/90", toolCallDividerLabel(count = 5, maxPerTurn = 90))
assertEquals("10/90", toolCallDividerLabel(count = 10, maxPerTurn = 90))
}
@Test
fun labelDegradesToBareCountWhenMaxUnknown() {
assertEquals("5", toolCallDividerLabel(count = 5, maxPerTurn = null))
assertEquals("5", toolCallDividerLabel(count = 5, maxPerTurn = 0))
assertEquals("5", toolCallDividerLabel(count = 5, maxPerTurn = -1))
}
}