fix(chat): context meter reflects compression via session.context_breakdown RPC (#759)
* fix(chat): context meter reflects compression via session.context_breakdown RPC
The chat context meter read GET /api/sessions/{id} input_tokens — a
cumulative lifetime counter that compression never resets, so the meter
only ever grew and sat red after the first compaction.
Mirror the desktop: read live occupancy from the session.context_breakdown
WS RPC (context_used = compressor last_prompt_tokens, falling back to an
estimate of the live system prompt + tools + compacted history). The
compressor zeroes last_prompt_tokens when it runs, so the meter now drops
right after auto-compaction or /compress, exactly like desktop/web.
REST getSessionDetail is kept solely to feed the detail sheet's cumulative
token accounting. RPC failures keep the last known values; the meter never
blanks. Fixes #756.
* feat(chat): compressed xN badge on the context widget
The context chip now shows how many times the current session has been
context-compressed, sourced from the session.usage WS RPC (compressions =
context_compressor.compression_count) — the same usage snapshot the desktop
status bar reads. The badge explains a meter drop after auto-compaction or
/compress: the widget no longer just shows the number, it shows the story.
- UsageSnapshotResponse model + parseUsageSnapshot (Number-safe like the
breakdown parser — WS parser decodes JSON numbers as Double)
- compressionCount added to ChatUiState, reset on session switch/create/branch
- session.usage fires alongside session.context_breakdown in the 5s poll;
failures keep the last known badge value (best-effort, never blank)
- 3 parser tests; full local suite 615 tests green
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
ed48e10ca6
commit
e014d2a0e7
9 files changed
+320
-32
No files matched your search
@@ -0,0 +1,49 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
/**
|
||||
* Live context-window occupancy from the `session.context_breakdown` WS RPC —
|
||||
* the same RPC the Hermes desktop app's status-bar meter uses.
|
||||
*
|
||||
* Unlike the cumulative `input_tokens` REST counter, [contextUsed] is the
|
||||
* live agent's actual prompt occupancy: the context compressor's
|
||||
* `last_prompt_tokens` (the last prompt actually sent to the model), falling
|
||||
* back to an estimate of the live system prompt + tools + (compacted)
|
||||
* history. It therefore DROPS after context compression, which the cumulative
|
||||
* counter never does (issue #756).
|
||||
*
|
||||
* All counts are nullable — the backend omits/zeroes them when there is no
|
||||
* live agent or the engine doesn't track per-window occupancy.
|
||||
*/
|
||||
data class ContextBreakdownResponse(
|
||||
/** Actual current prompt size in tokens, or null when unknown. */
|
||||
val contextUsed: Long?,
|
||||
/** The active model's full context window in tokens, or null. */
|
||||
val contextMax: Long?,
|
||||
/** Clamped 0-100 fill percentage, or null. */
|
||||
val contextPercent: Int?,
|
||||
/** Rough estimate of system prompt + tools + history, or null. */
|
||||
val estimatedTotal: Long?,
|
||||
val model: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Parse the decoded JSON-RPC result of `session.context_breakdown`.
|
||||
*
|
||||
* The WS event parser decodes JSON numbers as [Double] (`JsonRpcModels.toAny`),
|
||||
* so every count is read through [Number]. Returns null when the payload is
|
||||
* not a map (error/malformed response) — callers treat that as unknown and
|
||||
* keep the last known meter values.
|
||||
*/
|
||||
fun parseContextBreakdown(result: Any?): ContextBreakdownResponse? {
|
||||
val map = result as? Map<*, *> ?: return null
|
||||
|
||||
fun num(key: String): Long? = (map[key] as? Number)?.toLong()
|
||||
|
||||
return ContextBreakdownResponse(
|
||||
contextUsed = num("context_used"),
|
||||
contextMax = num("context_max"),
|
||||
contextPercent = (map["context_percent"] as? Number)?.toInt(),
|
||||
estimatedTotal = num("estimated_total"),
|
||||
model = map["model"] as? String,
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Response from `GET /api/sessions/{id}` (gated — rides the app's existing
|
||||
* cookie auth). Only the fields the chat screen needs for the context meter
|
||||
* cookie auth). Only the fields the chat screen's context detail sheet needs
|
||||
* are modelled; the backend serializes many more and [kotlinx.serialization]
|
||||
* ignores the rest.
|
||||
*
|
||||
@@ -13,15 +13,19 @@ import kotlinx.serialization.Serializable
|
||||
* `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`,
|
||||
* `message_count`, etc. — but it does NOT have a `last_prompt_tokens` column
|
||||
* (that field lives only on the in-memory `SessionEntry` in the gateway
|
||||
* process, never persisted to the REST response). So the *used* context window
|
||||
* is sourced from `input_tokens` (cumulative prompt tokens for the session),
|
||||
* paired with [ModelInfoResponse.effective_context_length] as the denominator.
|
||||
* process, never persisted to the REST response).
|
||||
*
|
||||
* NOTE (issue #756): this endpoint is NO LONGER the chat meter's numerator
|
||||
* source — `input_tokens` is a cumulative lifetime counter that never drops
|
||||
* after context compression. The meter reads live occupancy from the
|
||||
* `session.context_breakdown` WS RPC ([ContextBreakdownResponse]); this
|
||||
* response feeds only the detail sheet's cumulative token accounting.
|
||||
*/
|
||||
@Serializable
|
||||
data class SessionDetailResponse(
|
||||
val session_id: String? = null,
|
||||
val session_key: String? = null,
|
||||
/** Cumulative prompt tokens for the session — the *used* context window. */
|
||||
/** Cumulative prompt tokens for the session (lifetime accounting). */
|
||||
val input_tokens: Long? = null,
|
||||
val output_tokens: Long? = null,
|
||||
val cache_read_tokens: Long? = null,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
/**
|
||||
* Minimal view of the `session.usage` WS RPC result — only the fields the
|
||||
* context widget consumes today. The backend sends many more keys
|
||||
* (`input`/`output`/`total`/`calls`/`active_subagents`/`credits_lines` …);
|
||||
* parsing is key-by-key, so unmodelled keys are simply ignored.
|
||||
*
|
||||
* Verified backend shape (`tui_gateway/server.py` `_get_usage`): `compressions`
|
||||
* = `context_compressor.compression_count` (0 when never compressed, absent on
|
||||
* sessions with no live agent).
|
||||
*/
|
||||
data class UsageSnapshotResponse(
|
||||
/** How many times this session has been context-compressed. */
|
||||
val compressions: Int?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Parse the decoded JSON-RPC result of `session.usage`.
|
||||
*
|
||||
* The WS event parser decodes JSON numbers as [Double] (`JsonRpcModels.toAny`),
|
||||
* so every count is read through [Number]. Returns null when the payload is
|
||||
* not a map (error/malformed response) — callers keep the last known value.
|
||||
*/
|
||||
fun parseUsageSnapshot(result: Any?): UsageSnapshotResponse? {
|
||||
val map = result as? Map<*, *> ?: return null
|
||||
return UsageSnapshotResponse(
|
||||
compressions = (map["compressions"] as? Number)?.toInt(),
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,12 @@ object WsMethods {
|
||||
const val SESSION_TITLE = "session.title"
|
||||
const val SESSION_BRANCH = "session.branch"
|
||||
|
||||
/** Live context-window occupancy for the chat meter (desktop-mirror). */
|
||||
const val SESSION_CONTEXT_BREAKDOWN = "session.context_breakdown"
|
||||
|
||||
/** Lifetime usage snapshot (calls, token totals, compression count). */
|
||||
const val SESSION_USAGE = "session.usage"
|
||||
|
||||
// ── Interaction ───────────────────────────────────────────────────────
|
||||
const val PROMPT_SUBMIT = "prompt.submit"
|
||||
const val CLARIFY_RESPOND = "clarify.respond"
|
||||
|
||||
@@ -542,6 +542,7 @@ fun ChatScreen(
|
||||
ContextUsageChip(
|
||||
usedTokens = state.usedContextTokens,
|
||||
fullTokens = state.fullContextTokens,
|
||||
compressionCount = state.compressionCount,
|
||||
onClick =
|
||||
if (state.contextBreakdown != null) {
|
||||
{ showContextSheet = true }
|
||||
|
||||
@@ -14,6 +14,8 @@ import com.m57.hermescontrol.data.model.AttachmentSource
|
||||
import com.m57.hermescontrol.data.model.ModelProvider
|
||||
import com.m57.hermescontrol.data.model.PinnedModel
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
import com.m57.hermescontrol.data.model.parseContextBreakdown
|
||||
import com.m57.hermescontrol.data.model.parseUsageSnapshot
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.GatewayFile
|
||||
import com.m57.hermescontrol.data.remote.GatewayFileClient
|
||||
@@ -98,7 +100,7 @@ data class ChatUiState(
|
||||
val currentSessionModel: String? = null,
|
||||
// Reasoning effort level for the current session
|
||||
val reasoningLevel: String? = null,
|
||||
// Context-window meter (issue #XXX): tokens currently used by the session
|
||||
// Context-window meter (issue #756): tokens currently used by the session
|
||||
// prompt (numerator) and the active model's full context window (denominator).
|
||||
// Both null until the first successful fetch.
|
||||
val usedContextTokens: Long? = null,
|
||||
@@ -106,6 +108,10 @@ data class ChatUiState(
|
||||
// Detailed token breakdown for the context meter's detail sheet (null until
|
||||
// the first successful session-detail fetch).
|
||||
val contextBreakdown: ContextBreakdown? = null,
|
||||
// How many times the current session has been context-compressed (null
|
||||
// until the first successful session.usage fetch) — drives the
|
||||
// "compressed ×N" badge on the context chip.
|
||||
val compressionCount: Int? = null,
|
||||
// Attachment state
|
||||
val pendingAttachments: List<Attachment> = emptyList(),
|
||||
// Reaction animation — set when a reaction WS event arrives, auto-clears
|
||||
@@ -160,9 +166,12 @@ data class SecretPromptUi(
|
||||
|
||||
/**
|
||||
* Token breakdown backing the context meter's detail sheet. All values are
|
||||
* token counts sourced from `GET /api/sessions/{id}` (`input_tokens`,
|
||||
* `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `reasoning_tokens`,
|
||||
* `message_count`) — verified present on the live gateway's `sessions` table.
|
||||
* cumulative lifetime token counts sourced from `GET /api/sessions/{id}`
|
||||
* (`input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`,
|
||||
* `reasoning_tokens`, `message_count`) — verified present on the live
|
||||
* gateway's `sessions` table. Informational accounting only; the meter's
|
||||
* live used/full values come from the `session.context_breakdown` RPC
|
||||
* (issue #756).
|
||||
*/
|
||||
data class ContextBreakdown(
|
||||
val inputTokens: Long,
|
||||
@@ -598,6 +607,7 @@ class ChatViewModel(
|
||||
usedContextTokens = null,
|
||||
fullContextTokens = null,
|
||||
contextBreakdown = null,
|
||||
compressionCount = null,
|
||||
)
|
||||
}
|
||||
// Mirror the active session id app-wide so session-scoped
|
||||
@@ -623,6 +633,7 @@ class ChatViewModel(
|
||||
usedContextTokens = null,
|
||||
fullContextTokens = null,
|
||||
contextBreakdown = null,
|
||||
compressionCount = null,
|
||||
)
|
||||
}
|
||||
ActiveSessionHolder.set(newId)
|
||||
@@ -1459,6 +1470,7 @@ class ChatViewModel(
|
||||
usedContextTokens = null,
|
||||
fullContextTokens = null,
|
||||
contextBreakdown = null,
|
||||
compressionCount = null,
|
||||
)
|
||||
}
|
||||
// Mirror the active session id app-wide (issue #532).
|
||||
@@ -1631,12 +1643,20 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the context-window meter for the current session.
|
||||
* Refresh the context meter: used / full tokens for the current session.
|
||||
*
|
||||
* Numerator (`usedContextTokens`) comes from the session record's
|
||||
* `last_prompt_tokens` (`/api/sessions/{id}`, backend
|
||||
* `gateway/session.py`). Denominator (`fullContextTokens`) comes from the
|
||||
* active model's `effective_context_length` (`/api/model/info`, PUBLIC).
|
||||
* The numerator comes from the `session.context_breakdown` WS RPC — the
|
||||
* same RPC the Hermes desktop app's status-bar meter uses. It reports the
|
||||
* live agent's actual prompt occupancy (compressor `last_prompt_tokens`,
|
||||
* falling back to an estimate of the live system prompt + tools +
|
||||
* history), so it DROPS after context compression. The previous numerator,
|
||||
* `GET /api/sessions/{id}` `input_tokens`, is a cumulative lifetime
|
||||
* counter that never resets on compression (issue #756).
|
||||
*
|
||||
* The denominator comes from the RPC's `context_max` (the compressor's
|
||||
* real context window) when present, else `GET /api/model/info`
|
||||
* `effective_context_length`. The REST session-detail call is kept only to
|
||||
* feed the detail sheet's cumulative token accounting.
|
||||
*
|
||||
* Both calls are independent and best-effort: a failure on one must not
|
||||
* wipe the other's already-shown value, and neither blocks the chat. The
|
||||
@@ -1648,7 +1668,8 @@ class ChatViewModel(
|
||||
val sessionId = _uiState.value.currentSessionId ?: return
|
||||
val profile = AuthManager.getSelectedProfileId()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
// Denominator: full context window (cheap, public, rarely changes).
|
||||
// Denominator fallback: full context window (cheap, public, rarely
|
||||
// changes). The RPC's context_max below overrides it when present.
|
||||
val fullResult =
|
||||
safeApiCall { ApiClient.hermesApi.getModelInfo() }
|
||||
if (fullResult is NetworkResult.Success) {
|
||||
@@ -1660,10 +1681,49 @@ class ChatViewModel(
|
||||
_uiState.update { it.copy(fullContextTokens = full) }
|
||||
}
|
||||
}
|
||||
// Numerator: used context for THIS session. The gateway's sessions
|
||||
// table persists `input_tokens` (cumulative prompt tokens) — there is
|
||||
// NO `last_prompt_tokens` column on the REST response, so we use
|
||||
// `input_tokens` as the used-context numerator.
|
||||
// Numerator: live context occupancy from the gateway's live agent,
|
||||
// via the same RPC the desktop meter uses. `context_used` is the
|
||||
// real current prompt size (drops after compression); `context_max`
|
||||
// is the compressor's actual window. Any failure keeps the last
|
||||
// known values — never blank the meter over a transient RPC error.
|
||||
val rpcSessionId = runtimeSessionId ?: sessionId
|
||||
try {
|
||||
val result =
|
||||
sendRpcAndAwait(
|
||||
WsMethods.SESSION_CONTEXT_BREAKDOWN,
|
||||
mapOf("session_id" to rpcSessionId),
|
||||
)
|
||||
val ctx = parseContextBreakdown(result)
|
||||
if (ctx != null) {
|
||||
_uiState.update { current ->
|
||||
current.copy(
|
||||
usedContextTokens =
|
||||
ctx.contextUsed?.takeIf { it > 0L } ?: current.usedContextTokens,
|
||||
fullContextTokens =
|
||||
ctx.contextMax?.takeIf { it > 0L } ?: current.fullContextTokens,
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Best-effort: RPC error/timeout/disconnect — keep last values.
|
||||
}
|
||||
// Compression count: how many times this session has been compacted
|
||||
// (session.usage → compressions). Feeds the "compressed ×N" badge —
|
||||
// the same usage snapshot the desktop status bar reads.
|
||||
try {
|
||||
val usage =
|
||||
sendRpcAndAwait(
|
||||
WsMethods.SESSION_USAGE,
|
||||
mapOf("session_id" to rpcSessionId),
|
||||
)
|
||||
val snapshot = parseUsageSnapshot(usage)
|
||||
if (snapshot != null && snapshot.compressions != null) {
|
||||
_uiState.update { it.copy(compressionCount = snapshot.compressions) }
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Best-effort: keep the last known badge value.
|
||||
}
|
||||
// Detail-sheet accounting (cumulative REST counters, informational).
|
||||
val usedResult =
|
||||
safeApiCall { ApiClient.hermesApi.getSessionDetail(sessionId, profile) }
|
||||
if (usedResult is NetworkResult.Success) {
|
||||
@@ -1672,7 +1732,6 @@ class ChatViewModel(
|
||||
if (used != null) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
usedContextTokens = used,
|
||||
contextBreakdown =
|
||||
ContextBreakdown(
|
||||
inputTokens = used,
|
||||
|
||||
@@ -28,20 +28,26 @@ import kotlin.math.min
|
||||
* window so the user sees compression pressure building before a turn silently
|
||||
* compacts.
|
||||
*
|
||||
* The denominator (`fullTokens`) comes from `GET /api/model/info` (PUBLIC) and
|
||||
* is always available. The numerator (`usedTokens`) comes from
|
||||
* `GET /api/sessions/{id}` (`input_tokens`) — gated, so it may lag or be absent
|
||||
* on first render. The chip shows as soon as `fullTokens` is known; when
|
||||
* `usedTokens` is still null it renders `— / <full>` (unknown used) rather than
|
||||
* hiding entirely, so a missing gated fetch never blanks the meter.
|
||||
* The denominator (`fullTokens`) comes from the `session.context_breakdown`
|
||||
* WS RPC's `context_max` (fallback `GET /api/model/info`), and the numerator
|
||||
* (`usedTokens`) from the same RPC's `context_used` — the live agent's actual
|
||||
* prompt occupancy, which drops after context compression (issue #756). The
|
||||
* RPC may be absent on first render (WS still connecting); the chip shows as
|
||||
* soon as `fullTokens` is known, and when `usedTokens` is still null it
|
||||
* renders `— / <full>` (unknown used) rather than hiding entirely, so a
|
||||
* missing fetch never blanks the meter.
|
||||
*
|
||||
* @param usedTokens tokens currently in the session prompt (numerator), or null
|
||||
* @param fullTokens the active model's full context window (denominator)
|
||||
* @param compressionCount how many times the session has been context-compressed;
|
||||
* when > 0 a "compressed ×N" badge renders next to the percentage — the badge
|
||||
* explains a meter drop after auto-compaction or `/compress` (issue #756)
|
||||
*/
|
||||
@Composable
|
||||
fun ContextUsageChip(
|
||||
usedTokens: Long?,
|
||||
fullTokens: Long?,
|
||||
compressionCount: Int? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -91,12 +97,25 @@ fun ContextUsageChip(
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = "$pct%",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = barColor,
|
||||
maxLines = 1,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (compressionCount != null && compressionCount > 0) {
|
||||
Text(
|
||||
text = "compressed ×$compressionCount",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "$pct%",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = barColor,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
|
||||
Reference in new issue
Block a user