Skip to content

Commit 9957a94

Browse files
authored
Merge pull request #1469 from thotam/fix/session-context-usage-tokens
fix(sessions): store last-call context tokens instead of run-cumulative total
2 parents 08e8040 + 503909d commit 9957a94

10 files changed

Lines changed: 197 additions & 14 deletions

‎internal/agent/loop_pipeline_callbacks.go‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ type pipelineCallbackSet struct {
9393
checkReadOnly func(state *pipeline.RunState) (*providers.Message, bool)
9494
sanitizeContent func(string) string
9595
flushMessages func(ctx context.Context, sessionKey string, msgs []providers.Message) error
96-
updateMetadata func(ctx context.Context, sessionKey string, usage providers.Usage, msgCount int) error
96+
updateMetadata func(ctx context.Context, sessionKey string, usage, lastUsage providers.Usage, msgCount int) error
9797
bootstrapCleanup func(ctx context.Context, state *pipeline.RunState) error
9898
maybeSummarize func(ctx context.Context, sessionKey string)
9999
}
@@ -713,14 +713,21 @@ func (l *Loop) makeFlushMessages(req *RunRequest) func(ctx context.Context, sess
713713
}
714714
}
715715

716-
func (l *Loop) makeUpdateMetadata(req *RunRequest) func(ctx context.Context, sessionKey string, usage providers.Usage, msgCount int) error {
717-
return func(ctx context.Context, sessionKey string, usage providers.Usage, msgCount int) error {
716+
func (l *Loop) makeUpdateMetadata(req *RunRequest) func(ctx context.Context, sessionKey string, usage, lastUsage providers.Usage, msgCount int) error {
717+
return func(ctx context.Context, sessionKey string, usage, lastUsage providers.Usage, msgCount int) error {
718718
l.sessions.UpdateMetadata(ctx, sessionKey, l.model, l.provider.Name(), req.Channel)
719719
l.sessions.AccumulateTokens(ctx, sessionKey, int64(usage.PromptTokens), int64(usage.CompletionTokens))
720720
// Persist session to DB (matching v2 finalizeRun behavior).
721721
// FlushMessages already ran, so all pending messages are in the cache.
722-
if usage.PromptTokens > 0 {
723-
l.sessions.SetLastPromptTokens(ctx, sessionKey, usage.PromptTokens, msgCount)
722+
// Calibration uses the FINAL iteration's context size, NOT the
723+
// run-cumulative total — the total sums every think→act→observe
724+
// iteration and inflated the sessions "context used" display and
725+
// compaction decisions by the iteration count.
726+
// Current context = final prompt (incl. cached segments) + final output:
727+
// the last reply joins history, so it occupies the next request's prompt.
728+
// This matches msgCount, which already counts the flushed reply.
729+
if lastCtx := lastUsage.ContextTokens(); lastCtx > 0 {
730+
l.sessions.SetLastPromptTokens(ctx, sessionKey, lastCtx+lastUsage.CompletionTokens, msgCount)
724731
}
725732
l.sessions.Save(ctx, sessionKey)
726733
return nil

‎internal/pipeline/deps.go‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,13 @@ type PipelineDeps struct {
121121
DeduplicateMediaSuffix func(content, suffix string) string
122122
IsSilentReply func(content string) bool
123123
EmitSessionCompleted func(ctx context.Context, sessionKey string, msgCount, tokensUsed, compactionCount int)
124-
UpdateMetadata func(ctx context.Context, sessionKey string, usage providers.Usage, msgCount int) error
125-
BootstrapCleanup func(ctx context.Context, state *RunState) error
126-
MaybeSummarize func(ctx context.Context, sessionKey string)
124+
// UpdateMetadata persists run token accounting: usage is the run-cumulative
125+
// total (billing/AccumulateTokens); lastUsage is the final iteration's own
126+
// usage — its ContextTokens() is the session's current context size
127+
// (SetLastPromptTokens → sessions context display + compaction calibration).
128+
UpdateMetadata func(ctx context.Context, sessionKey string, usage, lastUsage providers.Usage, msgCount int) error
129+
BootstrapCleanup func(ctx context.Context, state *RunState) error
130+
MaybeSummarize func(ctx context.Context, sessionKey string)
127131
}
128132

129133
// FireHook is nil-safe. Returns FireResult{Decision: DecisionAllow} when no

‎internal/pipeline/finalize_stage.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ func (s *FinalizeStage) Execute(ctx context.Context, state *RunState) error {
133133
// 5. Update session metadata (token usage)
134134
if s.deps.UpdateMetadata != nil {
135135
msgCount := historyCountBeforeFlush + len(persistablePending)
136-
if err := s.deps.UpdateMetadata(ctx, state.Input.SessionKey, state.Think.TotalUsage, msgCount); err != nil {
136+
if err := s.deps.UpdateMetadata(ctx, state.Input.SessionKey, state.Think.TotalUsage, state.Think.LastUsage, msgCount); err != nil {
137137
slog.Warn("finalize metadata update failed", "err", err)
138138
}
139139
}

‎internal/pipeline/finalize_stage_image_delivery_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ func TestFinalizeStage_GeneratedImageReachesMediaResults(t *testing.T) {
2323

2424
deps := &PipelineDeps{
2525
FlushMessages: func(context.Context, string, []providers.Message) error { return nil },
26-
UpdateMetadata: func(context.Context, string, providers.Usage, int) error { return nil },
26+
UpdateMetadata: func(context.Context, string, providers.Usage, providers.Usage, int) error { return nil },
2727
// Simulates persistAssistantImages: real implementation decodes base64,
2828
// writes to workspace/media/, and appends MediaRefs to msg.
2929
PersistAssistantImages: func(msg *providers.Message, _ string) {
@@ -73,7 +73,7 @@ func TestFinalizeStage_NoGeneratedImagesNoMediaResults(t *testing.T) {
7373

7474
deps := &PipelineDeps{
7575
FlushMessages: func(context.Context, string, []providers.Message) error { return nil },
76-
UpdateMetadata: func(context.Context, string, providers.Usage, int) error { return nil },
76+
UpdateMetadata: func(context.Context, string, providers.Usage, providers.Usage, int) error { return nil },
7777
}
7878

7979
stage := NewFinalizeStage(deps)

‎internal/pipeline/finalize_stage_metadata_test.go‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ func TestFinalizeStage_UpdateMetadataReceivesPersistedMessageCount(t *testing.T)
1111
t.Parallel()
1212

1313
var gotUsage providers.Usage
14+
var gotLastUsage providers.Usage
1415
var gotMsgCount int
1516
deps := &PipelineDeps{
1617
FlushMessages: func(_ context.Context, _ string, _ []providers.Message) error {
1718
return nil
1819
},
19-
UpdateMetadata: func(_ context.Context, _ string, usage providers.Usage, msgCount int) error {
20+
UpdateMetadata: func(_ context.Context, _ string, usage, lastUsage providers.Usage, msgCount int) error {
2021
gotUsage = usage
22+
gotLastUsage = lastUsage
2123
gotMsgCount = msgCount
2224
return nil
2325
},
@@ -33,13 +35,21 @@ func TestFinalizeStage_UpdateMetadataReceivesPersistedMessageCount(t *testing.T)
3335
state.Messages.AppendPending(providers.Message{Role: "assistant", Content: "transient", Transient: true})
3436
state.Observe.FinalContent = "final answer"
3537
state.Think.TotalUsage = providers.Usage{PromptTokens: 1234, CompletionTokens: 56}
38+
state.Think.LastUsage = providers.Usage{PromptTokens: 700, CompletionTokens: 30}
3639

3740
if err := stage.Execute(context.Background(), state); err != nil {
3841
t.Fatalf("Execute() error: %v", err)
3942
}
4043
if gotUsage.PromptTokens != 1234 || gotUsage.CompletionTokens != 56 {
4144
t.Fatalf("UpdateMetadata usage = %+v, want prompt=1234 completion=56", gotUsage)
4245
}
46+
// Regression guard: the last-call usage (current context size) must reach
47+
// UpdateMetadata separately from the run-cumulative total — the sessions
48+
// "context used" bar and compaction calibration read it via
49+
// SetLastPromptTokens. Passing the total inflated it by the iteration count.
50+
if gotLastUsage.PromptTokens != 700 {
51+
t.Fatalf("UpdateMetadata lastUsage.PromptTokens = %d, want 700 (final iteration only)", gotLastUsage.PromptTokens)
52+
}
4353
if gotMsgCount != 4 {
4454
t.Fatalf("UpdateMetadata msgCount = %d, want 4 persisted messages", gotMsgCount)
4555
}

‎internal/pipeline/substates.go‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,14 @@ type ContextState struct {
2929

3030
// ThinkState: owned by ThinkStage.
3131
type ThinkState struct {
32-
LastResponse *providers.ChatResponse
33-
TotalUsage providers.Usage
32+
LastResponse *providers.ChatResponse
33+
TotalUsage providers.Usage
34+
// LastUsage snapshots the most recent iteration that reported prompt tokens.
35+
// Unlike TotalUsage (run-cumulative), it reflects the actual size of the last
36+
// prompt sent to the model — the session's current context. Consumed by
37+
// FinalizeStage → UpdateMetadata → SetLastPromptTokens for the sessions
38+
// context-usage display and compaction calibration.
39+
LastUsage providers.Usage
3440
TruncRetries int // consecutive truncation retries (max 3)
3541
OverflowRetries int // context overflow compact+retry attempts (max 1)
3642
StreamingActive bool // true during active stream

‎internal/pipeline/think_stage.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ func (s *ThinkStage) Execute(ctx context.Context, state *RunState) error {
9595
if resp.Usage.PromptTokensIncludeCachedSegments {
9696
state.Think.TotalUsage.PromptTokensIncludeCachedSegments = true
9797
}
98+
// Snapshot per-call usage: the LAST iteration's prompt size IS the
99+
// session's current context. Keep the previous snapshot when a response
100+
// carries no prompt tokens (e.g. providers that omit usage on some turns).
101+
if resp.Usage.PromptTokens > 0 {
102+
state.Think.LastUsage = *resp.Usage
103+
}
98104
}
99105

100106
if isEmptyLengthResponse(resp) {
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package pipeline
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/nextlevelbuilder/goclaw/internal/providers"
8+
)
9+
10+
// TestThinkStage_TracksLastUsagePerIteration guards the per-iteration usage
11+
// snapshot used for the sessions "context used" display and compaction
12+
// calibration. Regression: SetLastPromptTokens received the RUN-CUMULATIVE
13+
// TotalUsage.PromptTokens (sum over all think→act→observe iterations), so a
14+
// tool-heavy run reported e.g. 901K "context" against a 258K window and
15+
// over-triggered compaction. LastUsage must hold only the FINAL iteration's
16+
// own usage — the actual size of the last prompt sent to the model.
17+
func TestThinkStage_TracksLastUsagePerIteration(t *testing.T) {
18+
t.Parallel()
19+
20+
usages := []providers.Usage{
21+
{PromptTokens: 100000, CompletionTokens: 500, TotalTokens: 100500},
22+
{PromptTokens: 120000, CompletionTokens: 300, TotalTokens: 120300, CacheReadTokens: 90000},
23+
}
24+
call := 0
25+
deps := &PipelineDeps{
26+
Config: PipelineConfig{MaxIterations: 10, MaxTokens: 1000},
27+
CallLLM: func(_ context.Context, _ *RunState, _ providers.ChatRequest) (*providers.ChatResponse, error) {
28+
u := usages[call]
29+
call++
30+
return &providers.ChatResponse{FinishReason: "stop", Content: "done", Usage: &u}, nil
31+
},
32+
}
33+
stage := NewThinkStage(deps)
34+
state := defaultState()
35+
36+
if err := stage.Execute(context.Background(), state); err != nil {
37+
t.Fatalf("Execute() 1: %v", err)
38+
}
39+
if err := stage.Execute(context.Background(), state); err != nil {
40+
t.Fatalf("Execute() 2: %v", err)
41+
}
42+
43+
// TotalUsage accumulates (unchanged behavior).
44+
if state.Think.TotalUsage.PromptTokens != 220000 {
45+
t.Errorf("TotalUsage.PromptTokens = %d, want 220000", state.Think.TotalUsage.PromptTokens)
46+
}
47+
// LastUsage holds only the final iteration's usage — NOT the sum.
48+
if state.Think.LastUsage.PromptTokens != 120000 {
49+
t.Errorf("LastUsage.PromptTokens = %d, want 120000 (final iteration only)", state.Think.LastUsage.PromptTokens)
50+
}
51+
if state.Think.LastUsage.CacheReadTokens != 90000 {
52+
t.Errorf("LastUsage.CacheReadTokens = %d, want 90000", state.Think.LastUsage.CacheReadTokens)
53+
}
54+
}
55+
56+
// TestThinkStage_LastUsageKeptWhenFinalResponseLacksUsage: a final iteration
57+
// whose response carries no usage (nil or zero prompt tokens) must not wipe the
58+
// last usable snapshot.
59+
func TestThinkStage_LastUsageKeptWhenFinalResponseLacksUsage(t *testing.T) {
60+
t.Parallel()
61+
62+
responses := []*providers.ChatResponse{
63+
{FinishReason: "stop", Content: "a", Usage: &providers.Usage{PromptTokens: 50000, CompletionTokens: 10}},
64+
{FinishReason: "stop", Content: "b", Usage: nil},
65+
}
66+
call := 0
67+
deps := &PipelineDeps{
68+
Config: PipelineConfig{MaxIterations: 10, MaxTokens: 1000},
69+
CallLLM: func(_ context.Context, _ *RunState, _ providers.ChatRequest) (*providers.ChatResponse, error) {
70+
r := responses[call]
71+
call++
72+
return r, nil
73+
},
74+
}
75+
stage := NewThinkStage(deps)
76+
state := defaultState()
77+
78+
if err := stage.Execute(context.Background(), state); err != nil {
79+
t.Fatalf("Execute() 1: %v", err)
80+
}
81+
if err := stage.Execute(context.Background(), state); err != nil {
82+
t.Fatalf("Execute() 2: %v", err)
83+
}
84+
85+
if state.Think.LastUsage.PromptTokens != 50000 {
86+
t.Errorf("LastUsage.PromptTokens = %d, want 50000 (kept from iteration 1)", state.Think.LastUsage.PromptTokens)
87+
}
88+
}

‎internal/providers/call_usage.go‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,19 @@ func SumCallUsage(calls []CallUsage) Usage {
3232
return total
3333
}
3434

35+
// ContextTokens returns the full prompt-side size of a single LLM call —
36+
// the actual context the model saw — normalizing provider cache accounting.
37+
// Anthropic-style usage reports input_tokens EXCLUDING cached segments, so
38+
// cache read/creation tokens must be added back; OpenAI-style usage
39+
// (PromptTokensIncludeCachedSegments=true) already includes them.
40+
// Only meaningful for per-call usage, not run-cumulative totals.
41+
func (u Usage) ContextTokens() int {
42+
if u.PromptTokensIncludeCachedSegments {
43+
return u.PromptTokens
44+
}
45+
return u.PromptTokens + u.CacheReadTokens + u.CacheCreationTokens
46+
}
47+
3548
// SumCallCost totals the per-call USD cost (0 when pricing was unavailable).
3649
func SumCallCost(calls []CallUsage) float64 {
3750
var total float64
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package providers
2+
3+
import "testing"
4+
5+
// TestUsage_ContextTokens guards the "current context size" computation used by
6+
// the sessions context-usage display and compaction calibration. Anthropic-style
7+
// accounting reports input_tokens EXCLUDING cached segments, so the real context
8+
// of a call is prompt + cache_read + cache_creation. OpenAI-style accounting
9+
// (PromptTokensIncludeCachedSegments=true) already includes cached tokens.
10+
func TestUsage_ContextTokens(t *testing.T) {
11+
t.Parallel()
12+
13+
tests := []struct {
14+
name string
15+
usage Usage
16+
want int
17+
}{
18+
{
19+
name: "anthropic-style excludes cached segments — add them back",
20+
usage: Usage{
21+
PromptTokens: 1000,
22+
CacheReadTokens: 80000,
23+
CacheCreationTokens: 5000,
24+
},
25+
want: 86000,
26+
},
27+
{
28+
name: "openai-style already includes cached segments",
29+
usage: Usage{
30+
PromptTokens: 86000,
31+
CacheReadTokens: 80000,
32+
PromptTokensIncludeCachedSegments: true,
33+
},
34+
want: 86000,
35+
},
36+
{
37+
name: "zero value",
38+
usage: Usage{},
39+
want: 0,
40+
},
41+
}
42+
for _, tt := range tests {
43+
t.Run(tt.name, func(t *testing.T) {
44+
if got := tt.usage.ContextTokens(); got != tt.want {
45+
t.Errorf("ContextTokens() = %d, want %d", got, tt.want)
46+
}
47+
})
48+
}
49+
}

0 commit comments

Comments
 (0)