feat: add 3-tier context overflow recovery with tool result truncation

Replace single-strategy context overflow handling with a tiered cascade:
- Tier 1: Truncate oversized tool results (>8000 chars) with newline-aware
  boundary detection. Cheapest recovery — no messages dropped.
- Tier 2: Drop oldest 50% of messages (existing forceCompression).
- Tier 3: Both truncation + compression for maximum space reclamation.

Each retry escalates to the next tier, giving the LLM progressively more
aggressive context reduction before giving up.
This commit is contained in:
Leandro Barbosa 2026-02-19 13:26:00 -03:00
parent 94675e024a
commit d83280739c

View file

@ -671,10 +671,12 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
strings.Contains(errMsg, "length")
if isContextError && retry < maxRetries {
logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]interface{}{
"error": err.Error(),
"retry": retry,
})
logger.WarnCF("agent", "Context overflow recovery",
map[string]interface{}{
"error": err.Error(),
"retry": retry,
"tier": retry + 1,
})
if retry == 0 && !constants.IsInternalChannel(opts.Channel) {
al.bus.PublishOutbound(bus.OutboundMessage{
@ -684,7 +686,19 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
})
}
al.forceCompression(agent, opts.SessionKey)
// 3-tier context overflow recovery:
// Tier 1: Truncate oversized tool results (cheapest, no LLM call)
// Tier 2: Drop oldest 50% of messages
// Tier 3: Both truncation + compression (most aggressive)
switch retry {
case 0:
al.truncateToolResults(agent, opts.SessionKey)
case 1:
al.forceCompression(agent, opts.SessionKey)
default:
al.truncateToolResults(agent, opts.SessionKey)
al.forceCompression(agent, opts.SessionKey)
}
newHistory := agent.Sessions.GetHistory(opts.SessionKey)
newSummary := agent.Sessions.GetSummary(opts.SessionKey)
messages = agent.ContextBuilder.BuildMessages(
@ -895,6 +909,51 @@ func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, c
}
}
// maxToolResultChars is the maximum allowed length for a single tool result message.
// Results exceeding this are truncated during context overflow recovery (Tier 1).
// 8000 chars ≈ ~2000 tokens — large enough for useful output, small enough to reclaim space.
const maxToolResultChars = 8000
// truncateToolResults walks the message history and truncates oversized tool
// result messages in-place. This is the cheapest recovery tier — no messages
// are dropped, only oversized Content fields are shortened.
func (al *AgentLoop) truncateToolResults(agent *AgentInstance, sessionKey string) {
history := agent.Sessions.GetHistory(sessionKey)
truncated := 0
for i := range history {
if history[i].Role != "tool" {
continue
}
if utf8.RuneCountInString(history[i].Content) <= maxToolResultChars {
continue
}
// Truncate at a newline boundary if possible, otherwise at the limit.
runes := []rune(history[i].Content)
cutPoint := maxToolResultChars
// Search backward from cut point for a newline (up to 200 chars back).
for j := cutPoint; j > cutPoint-200 && j > 0; j-- {
if runes[j] == '\n' {
cutPoint = j
break
}
}
history[i].Content = string(runes[:cutPoint]) +
"\n\n[... content truncated — original too large for context window]"
truncated++
}
if truncated > 0 {
agent.Sessions.SetHistory(sessionKey, history)
logger.WarnCF("agent", "Tool results truncated", map[string]interface{}{
"session_key": sessionKey,
"truncated": truncated,
"max_chars": maxToolResultChars,
})
}
}
// forceCompression aggressively reduces context when the limit is hit.
// It drops the oldest 50% of messages (keeping system prompt and last user message).
func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {