fix(agent): forceCompression recovers from single oversized Turn

When the entire session history is a single Turn (e.g. one user message
followed by a massive tool response), findSafeBoundary returns 0 and
forceCompression previously did nothing — leaving the agent stuck in
a context-exceeded retry loop.

Now falls back to keeping only the most recent user message when no
safe Turn boundary exists. This breaks Turn atomicity as a last resort
but guarantees the agent can recover.

Also updates docs/agent-refactor/context.md to document this behavior.

Ref #1490
This commit is contained in:
xiaoen 2026-03-17 10:23:16 +08:00
parent 08259d7e9a
commit c63c6449b4
2 changed files with 22 additions and 4 deletions

View file

@ -103,7 +103,9 @@ This prevents wasted (and billed) LLM calls that would otherwise fail with a con
`forceCompression` runs when the LLM returns a context-window error despite the proactive check. `forceCompression` runs when the LLM returns a context-window error despite the proactive check.
Drops the oldest ~50% of Turns. Stores a compression note in the session summary (not in history messages) so `BuildMessages` can include it in the next system prompt. Drops the oldest ~50% of Turns. If the history is a single Turn with no safe split point (e.g. one user message followed by a massive tool response), falls back to keeping only the most recent user message — breaking Turn atomicity as a last resort to avoid a context-exceeded loop.
Stores a compression note in the session summary (not in history messages) so `BuildMessages` can include it in the next system prompt.
This is the fallback for when the token estimate undershoots reality. This is the fallback for when the token estimate undershoots reality.

View file

@ -1559,6 +1559,10 @@ func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, c
// It drops the oldest ~50% of Turns (a Turn is a complete user→LLM→response // It drops the oldest ~50% of Turns (a Turn is a complete user→LLM→response
// cycle, as defined in #1316), so tool-call sequences are never split. // cycle, as defined in #1316), so tool-call sequences are never split.
// //
// If the history is a single Turn with no safe split point, the function
// falls back to keeping only the most recent user message. This breaks
// Turn atomicity as a last resort to avoid a context-exceeded loop.
//
// Session history contains only user/assistant/tool messages — the system // Session history contains only user/assistant/tool messages — the system
// prompt is built dynamically by BuildMessages and is NOT stored here. // prompt is built dynamically by BuildMessages and is NOT stored here.
// The compression note is recorded in the session summary so that // The compression note is recorded in the session summary so that
@ -1581,12 +1585,24 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
// aligned to the nearest Turn boundary. // aligned to the nearest Turn boundary.
mid = findSafeBoundary(history, len(history)/2) mid = findSafeBoundary(history, len(history)/2)
} }
var keptHistory []providers.Message
if mid <= 0 { if mid <= 0 {
return // No safe Turn boundary — the entire history is a single Turn
// (e.g. one user message followed by a massive tool response).
// Keeping everything would leave the agent stuck in a context-
// exceeded loop, so fall back to keeping only the most recent
// user message. This breaks Turn atomicity as a last resort.
for i := len(history) - 1; i >= 0; i-- {
if history[i].Role == "user" {
keptHistory = []providers.Message{history[i]}
break
}
}
} else {
keptHistory = history[mid:]
} }
droppedCount := mid droppedCount := len(history) - len(keptHistory)
keptHistory := history[mid:]
// Record compression in the session summary so BuildMessages includes it // Record compression in the session summary so BuildMessages includes it
// in the system prompt. We do not modify history messages themselves. // in the system prompt. We do not modify history messages themselves.