fix(agent,desktop): align context gauge with compaction trigger / 对齐上下文容量条与压缩触发器 (#8329)

* fix(agent,desktop): align the context gauge with the compaction trigger

The desktop context gauge measured something different from the
compaction trigger, in both directions:

- The backend gauge (ContextUsedTokens) used the message-only estimator
  while the trigger sizes messages + role projection + tool schemas
  (estimatedVisibleRequestTokens). With a non-empty tool registry the
  gauge under-reported the fill, so a session could display below
  compact_ratio while the trigger had already crossed it (the #8221 test
  passed only because its tool registry was empty).
- The desktop frontend overwrote the backend gauge with the last turn's
  real usage and added completion tokens to the used figure. A long
  output round inflated the displayed fill (e.g. 90%) while the trigger,
  which only counts the next request's prompt, still sat below the
  configured 85% — compaction never fired and the session ran into a real
  overflow.

Both now use one measurement: the gauge calls the trigger's own estimator,
and the frontend counts prompt tokens only. Completion stays in the output
metrics, not the occupancy gauge.

Refs: #8191

* fix(agent,desktop): keep context gauge current

Problem:
- Context usage stayed cached after live MCP tool changes.
- Completion-only latest usage could collapse prompt occupancy to zero.
- The PR also tripped formatting and repository budget gates.

Root cause:
- The memoization key omitted the provider-visible tool registry revision.
- Frontend fallback selected context prompt data based on unrelated completion data.

Fix:
- Track tool schema revisions and include registry identity plus revision in the gauge cache key.
- Select prompt and completion fallbacks independently and align ContextFillTokens with prompt occupancy.
- Add deterministic backend and frontend regression coverage while removing budget-breaking comments.

Verification:
- go test ./...
- go test -race ./internal/tool ./internal/agent
- go vet ./...
- pnpm build
- go run ./tools/repolint

---------

Co-authored-by: SivanCola <[email protected]>
This commit is contained in:
xiaotaoya2022-cellandSivanCola authored and GitHub committed 2026-08-11 07:24:24 +00:00
1 parent ced0eeeb9a
commit ee2a6a766f
8 files changed
+184 -29

No files matched your search

@@ -490,5 +490,33 @@ function ev(s: typeof initialState, e: WireEvent) {
}
}
// --- 8. context occupancy uses prompt tokens and keeps legacy fallback semantics ---
{
let s = ev({
...initialState,
running: true,
turnActive: true,
context: { ...initialState.context, window: 1_000 },
}, { kind: "usage", usage: {
promptTokens: 500,
completionTokens: 20,
totalTokens: 520,
contextPromptTokens: 0,
contextCompletionTokens: 20,
source: "executor",
} } as WireEvent);
eq(s.context.used, 500, "completion-only latest usage falls back to aggregate prompt occupancy");
s = ev({ ...s, running: true, turnActive: true }, { kind: "usage", usage: {
promptTokens: 700,
completionTokens: 30,
totalTokens: 730,
contextPromptTokens: 450,
contextCompletionTokens: 0,
source: "executor",
} } as WireEvent);
eq(s.context.used, 450, "latest-attempt prompt occupancy excludes completion tokens");
}
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
+5 -5
View File
@@ -1669,15 +1669,15 @@ function applyEvent(s: State, e: WireEvent): State {
// subagent, and auxiliary usage still contributes to session totals and
// usageSeq, but must not close or inflate the executor TPS interval.
const settled = updateContextGauge ? endTurnModelActivity(s, Date.now(), true) : s;
const hasRequestContext = (e.usage?.contextPromptTokens ?? 0) > 0 || (e.usage?.contextCompletionTokens ?? 0) > 0;
const hasRequestCompletion = (e.usage?.contextCompletionTokens ?? 0) > 0;
const requestModelMs = updateContextGauge ? (settled.pendingRequestModelMs ?? 0) : 0;
const requestTokens = updateContextGauge ? (hasRequestContext ? (e.usage?.contextCompletionTokens ?? 0) : (e.usage?.completionTokens ?? 0)) : 0;
const requestTokens = updateContextGauge ? (hasRequestCompletion ? (e.usage?.contextCompletionTokens ?? 0) : (e.usage?.completionTokens ?? 0)) : 0;
const lastRequestTps = updateContextGauge ? (requestTokens > 0 && requestModelMs >= 500 ? requestTokens / (requestModelMs / 1000) : null) : s.lastRequestTps;
// Context* is the latest sampling attempt; other token fields are billable aggregates.
let used = settled.context.used;
if (e.usage && settled.context.window && updateContextGauge) used = hasRequestContext
? (e.usage.contextPromptTokens ?? 0) + (e.usage.contextCompletionTokens ?? 0)
: (e.usage.promptTokens ?? 0) + (e.usage.completionTokens ?? 0);
if (e.usage && settled.context.window && updateContextGauge) used = (e.usage.contextPromptTokens ?? 0) > 0
? (e.usage.contextPromptTokens ?? 0)
: (e.usage.promptTokens ?? 0);
const turnTokens = settled.turnTokens + (e.usage?.completionTokens ?? 0);
const turnOutputTokens = updateContextGauge
? settled.turnOutputTokens + (e.usage?.completionTokens ?? 0)
+22 -12
View File
@@ -1,14 +1,18 @@
package agent
import "reasonix/internal/tool"
// contextUsage memoises the projected prompt size. The estimate walks every
// visible message, and status gauges redraw far more often than the view moves,
// so it is keyed on the three things that can change the answer: the
// transcript, the projection, and the calibration.
// so it is keyed on everything that can change the answer: the transcript,
// projection, calibration, and provider-visible tool schemas.
type contextUsage struct {
transcriptVersion uint64
projectionVersion uint64
calibration *promptTokenCalibration
tokens int
transcriptVersion uint64
projectionVersion uint64
calibration *promptTokenCalibration
tools *tool.Registry
toolSchemaRevision uint64
tokens int
}
// ContextUsedTokens is the number ContextManager compares against its
@@ -27,18 +31,24 @@ func (a *Agent) ContextUsedTokens() int {
transcriptVersion := session.TranscriptVersion()
projectionVersion := a.currentProjectionVersion()
calibration := a.promptCalibration.Load()
tools := a.tools
toolSchemaRevision := tools.SchemaRevision()
if cached := a.contextUsage.Load(); cached != nil &&
cached.transcriptVersion == transcriptVersion &&
cached.projectionVersion == projectionVersion &&
cached.calibration == calibration {
cached.calibration == calibration &&
cached.tools == tools &&
cached.toolSchemaRevision == toolSchemaRevision {
return cached.tokens
}
tokens := a.estimatedPromptTokens(a.modelVisibleMessages())
tokens := a.estimatedVisibleRequestTokens(a.modelVisibleMessages())
a.contextUsage.Store(&contextUsage{
transcriptVersion: transcriptVersion,
projectionVersion: projectionVersion,
calibration: calibration,
tokens: tokens,
transcriptVersion: transcriptVersion,
projectionVersion: projectionVersion,
calibration: calibration,
tools: tools,
toolSchemaRevision: toolSchemaRevision,
tokens: tokens,
})
return tokens
}
+83
View File
@@ -1,6 +1,8 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
@@ -10,6 +12,21 @@ import (
"reasonix/internal/tool"
)
// bigSchemaTool mimics a real built-in tool whose JSON schema is large enough
// to matter: the compaction trigger counts tool schemas in the prompt it sizes,
// so the gauge must too.
type bigSchemaTool struct{}
func (bigSchemaTool) Name() string { return "big_schema" }
func (bigSchemaTool) Description() string { return "a tool with a large schema" }
func (bigSchemaTool) Schema() json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string","description":"` +
strings.Repeat("a fairly long property description that consumes tokens. ", 200) +
`"},"query":{"type":"string","description":"another long field to inflate the schema"}}}`)
}
func (bigSchemaTool) Execute(context.Context, json.RawMessage) (string, error) { return "", nil }
func (bigSchemaTool) ReadOnly() bool { return true }
func usageFixture(t *testing.T, toolResults int) *Agent {
t.Helper()
big := strings.Repeat("line\n", 400)
@@ -50,6 +67,72 @@ func TestContextUsedTokensMatchesTheTriggerInput(t *testing.T) {
}
}
// Regression: the gauge used the message-only estimator while the trigger also
// sizes tool schemas, so with a non-empty tool registry the gauge under-reported
// the fill — a session could display 80% while the trigger had already crossed
// compact_ratio (and after a compaction the two disagreed again). The gauge must
// call the exact same estimator as the trigger, tool schemas included.
func TestContextUsedTokensIncludesToolSchemasLikeTheTrigger(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(bigSchemaTool{})
msgs := []provider.Message{
{Role: provider.RoleSystem, Content: "system"},
{Role: provider.RoleUser, Content: "task"},
}
a := New(nil, reg, &Session{Messages: msgs}, Options{
ContextWindow: 1_000_000,
RecentKeep: 2,
ArchiveDir: t.TempDir(),
}, event.Discard)
used := a.ContextUsedTokens()
// The trigger's own measurement, verbatim.
if got := a.ContextMaintenanceSnapshot().ProjectedTokens; used != got {
t.Fatalf("gauge = %d, trigger input = %d; they must be the same measurement", used, got)
}
// The gauge must include the tool schemas the trigger counts. The old
// message-only estimator ignored them and reported exactly the message cost.
if msgOnly := a.estimatedPromptTokens(a.modelVisibleMessages()); used <= msgOnly {
t.Fatalf("gauge = %d, message-only estimate = %d; the gauge must price tool schemas like the trigger", used, msgOnly)
}
}
func TestContextUsedTokensFollowsLiveToolRegistry(t *testing.T) {
reg := tool.NewRegistry()
a := New(nil, reg, &Session{Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "system"},
{Role: provider.RoleUser, Content: "task"},
}}, Options{ContextWindow: 1_000_000, RecentKeep: 2, ArchiveDir: t.TempDir()}, event.Discard)
withoutTools := a.ContextUsedTokens()
reg.Add(bigSchemaTool{})
withTools := a.ContextUsedTokens()
if got := a.ContextMaintenanceSnapshot().ProjectedTokens; withTools != got {
t.Fatalf("gauge after tool registration = %d, trigger input = %d", withTools, got)
}
if withTools <= withoutTools {
t.Fatalf("gauge did not grow after tool registration: %d -> %d", withoutTools, withTools)
}
if removed := reg.RemovePrefix("big_"); removed != 1 {
t.Fatalf("removed %d tools, want 1", removed)
}
if got := a.ContextUsedTokens(); got != withoutTools {
t.Fatalf("gauge after tool removal = %d, want %d", got, withoutTools)
}
reg.Add(bigSchemaTool{})
if got := a.ContextUsedTokens(); got != withTools {
t.Fatalf("gauge after re-registration = %d, want %d", got, withTools)
}
if removed := reg.SuspendPrefix("big_"); removed != 1 {
t.Fatalf("suspended %d tools, want 1", removed)
}
if got := a.ContextUsedTokens(); got != withoutTools {
t.Fatalf("gauge after tool suspension = %d, want %d", got, withoutTools)
}
}
func TestContextUsedTokensIsZeroWithoutASession(t *testing.T) {
a := &Agent{}
if got := a.ContextUsedTokens(); got != 0 {
+6 -2
View File
@@ -88,8 +88,12 @@ func TestFinalizeSamplingUsageKeepsLatestPromptContext(t *testing.T) {
if got.ContextPromptTokens != 30000 || got.ContextCompletionTokens != 10 {
t.Fatalf("context shape = prompt %d completion %d, want latest 30000/10", got.ContextPromptTokens, got.ContextCompletionTokens)
}
if got.ContextFillTokens() != 30010 {
t.Fatalf("ContextFillTokens = %d, want 30010", got.ContextFillTokens())
if got.ContextFillTokens() != 30000 {
t.Fatalf("ContextFillTokens = %d, want 30000", got.ContextFillTokens())
}
completionOnly := &provider.Usage{PromptTokens: 500, ContextCompletionTokens: 20}
if fill := completionOnly.ContextFillTokens(); fill != 500 {
t.Fatalf("completion-only ContextFillTokens = %d, want prompt fallback 500", fill)
}
if got.CompletionTokens != 30 || got.RequestCount != 3 {
t.Fatalf("billable fields = %+v, want summed completion/requests", got)
+2 -10
View File
@@ -754,17 +754,9 @@ type Usage struct {
ContextCacheMissTokens int
}
// ContextFillTokens returns the latest-attempt context fill (prompt+completion)
// used by status bars and context panels. Falls back to billable totals when
// no Context* fields were set (single-attempt / legacy usage events).
// ContextFillTokens returns the latest prompt occupancy used by context gauges.
func (u *Usage) ContextFillTokens() int {
if u == nil {
return 0
}
if u.ContextPromptTokens > 0 || u.ContextCompletionTokens > 0 {
return u.ContextPromptTokens + u.ContextCompletionTokens
}
return u.PromptTokens + u.CompletionTokens
return u.LatestPromptTokens()
}
// LatestPromptTokens returns the latest-attempt prompt size for context-aware
+21
View File
@@ -149,6 +149,27 @@ func TestRegistrySuspendPrefixBlocksLateAddsUntilResume(t *testing.T) {
}
}
func TestRegistrySchemaRevisionTracksVisibleChanges(t *testing.T) {
r := NewRegistry()
initial := r.SchemaRevision()
r.Add(stubTool{name: "mcp__fs__read"})
afterAdd := r.SchemaRevision()
if afterAdd <= initial {
t.Fatalf("revision after add = %d, want greater than %d", afterAdd, initial)
}
r.Add(stubTool{name: "mcp__fs__read", schema: json.RawMessage(`{"type":"string"}`)})
afterReplace := r.SchemaRevision()
if afterReplace <= afterAdd {
t.Fatalf("revision after replace = %d, want greater than %d", afterReplace, afterAdd)
}
if removed := r.RemovePrefix("missing"); removed != 0 || r.SchemaRevision() != afterReplace {
t.Fatalf("no-op removal changed revision: removed=%d revision=%d", removed, r.SchemaRevision())
}
if removed := r.SuspendPrefix("mcp__fs__"); removed != 1 || r.SchemaRevision() <= afterReplace {
t.Fatalf("suspension did not advance revision: removed=%d revision=%d", removed, r.SchemaRevision())
}
}
// TestRegistrySchemasSorted proves Schemas() emits tool definitions in
// deterministic alphabetical order regardless of insertion order, so a logically
// identical tool set produces a stable provider-facing request prefix (prompt
+17
View File
@@ -11,6 +11,7 @@ import (
"sort"
"strings"
"sync"
"sync/atomic"
"reasonix/internal/diff"
"reasonix/internal/provider"
@@ -284,6 +285,7 @@ type Registry struct {
order []string
canon map[string]json.RawMessage
suspended map[string]bool
schemaRev atomic.Uint64
}
// NewRegistry returns an empty registry.
@@ -309,6 +311,7 @@ func (r *Registry) Add(t Tool) {
}
r.tools[name] = t
r.canon[name] = provider.CanonicalizeSchema(t.Schema())
r.schemaRev.Add(1)
}
// MCPNamePrefix is the namespace every MCP tool name carries: the
@@ -349,6 +352,9 @@ func (r *Registry) RemovePrefix(prefix string) int {
kept = append(kept, name)
}
r.order = kept
if removed > 0 {
r.schemaRev.Add(1)
}
return removed
}
@@ -373,6 +379,9 @@ func (r *Registry) SuspendPrefix(prefix string) int {
kept = append(kept, name)
}
r.order = kept
if removed > 0 {
r.schemaRev.Add(1)
}
return removed
}
@@ -515,6 +524,14 @@ func (r *Registry) Len() int {
return len(r.order)
}
// SchemaRevision changes whenever the provider-visible tool set changes.
func (r *Registry) SchemaRevision() uint64 {
if r == nil {
return 0
}
return r.schemaRev.Load()
}
// Names returns the registered tool names in insertion order.
func (r *Registry) Names() []string {
r.mu.RLock()