Skip to content

Commit b2147d0

Browse files
authored
Merge pull request #3659 from docker/fix/3591-session-scalar-locking
fix(session): lock title and usage scalar access
2 parents 652f0b1 + ac18222 commit b2147d0

23 files changed

Lines changed: 538 additions & 76 deletions

pkg/a2a/adapter.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ func runDockerAgent(ctx agent.InvocationContext, t *team.Team, agentName string,
9191
session.WithNonInteractive(true),
9292
session.WithWorkingDir(workingDir),
9393
)
94-
sess.Title = "A2A Session " + sessionID
94+
sess.SetTitle("A2A Session " + sessionID)
9595
}
9696

9797
// Create runtime

pkg/acp/agent.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ func (a *Agent) NewSession(ctx context.Context, params acp.NewSessionRequest) (a
214214
session.WithMaxToolResultTokens(defaultAgent.MaxToolResultTokens()),
215215
session.WithWorkingDir(workingDir),
216216
)
217-
sess.Title = "ACP Session " + sess.ID
217+
sess.SetTitle("ACP Session " + sess.ID)
218218

219219
if err := a.sessionStore.AddSession(ctx, sess); err != nil {
220220
return acp.NewSessionResponse{}, fmt.Errorf("failed to persist session: %w", err)

pkg/app/app.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -484,7 +484,7 @@ func (a *App) Run(ctx context.Context, cancel context.CancelFunc, message string
484484
a.cancel = cancel
485485

486486
// If this is the first message and no title exists, start local title generation
487-
if a.session.Title == "" && a.titleGen != nil {
487+
if a.session.TitleSnapshot() == "" && a.titleGen != nil {
488488
a.titleGenerating.Store(true)
489489
go a.generateTitle(ctx, []string{message})
490490
}
@@ -764,7 +764,7 @@ func (a *App) RunWithMessage(ctx context.Context, cancel context.CancelFunc, msg
764764
a.cancel = cancel
765765

766766
// If this is the first message and no title exists, start local title generation
767-
if a.session.Title == "" && a.titleGen != nil {
767+
if a.session.TitleSnapshot() == "" && a.titleGen != nil {
768768
a.titleGenerating.Store(true)
769769
// Extract text content from the message for title generation
770770
userMessage := msg.Message.Content

pkg/cli/runner.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ func Run(ctx context.Context, out *Printer, cfg Config, rt runtime.Runtime, sess
9090
ctx = telemetry.WithClient(ctx, telemetryClient)
9191
}
9292

93-
sess.Title = "Running agent"
93+
sess.SetTitle("Running agent")
9494
// If the last received event was an error, return it. That way the exit code
9595
// will be non-zero if the agent failed.
9696
var lastErr error

pkg/evaluation/eval.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -360,7 +360,7 @@ func (r *Runner) runSingleEval(ctx context.Context, evalSess *InputSession) (Res
360360

361361
// Re-apply the display title in case a session_title event overrode it.
362362
// This ensures repeated evals retain their '#N' suffix in stored sessions.
363-
result.Session.Title = title
363+
result.Session.SetTitle(title)
364364

365365
if len(expectedToolCalls) > 0 || len(actualToolCalls) > 0 {
366366
result.ToolCallsScore = toolCallF1Score(expectedToolCalls, actualToolCalls)

pkg/evaluation/save.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -211,15 +211,20 @@ func SessionFromEvents(events []map[string]any, title string, questions []string
211211
case "token_usage":
212212
// Update session token usage
213213
if usage, ok := event["usage"].(map[string]any); ok {
214+
// Route the write through the locked setter, keeping the
215+
// previous value for any field absent from the event. sess is
216+
// local to this import, so the pre-read cannot race.
217+
sessInput, sessOutput, sessCost := sess.TokensAndCost()
214218
if inputTokens, ok := usage["input_tokens"].(float64); ok {
215-
sess.InputTokens = int64(inputTokens)
219+
sessInput = int64(inputTokens)
216220
}
217221
if outputTokens, ok := usage["output_tokens"].(float64); ok {
218-
sess.OutputTokens = int64(outputTokens)
222+
sessOutput = int64(outputTokens)
219223
}
220224
if cost, ok := usage["cost"].(float64); ok {
221-
sess.Cost = cost
225+
sessCost = cost
222226
}
227+
sess.SetTokensAndCost(sessInput, sessOutput, sessCost)
223228
// Extract per-message usage if available
224229
if lastMsg, ok := usage["last_message"].(map[string]any); ok {
225230
currentUsage = parseMessageUsage(lastMsg)
@@ -251,7 +256,7 @@ func SessionFromEvents(events []map[string]any, title string, questions []string
251256
case "session_title":
252257
// Update session title if provided (may override the one from eval config)
253258
if eventTitle, ok := event["title"].(string); ok && eventTitle != "" {
254-
sess.Title = eventTitle
259+
sess.SetTitle(eventTitle)
255260
}
256261

257262
case "stream_stopped":

pkg/evaluation/save_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,71 @@ func TestSessionFromEventsTokenUsage(t *testing.T) {
468468
assert.InDelta(t, 0.005, sess.Cost, 0.0001)
469469
}
470470

471+
// TestSessionFromEventsPartialTokenUsage pins the merge semantics of
472+
// consecutive token_usage events: a later event that omits some usage
473+
// fields must preserve the previously recorded values for the omitted
474+
// fields instead of resetting them to zero.
475+
func TestSessionFromEventsPartialTokenUsage(t *testing.T) {
476+
t.Parallel()
477+
478+
tests := []struct {
479+
name string
480+
second map[string]any
481+
wantInput int64
482+
wantOutput int64
483+
wantCost float64
484+
}{
485+
{
486+
name: "second event omits input_tokens and cost",
487+
second: map[string]any{"output_tokens": float64(80)},
488+
wantInput: 100,
489+
wantOutput: 80,
490+
wantCost: 0.005,
491+
},
492+
{
493+
name: "second event omits output_tokens",
494+
second: map[string]any{"input_tokens": float64(200), "cost": 0.01},
495+
wantInput: 200,
496+
wantOutput: 50,
497+
wantCost: 0.01,
498+
},
499+
{
500+
name: "second event omits every field",
501+
second: map[string]any{},
502+
wantInput: 100,
503+
wantOutput: 50,
504+
wantCost: 0.005,
505+
},
506+
}
507+
508+
for _, tt := range tests {
509+
t.Run(tt.name, func(t *testing.T) {
510+
t.Parallel()
511+
512+
events := []map[string]any{
513+
{"type": "agent_choice", "content": "Answer"},
514+
{
515+
"type": "token_usage",
516+
"usage": map[string]any{
517+
"input_tokens": float64(100),
518+
"output_tokens": float64(50),
519+
"cost": 0.005,
520+
},
521+
},
522+
{"type": "token_usage", "usage": tt.second},
523+
{"type": "stream_stopped"},
524+
}
525+
526+
sess := SessionFromEvents(events, "test", []string{"question"})
527+
528+
gotInput, gotOutput, gotCost := sess.TokensAndCost()
529+
assert.Equal(t, tt.wantInput, gotInput)
530+
assert.Equal(t, tt.wantOutput, gotOutput)
531+
assert.InDelta(t, tt.wantCost, gotCost, 0.0001)
532+
})
533+
}
534+
}
535+
471536
func TestParseToolCall(t *testing.T) {
472537
t.Parallel()
473538

pkg/runtime/event.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -404,10 +404,13 @@ func (e *TokenUsageEvent) GetSessionID() string { return e.SessionID }
404404
// compactionThreshold is the agent's configured auto-compaction trigger
405405
// fraction (0 or omitted when unknown), passed along verbatim for UI gauges.
406406
func SessionUsage(sess *session.Session, contextLimit int64, compactionThreshold ...float64) *Usage {
407+
// Usage() snapshots both counters under sess.mu so a concurrent
408+
// SetUsage/ApplyCompaction cannot tear the pair.
409+
input, output := sess.Usage()
407410
u := &Usage{
408-
InputTokens: sess.InputTokens,
409-
OutputTokens: sess.OutputTokens,
410-
ContextLength: sess.InputTokens + sess.OutputTokens,
411+
InputTokens: input,
412+
OutputTokens: output,
413+
ContextLength: input + output,
411414
ContextLimit: contextLimit,
412415
Cost: sess.OwnCost(),
413416
}

pkg/runtime/hooks.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -502,10 +502,11 @@ func (r *LocalRuntime) executeBeforeCompactionHooks(
502502
contextLimit int64,
503503
events EventSink,
504504
) *hooks.Result {
505+
inputTokens, outputTokens := sess.Usage()
505506
return r.dispatchHook(ctx, a, hooks.EventBeforeCompaction, &hooks.Input{
506507
SessionID: sess.ID,
507-
InputTokens: sess.InputTokens,
508-
OutputTokens: sess.OutputTokens,
508+
InputTokens: inputTokens,
509+
OutputTokens: outputTokens,
509510
ContextLimit: contextLimit,
510511
CompactionReason: reason,
511512
}, events)

pkg/runtime/loop.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -525,7 +525,8 @@ func (r *LocalRuntime) runStreamLoop(ctx context.Context, sess *session.Session,
525525
// trigger and the UI context gauge (issue #3241); it equals the primary
526526
// window unless a smaller compaction model is configured.
527527
contextLimit := r.effectiveContextLimit(ctx, a, r.resolveContextLimit(ctx, model, modelID))
528-
if contextLimit > 0 && r.sessionCompactionEnabled(a) && compaction.ShouldCompact(sess.InputTokens, sess.OutputTokens, 0, contextLimit, a.CompactionThreshold()) {
528+
inputTokens, outputTokens := sess.Usage()
529+
if contextLimit > 0 && r.sessionCompactionEnabled(a) && compaction.ShouldCompact(inputTokens, outputTokens, 0, contextLimit, a.CompactionThreshold()) {
529530
r.compactWithReason(ctx, sess, "", compactionReasonThreshold, sink)
530531
}
531532

@@ -1161,17 +1162,18 @@ func (r *LocalRuntime) compactIfNeeded(
11611162
addedTokens += estimator.EstimateMessageTokens(&newMessages[i].Message)
11621163
}
11631164

1164-
if !compaction.ShouldCompact(sess.InputTokens, sess.OutputTokens, addedTokens, contextLimit, a.CompactionThreshold()) {
1165+
inputTokens, outputTokens := sess.Usage()
1166+
if !compaction.ShouldCompact(inputTokens, outputTokens, addedTokens, contextLimit, a.CompactionThreshold()) {
11651167
return
11661168
}
11671169

11681170
slog.InfoContext(ctx, "Proactive compaction: tool results pushed estimated context past the compaction threshold",
11691171
"agent", a.Name(),
1170-
"input_tokens", sess.InputTokens,
1171-
"output_tokens", sess.OutputTokens,
1172+
"input_tokens", inputTokens,
1173+
"output_tokens", outputTokens,
11721174
"added_estimated_tokens", addedTokens,
11731175
"estimator_scale", estimator.Scale(),
1174-
"estimated_total", sess.InputTokens+sess.OutputTokens+addedTokens,
1176+
"estimated_total", inputTokens+outputTokens+addedTokens,
11751177
"context_limit", contextLimit,
11761178
)
11771179
r.compactWithReason(ctx, sess, "", compactionReasonThreshold, events)

0 commit comments

Comments
 (0)