Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 47 additions & 21 deletions pkg/runtime/loop_steps.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,27 +156,53 @@ func (r *LocalRuntime) handleStreamError(
return streamErrorFatal
}

// Auto-recovery: if the error is a context overflow and session
// compaction is enabled, compact the conversation and retry the
// request instead of surfacing raw errors. We allow at most
// r.maxOverflowCompactions consecutive attempts to avoid an infinite
// loop when compaction cannot reduce the context enough.
if _, ok := errors.AsType[*modelerrors.ContextOverflowError](err); ok && r.sessionCompaction && *overflowCompactions < r.maxOverflowCompactions {
*overflowCompactions++
slog.WarnContext(ctx, "Context window overflow detected, attempting auto-compaction",
"agent", a.Name(),
"session_id", sess.ID,
"input_tokens", sess.InputTokens,
"output_tokens", sess.OutputTokens,
"context_limit", contextLimit,
"attempt", *overflowCompactions,
)
events.Emit(Warning(
"The conversation has exceeded the model's context window. Automatically compacting the conversation history...",
a.Name(),
))
r.compactWithReason(ctx, sess, "", compactionReasonOverflow, events)
return streamErrorRetry
// Overflow handling has two independent concerns:
//
// 1. Auto-compaction (token overflow only): summarise older
// turns to fit the context window, then retry. Gated by
// r.sessionCompaction and the per-run attempt cap.
//
// 2. Session hygiene (wire/media overflow): rewrite the
// offending user message so the same oversized payload
// cannot reload on the next call and re-poison the session.
// Always runs when the kind warrants it, independent of
// the compaction config — the hygiene step does not retry
// and is correct even when compaction is disabled.
if _, ok := errors.AsType[*modelerrors.ContextOverflowError](err); ok {
kind := modelerrors.OverflowKindOf(err)

// Token overflow: compaction is the right recovery — older
// turns can be summarised to free up context. Wire/media do
// not benefit from compaction (the latest turn alone is
// over the cap; resending it during compaction would just
// fail again), so we fall through to the hygiene step
// below for those.
if kind == modelerrors.OverflowKindTokens && r.sessionCompaction && *overflowCompactions < r.maxOverflowCompactions {
*overflowCompactions++
slog.WarnContext(ctx, "Context window overflow detected, attempting auto-compaction",
"agent", a.Name(),
"session_id", sess.ID,
"input_tokens", sess.InputTokens,
"output_tokens", sess.OutputTokens,
"context_limit", contextLimit,
"attempt", *overflowCompactions,
)
events.Emit(Warning(
"The conversation has exceeded the model's context window. Automatically compacting the conversation history...",
a.Name(),
))
r.compactWithReason(ctx, sess, "", compactionReasonOverflow, events)
return streamErrorRetry
}

// Hygiene scrub for wire/media overflow. Runs independently
// of r.sessionCompaction: this rewrites a single message in
// place, it does not retry, and the same-process
// session-poisoning bug it fixes occurs regardless of
// whether the user opted into auto-compaction.
if kind == modelerrors.OverflowKindWire || kind == modelerrors.OverflowKindMedia {
r.recoverFromOversizedTurn(ctx, sess, kind, events)
}
}

streamSpan.RecordError(err)
Expand Down
72 changes: 72 additions & 0 deletions pkg/runtime/loop_steps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,3 +292,75 @@ func TestHandleStreamError_GenericError_FatalAndEmitsError(t *testing.T) {
}
assert.True(t, sawError, "generic error should emit ErrorEvent")
}

// TestHandleStreamError_WireOverflowSkipsCompaction verifies that wire-level
// overflow does not trigger auto-compaction. Compaction would resend the same
// oversized request that just got rejected, so it is guaranteed to fail; we
// surface the error directly instead.
func TestHandleStreamError_WireOverflowSkipsCompaction(t *testing.T) {
t.Parallel()

rt, a := newTestRuntime(t)
sess := session.New()
events := make(chan Event, 16)
_, sp := noop.NewTracerProvider().Tracer("t").Start(t.Context(), "x")

overflow := &modelerrors.ContextOverflowError{
Underlying: errors.New("HTTP 413: Payload Too Large"),
Kind: modelerrors.OverflowKindWire,
}
overflowCount := 0

outcome := rt.handleStreamError(t.Context(), sess, a, overflow, 1000, &overflowCount, sp, NewChannelSink(events))

assert.Equal(t, streamErrorFatal, outcome, "wire overflow must not trigger compaction retry")
assert.Equal(t, 0, overflowCount, "wire overflow must not bump the compaction counter")

got := drainEvents(events)
var sawError bool
var sawWarning bool
var errCode string
for _, ev := range got {
switch e := ev.(type) {
case *ErrorEvent:
sawError = true
errCode = e.Code
case *WarningEvent:
sawWarning = true
}
}
assert.True(t, sawError, "wire overflow should emit an ErrorEvent")
assert.False(t, sawWarning, "wire overflow should not emit the compaction warning")
assert.Equal(t, ErrorCodeRequestTooLarge, errCode, "ErrorEvent.Code should distinguish wire overflow")
}

// TestHandleStreamError_MediaOverflowSkipsCompaction verifies the same skip
// behaviour for media-size rejections. Without media-stripping during
// compaction, the offending attachment would be resent and fail again.
func TestHandleStreamError_MediaOverflowSkipsCompaction(t *testing.T) {
t.Parallel()

rt, a := newTestRuntime(t)
sess := session.New()
events := make(chan Event, 16)
_, sp := noop.NewTracerProvider().Tracer("t").Start(t.Context(), "x")

overflow := &modelerrors.ContextOverflowError{
Underlying: errors.New("image exceeds 5 MB maximum"),
Kind: modelerrors.OverflowKindMedia,
}
overflowCount := 0

outcome := rt.handleStreamError(t.Context(), sess, a, overflow, 1000, &overflowCount, sp, NewChannelSink(events))

assert.Equal(t, streamErrorFatal, outcome, "media overflow must not trigger compaction retry")
assert.Equal(t, 0, overflowCount, "media overflow must not bump the compaction counter")

var errCode string
for _, ev := range drainEvents(events) {
if e, ok := ev.(*ErrorEvent); ok {
errCode = e.Code
}
}
assert.Equal(t, ErrorCodeMediaTooLarge, errCode, "ErrorEvent.Code should distinguish media overflow")
}
Loading
Loading