Skip to content

Commit 644680c

Browse files
committed
Prevent infinite compaction loop on repeated ContextOverflowError
Add a retry counter (maxOverflowCompactions=1) to the auto-compaction path in the runtime loop. When every model call returns a ContextOverflowError, compaction is now attempted at most once before the error is surfaced to the user. The counter resets after each successful model call so future overflows can still trigger compaction. Add TestCompactionOverflowDoesNotLoop to verify the guard. Assisted-By: docker-agent
1 parent 13de815 commit 644680c

2 files changed

Lines changed: 66 additions & 1 deletion

File tree

pkg/runtime/loop.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,13 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c
128128
}
129129
loopDetector := newToolLoopDetector(loopThreshold)
130130

131+
// overflowCompactions counts how many consecutive context-overflow
132+
// auto-compactions have been attempted without a successful model
133+
// call in between. This prevents an infinite loop when compaction
134+
// cannot reduce the context below the model's limit.
135+
const maxOverflowCompactions = 1
136+
var overflowCompactions int
137+
131138
// toolModelOverride holds the per-toolset model from the most recent
132139
// tool calls. It applies for one LLM turn, then resets.
133140
var toolModelOverride string
@@ -281,13 +288,18 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c
281288
// Auto-recovery: if the error is a context overflow and
282289
// session compaction is enabled, compact the conversation
283290
// and retry the request instead of surfacing raw errors.
284-
if _, ok := errors.AsType[*modelerrors.ContextOverflowError](err); ok && r.sessionCompaction {
291+
// We allow at most maxOverflowCompactions consecutive attempts
292+
// to avoid an infinite loop when compaction cannot reduce
293+
// the context enough.
294+
if _, ok := errors.AsType[*modelerrors.ContextOverflowError](err); ok && r.sessionCompaction && overflowCompactions < maxOverflowCompactions {
295+
overflowCompactions++
285296
slog.Warn("Context window overflow detected, attempting auto-compaction",
286297
"agent", a.Name(),
287298
"session_id", sess.ID,
288299
"input_tokens", sess.InputTokens,
289300
"output_tokens", sess.OutputTokens,
290301
"context_limit", contextLimit,
302+
"attempt", overflowCompactions,
291303
)
292304
events <- Warning(
293305
"The conversation has exceeded the model's context window. Automatically compacting the conversation history...",
@@ -314,6 +326,9 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c
314326
return
315327
}
316328

329+
// A successful model call resets the overflow compaction counter.
330+
overflowCompactions = 0
331+
317332
if usedModel != nil && usedModel.ID() != model.ID() {
318333
slog.Info("Used fallback model", "agent", a.Name(), "primary", model.ID(), "used", usedModel.ID())
319334
events <- AgentInfo(a.Name(), usedModel.ID(), a.Description(), a.WelcomeMessage())

pkg/runtime/runtime_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/docker/docker-agent/pkg/chat"
1717
"github.com/docker/docker-agent/pkg/config/latest"
1818
"github.com/docker/docker-agent/pkg/model/provider/base"
19+
"github.com/docker/docker-agent/pkg/modelerrors"
1920
"github.com/docker/docker-agent/pkg/modelsdev"
2021
"github.com/docker/docker-agent/pkg/permissions"
2122
"github.com/docker/docker-agent/pkg/session"
@@ -631,6 +632,55 @@ func TestCompaction(t *testing.T) {
631632
require.NotEqual(t, -1, compactionStartIdx, "expected a SessionCompaction start event")
632633
}
633634

635+
// errorProvider always returns the configured error from CreateChatCompletionStream.
636+
type errorProvider struct {
637+
id string
638+
err error
639+
}
640+
641+
func (p *errorProvider) ID() string { return p.id }
642+
643+
func (p *errorProvider) CreateChatCompletionStream(context.Context, []chat.Message, []tools.Tool) (chat.MessageStream, error) {
644+
return nil, p.err
645+
}
646+
647+
func (p *errorProvider) BaseConfig() base.Config { return base.Config{} }
648+
649+
func (p *errorProvider) MaxTokens() int { return 0 }
650+
651+
func TestCompactionOverflowDoesNotLoop(t *testing.T) {
652+
// The model always returns a ContextOverflowError. Without the
653+
// max-retry guard this would loop forever because compaction
654+
// cannot fix the problem.
655+
overflowErr := modelerrors.NewContextOverflowError(errors.New("prompt is too long"))
656+
prov := &errorProvider{id: "test/overflow-model", err: overflowErr}
657+
658+
root := agent.New("root", "You are a test agent", agent.WithModel(prov))
659+
tm := team.New(team.WithAgents(root))
660+
661+
rt, err := NewLocalRuntime(tm, WithSessionCompaction(true), WithModelStore(mockModelStoreWithLimit{limit: 100}))
662+
require.NoError(t, err)
663+
664+
sess := session.New(session.WithUserMessage("Hello"))
665+
events := rt.RunStream(t.Context(), sess)
666+
667+
var compactionCount int
668+
var sawError bool
669+
for ev := range events {
670+
if e, ok := ev.(*SessionCompactionEvent); ok && e.Status == "started" {
671+
compactionCount++
672+
}
673+
if _, ok := ev.(*ErrorEvent); ok {
674+
sawError = true
675+
}
676+
}
677+
678+
// Compaction should have been attempted at most once, then the loop
679+
// must give up and surface an error instead of retrying indefinitely.
680+
require.LessOrEqual(t, compactionCount, 1, "expected at most 1 compaction attempt, got %d", compactionCount)
681+
require.True(t, sawError, "expected an ErrorEvent after exhausting compaction retries")
682+
}
683+
634684
func TestSessionWithoutUserMessage(t *testing.T) {
635685
stream := newStreamBuilder().AddContent("OK").AddStopWithUsage(1, 1).Build()
636686

0 commit comments

Comments
 (0)