-
Notifications
You must be signed in to change notification settings - Fork 266
Description
Summary
trimMessages() in pkg/session/session.go removes the oldest conversation messages first without preserving user messages. In single-turn agentic loops where one user message is followed by many tool call / tool result pairs, the user's original request is the first message trimmed away. The model then sees only assistant/tool messages with no user request, loses context, and stops the agentic loop by emitting a generic greeting instead of continuing the task.
Impact
Sessions that involve intensive tool use (reading files, running shell commands, fetching URLs) are silently derailed after ~15-20 tool invocations. The agent stops working on the task and greets the user as if no request was ever made. This wastes significant compute (tokens/cost) and requires the user to start over.
Root Cause
The issue is in trimMessages():
func trimMessages(messages []chat.Message, maxItems int) []chat.Message {
// ...
toRemove := len(conversationMessages) - maxItems
// Start from the beginning (oldest messages)
for i := range toRemove {
if conversationMessages[i].Role == chat.MessageRoleAssistant {
for _, toolCall := range conversationMessages[i].ToolCalls {
toolCallsToRemove[toolCall.ID] = true
}
}
}
// ...
}The function removes the oldest N conversation messages to fit within maxItems. In a typical agentic session the conversation structure is:
[user] ← position 0: the original request
[assistant] ← position 1: first tool call
[tool] ← position 2: tool result
[assistant] ← position 3: next tool call
[tool] ← position 4: tool result
... ← 50+ more assistant/tool pairs
When num_history_items: 30 is configured and the session accumulates 62 conversation messages, trimMessages computes toRemove = 62 - 30 = 32 and discards positions 0-31 — including the user message at position 0. Additionally, orphaned tool results from trimmed assistant messages are cascaded away.
The model then receives ~30 conversation messages that are all assistant/tool pairs with no user request visible. It concludes nothing was asked and emits a greeting, ending the agentic loop.
How to Reproduce
-
Create a
cagent.yamlwith an agent configured as:root: model: premium max_iterations: 200 num_history_items: 30 toolsets: - type: shell - type: filesystem - type: fetch - type: think - type: memory path: data/memory.db
-
Send a single user message that triggers intensive tool use, e.g.:
"Analyze merge request https://gitlab.com/myproject/-/merge_requests/123 and build an integration plan for the current codebase"
-
The agent will call 15-20+ tools (fetch MR, read files, run git commands, think, etc.).
-
Once the session exceeds 30 conversation messages,
trimMessagesdrops the user message. -
Observed: The agent outputs a greeting like "Hello! 👋 What would you like to work on?" and stops (
tool_calls=0, stopped=true). -
Expected: The agent should continue working on the original task.
Debug log signature
The issue can be identified in debug logs by the pattern:
Retrieved messages for agent agent=root total_messages=43 system_messages=13 conversation_messages=30 max_history_items=30
...
Stream processed agent=root tool_calls=0 content_length=448 stopped=true
Conversation stopped agent=root
The conversation_messages=30 (post-trim count) with max_history_items=30 means trimming is active. When this is followed by tool_calls=0, stopped=true, the model has lost context.
Verified Reproduction
This was reproduced across 4 independent sessions with identical behavior:
| Session | Total Items | Derailed At | Final Output |
|---|---|---|---|
67fbc6af |
105 | ~position 100 | "I notice you haven't actually asked me anything yet" |
121b3f94 |
47 | position 44 | "Hello! 👋 I'm ready to help..." |
16f92158 |
44 | position 43 | "Hello! 👋 I'm ready to help..." |
928109c9 |
62 | position 61 | "Hello! 👋 I'm ready to help..." |
Suggested Fix
Modify trimMessages() to always preserve user messages in the conversation, regardless of the trim budget. A minimal fix would ensure the first user message is never removed:
func trimMessages(messages []chat.Message, maxItems int) []chat.Message {
var systemMessages []chat.Message
var conversationMessages []chat.Message
for i := range messages {
if messages[i].Role == chat.MessageRoleSystem {
systemMessages = append(systemMessages, messages[i])
} else {
conversationMessages = append(conversationMessages, messages[i])
}
}
if len(conversationMessages) <= maxItems {
return messages
}
// Find the first user message — it must always be preserved
firstUserIdx := -1
for i, msg := range conversationMessages {
if msg.Role == chat.MessageRoleUser {
firstUserIdx = i
break
}
}
toolCallsToRemove := make(map[string]bool)
toRemove := len(conversationMessages) - maxItems
removed := 0
for i := 0; i < len(conversationMessages) && removed < toRemove; i++ {
if i == firstUserIdx {
continue // never trim the first user message
}
if conversationMessages[i].Role == chat.MessageRoleAssistant {
for _, toolCall := range conversationMessages[i].ToolCalls {
toolCallsToRemove[toolCall.ID] = true
}
}
removed++
}
result := make([]chat.Message, 0, len(systemMessages)+maxItems+1)
result = append(result, systemMessages...)
removedSoFar := 0
for i, msg := range conversationMessages {
// Always keep the first user message
if i == firstUserIdx {
result = append(result, msg)
continue
}
// Skip orphaned tool results
if msg.Role == chat.MessageRoleTool && toolCallsToRemove[msg.ToolCallID] {
continue
}
// Skip messages in the trim window (but not the protected user message)
if removedSoFar < toRemove && i < firstUserIdx {
removedSoFar++
continue
}
if removedSoFar < toRemove && i > firstUserIdx && i <= toRemove {
removedSoFar++
continue
}
result = append(result, msg)
}
return result
}An alternative or complementary approach: when trimming would remove all user messages, inject a synthetic summary like "[Previous user request]: <original content>" as a user message to ensure the model always has task context.