fix(group-chat): full desktop parity for turn timeout (180s base + 20m adaptive cap) and multi-profile REST fallback

- Match desktop GROUP_TURN_TIMEOUT_MS (180s) and GROUP_TURN_HARD_CAP_MS (20m)
- Implement adaptive deadline extension: actively streaming tokens or running tools within 60s keeps the turn slot alive up to 20m, preventing long research/terminal runs from getting discarded
- Update LEASE_STEP_TTL_MS to 180s
- Add explicit `@Query("profile") profile: String?` support to `HermesApiService.getSessionMessages`
- Pass `bot.name` explicitly in `GroupChatViewModel` REST fallback so multi-profile bot sessions do not hit 404 under `ProfileScopeInterceptor`

Co-authored-by: Luna <luna.m57@agentmail.to>
This commit is contained in:
M57andLuna committed 2026-09-04 21:11:00 +03:00
1 parent 31e292d7fe
commit c34701f4e2
2 files changed
+41 -5

No files matched your search

@@ -194,6 +194,7 @@ interface HermesApiService {
// legacy backends that don't know the param — they ignore it. // legacy backends that don't know the param — they ignore it.
@Query("order") order: String? = null, @Query("order") order: String? = null,
@Query("include_compacted") includeCompacted: Boolean? = true, @Query("include_compacted") includeCompacted: Boolean? = true,
@Query("profile") profile: String? = null,
): Response<SessionMessagesResponse> ): Response<SessionMessagesResponse>
@GET("api/sessions/stats") @GET("api/sessions/stats")
@@ -56,8 +56,9 @@ const val MAX_ALLOWED_BOT_MESSAGES = 50
const val MAX_ALLOWED_CONTINUATION_PASSES = 20 const val MAX_ALLOWED_CONTINUATION_PASSES = 20
const val WARN_MAX_BOT_MESSAGES = 20 const val WARN_MAX_BOT_MESSAGES = 20
const val WARN_MAX_CONTINUATION_PASSES = 6 const val WARN_MAX_CONTINUATION_PASSES = 6
private const val LEASE_STEP_TTL_MS = 45_000L private const val LEASE_STEP_TTL_MS = 180_000L
private const val TURN_TIMEOUT_MS = 45_000L private const val GROUP_TURN_TIMEOUT_MS = 180_000L
private const val GROUP_TURN_HARD_CAP_MS = 20 * 60_000L
private const val HISTORY_LIMIT = 10 private const val HISTORY_LIMIT = 10
class GroupChatViewModel( class GroupChatViewModel(
@@ -86,6 +87,9 @@ class GroupChatViewModel(
// Map of session_id -> bot profile // Map of session_id -> bot profile
private val sessionToBot = mutableMapOf<String, ProfileInfo>() private val sessionToBot = mutableMapOf<String, ProfileInfo>()
// Map of session_id -> last activity epoch ms (streaming token, tool call, thinking)
private val sessionLastActivity = mutableMapOf<String, Long>()
init { init {
if (groupName.isNotBlank()) { if (groupName.isNotBlank()) {
loadGroup() loadGroup()
@@ -106,6 +110,7 @@ class GroupChatViewModel(
inFlightTurns.clear() inFlightTurns.clear()
activeStreamMsgId.clear() activeStreamMsgId.clear()
sessionToBot.clear() sessionToBot.clear()
sessionLastActivity.clear()
_uiState.value = GroupChatUiState(groupName = name) _uiState.value = GroupChatUiState(groupName = name)
loadGroup() loadGroup()
} }
@@ -145,6 +150,7 @@ class GroupChatViewModel(
is WsEvent.MessageToken -> { is WsEvent.MessageToken -> {
val sid = event.sessionId?.trim('\"', ' ') val sid = event.sessionId?.trim('\"', ' ')
if (sid != null) { if (sid != null) {
sessionLastActivity[sid] = System.currentTimeMillis()
val streamId = activeStreamMsgId[sid] val streamId = activeStreamMsgId[sid]
val bot = sessionToBot[sid] val bot = sessionToBot[sid]
if (streamId != null && bot != null && event.token.isNotEmpty()) { if (streamId != null && bot != null && event.token.isNotEmpty()) {
@@ -180,6 +186,7 @@ class GroupChatViewModel(
is WsEvent.ToolStart -> { is WsEvent.ToolStart -> {
val sid = event.sessionId?.trim('\"', ' ') val sid = event.sessionId?.trim('\"', ' ')
if (sid != null) { if (sid != null) {
sessionLastActivity[sid] = System.currentTimeMillis()
val streamId = activeStreamMsgId[sid] val streamId = activeStreamMsgId[sid]
val bot = sessionToBot[sid] val bot = sessionToBot[sid]
val toolName = event.name ?: "tool" val toolName = event.name ?: "tool"
@@ -230,6 +237,7 @@ class GroupChatViewModel(
is WsEvent.ToolComplete -> { is WsEvent.ToolComplete -> {
val sid = event.sessionId?.trim('\"', ' ') val sid = event.sessionId?.trim('\"', ' ')
if (sid != null) { if (sid != null) {
sessionLastActivity[sid] = System.currentTimeMillis()
val streamId = activeStreamMsgId[sid] val streamId = activeStreamMsgId[sid]
val toolId = event.data?.get("tool_id") as? String val toolId = event.data?.get("tool_id") as? String
val toolName = event.name val toolName = event.name
@@ -719,10 +727,34 @@ class GroupChatViewModel(
} }
} }
var replyText = var replyText: String? = null
withTimeoutOrNull(TURN_TIMEOUT_MS) { var deadline = turnStartTime + GROUP_TURN_TIMEOUT_MS
turnDeferred.await() val hardCapDeadline = turnStartTime + GROUP_TURN_HARD_CAP_MS
while (System.currentTimeMillis() < deadline) {
val pollResult =
withTimeoutOrNull(2000L) {
turnDeferred.await()
}
if (pollResult != null) {
replyText = pollResult
break
} }
if (activeEpoch != epoch) break
// Adaptive extension: if bot is actively working (tokens/tools streamed in last 60s), extend deadline
val lastActivity =
listOfNotNull(
activeRuntimeId?.let { sessionLastActivity[it] },
activeStoredId?.let { sessionLastActivity[it] },
).maxOrNull() ?: turnStartTime
val isBusy = (System.currentTimeMillis() - lastActivity) < 60_000L
if (isBusy) {
deadline =
minOf(hardCapDeadline, maxOf(deadline, System.currentTimeMillis() + GROUP_TURN_TIMEOUT_MS))
}
}
inFlightTurns.remove(runtimeId) inFlightTurns.remove(runtimeId)
storedId?.let { inFlightTurns.remove(it) } storedId?.let { inFlightTurns.remove(it) }
@@ -736,6 +768,7 @@ class GroupChatViewModel(
sessionId = tid, sessionId = tid,
limit = 10, limit = 10,
order = "latest", order = "latest",
profile = bot.name,
) )
} }
if (res is NetworkResult.Success) { if (res is NetworkResult.Success) {
@@ -828,11 +861,13 @@ class GroupChatViewModel(
activeStreamMsgId.remove(it) activeStreamMsgId.remove(it)
sessionToBot.remove(it) sessionToBot.remove(it)
inFlightTurns.remove(it) inFlightTurns.remove(it)
sessionLastActivity.remove(it)
} }
activeStoredId?.let { activeStoredId?.let {
activeStreamMsgId.remove(it) activeStreamMsgId.remove(it)
sessionToBot.remove(it) sessionToBot.remove(it)
inFlightTurns.remove(it) inFlightTurns.remove(it)
sessionLastActivity.remove(it)
} }
} }
} }