From 04d047fd37645c9b9f1ced78a297267221befbca Mon Sep 17 00:00:00 2001 From: Rahul Bansal Date: Sat, 21 Feb 2026 11:35:10 +0530 Subject: [PATCH] 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. --- pkg/agent/loop.go | 10 ++++++ pkg/agent/loop_test.go | 72 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 0429b308d..0374f938d 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -811,6 +811,16 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) { // Helper to find the mid-point of the conversation 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: // 1. System Prompt (with compression note appended) // 2. Second half of conversation diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index eae9442aa..4f1d7bf43 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -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) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil {