Skip to content

[Bug] forceCompression breaks tool call/response pairing, causing 400 error #870

@QuietyAwe

Description

@QuietyAwe

Description

The forceCompression function in pkg/agent/loop.go (lines 771-820) aggressively drops the oldest 50% of messages when context limit is hit. However, it does not preserve the pairing between tool calls and tool responses, leading to orphaned tool role messages that cause API 400 errors.

Error Message

Status: 400
Body:   {"error":{"code":"invalid_parameter_error","message":"<400> InternalError.Algo.InvalidParameter: messages with role \"tool\" must be a response to a preceeding message with \"tool_calls\".","param":null,"type":"invalid_request_error"},"request_id":"99d697db-271a-98c4-a6b4-41937d43e224"}, retry=0}

Root Cause

In forceCompression:

conversation := history[1 : len(history)-1]
mid := len(conversation) / 2
keptConversation := conversation[mid:]  // Simply cuts from the middle!

The code cuts messages from the middle without checking if:

  1. A tool role message has a corresponding assistant message with tool_calls before it
  2. The cut point breaks a tool call/response pair

Example Scenario

History before compression:

[0] system: "You are..."
[1] user: "What's the weather?"
[2] assistant: [tool_calls: get_weather]
[3] tool: "Sunny, 25°C"
[4] assistant: "It's sunny..."
[5] user: "Thanks!"

After forceCompression (mid=2, keeps [3], [4]):

[0] system: "... [compression note]"
[1] tool: "Sunny, 25°C"      // ❌ Orphaned! No preceding assistant with tool_calls
[2] assistant: "It's sunny..."
[3] user: "Thanks!"

This results in a tool message without a preceding assistant message containing tool_calls, which violates API requirements.

Affected Providers

  • OpenAI-compatible APIs (including Zhipu/GLM)
  • Anthropic
  • Any provider that enforces strict tool call/response pairing

Suggested Fix

Option 1: Skip tool messages when finding cut point

// Find a safe cut point that doesn't break tool call/response pairs
func findSafeCutPoint(conversation []providers.Message) int {
    mid := len(conversation) / 2
    
    // Walk backwards from mid to find a user message (safe cut point)
    for i := mid; i >= 0; i-- {
        if conversation[i].Role == "user" {
            return i + 1 // Cut after user message
        }
    }
    
    // Walk forwards from mid to find a user message
    for i := mid; i < len(conversation); i++ {
        if conversation[i].Role == "user" {
            return i + 1
        }
    }
    
    return mid // Fallback (may still cause issues)
}

Option 2: Drop tool call/response pairs together
When dropping messages, ensure that if an assistant message with tool_calls is dropped, all corresponding tool responses are also dropped.

Option 3: Remove orphaned tool messages after compression
Add a sanitization step after compression to remove tool messages without preceding assistant[tool_calls].

Environment

  • PicoClaw version: latest
  • Provider: Zhipu GLM (also affects other OpenAI-compatible providers)

Related Issues

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions