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:
1 parent
1e8c3629f0
commit
ec72ed4733
5 files changed
+255
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,6 +95,7 @@ fun ChatMessageList(
|
|||||||
val isLastMessage = index == messages.lastIndex
|
val isLastMessage = index == messages.lastIndex
|
||||||
val isAssistant = message.role == MessageRole.ASSISTANT
|
val isAssistant = message.role == MessageRole.ASSISTANT
|
||||||
|
|
||||||
|
Column {
|
||||||
if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming &&
|
if (typingEffectEnabled && isLastMessage && isAssistant && message.isStreaming &&
|
||||||
lastAnimatedMessageId != message.id
|
lastAnimatedMessageId != message.id
|
||||||
) {
|
) {
|
||||||
@@ -112,6 +118,11 @@ fun ChatMessageList(
|
|||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Subtle beat counter after every 5th tool call (issue #767).
|
||||||
|
toolMilestones[index]?.let { count ->
|
||||||
|
ToolCallDivider(count = count, maxPerTurn = maxToolCallsPerTurn)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streaming message
|
// Streaming message
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user