mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
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);
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user