fix: make forceCompression snap cut point to tool pair boundaries

The midpoint calculation now walks forward past tool_result messages
to avoid splitting an assistant+tool_calls from its tool_results.
This commit is contained in:
Rahul Bansal 2026-02-21 11:35:10 +05:30
parent 3d1d480975
commit 04d047fd37
2 changed files with 82 additions and 0 deletions

View file

@ -811,6 +811,16 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
// Helper to find the mid-point of the conversation // Helper to find the mid-point of the conversation
mid := len(conversation) / 2 mid := len(conversation) / 2
// Snap the cut point forward past any tool_result messages so we don't
// separate them from their assistant+tool_calls header.
for mid < len(conversation) && conversation[mid].Role == "tool" {
mid++
}
// Safety: if we walked past the end, step back to keep at least 1 message
if mid >= len(conversation) {
mid = len(conversation) - 1
}
// New history structure: // New history structure:
// 1. System Prompt (with compression note appended) // 1. System Prompt (with compression note appended)
// 2. Second half of conversation // 2. Second half of conversation

View file

@ -858,6 +858,78 @@ func TestHandleCommand_New(t *testing.T) {
} }
} }
func TestForceCompression_PreservesToolPairs(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
provider := &mockProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
agent := al.registry.GetDefaultAgent()
if agent == nil {
t.Fatal("No default agent found")
}
sessionKey := "agent:main:test-compression"
agent.Sessions.GetOrCreate(sessionKey)
// Build: system, user, assistant+tool, tool_result, tool_result, user, assistant, user
// = 8 messages. Conversation (indices 1-6) has 6 msgs, mid=3 lands on
// the second tool_result, splitting it from its assistant+tool_calls.
agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "system", Content: "sys"})
agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "user", Content: "q1"})
agent.Sessions.AddFullMessage(sessionKey, providers.Message{
Role: "assistant", Content: "a1",
ToolCalls: []providers.ToolCall{{ID: "c1", Name: "exec"}, {ID: "c2", Name: "read"}},
})
agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "tool", Content: "r1", ToolCallID: "c1"})
agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "tool", Content: "r2", ToolCallID: "c2"})
agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "user", Content: "q2"})
agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "assistant", Content: "a2"})
agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "user", Content: "q3"})
al.forceCompression(agent, sessionKey)
history := agent.Sessions.GetHistory(sessionKey)
toolCallIDs := map[string]bool{}
toolResultIDs := map[string]bool{}
for _, m := range history {
for _, tc := range m.ToolCalls {
if tc.ID != "" {
toolCallIDs[tc.ID] = true
}
}
if m.Role == "tool" && m.ToolCallID != "" {
toolResultIDs[m.ToolCallID] = true
}
}
for id := range toolResultIDs {
if !toolCallIDs[id] {
t.Errorf("orphaned tool_result %q after forceCompression", id)
}
}
for id := range toolCallIDs {
if !toolResultIDs[id] {
t.Errorf("orphaned tool_call %q after forceCompression", id)
}
}
}
func TestHandleCommand_Status(t *testing.T) { func TestHandleCommand_Status(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*") tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil { if err != nil {