Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 39 additions & 19 deletions src/core/condense/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,19 +544,33 @@ export function getMessagesSinceLastSummary(messages: ApiMessage[]): ApiMessage[
* @returns The filtered history that should be sent to the API
*/
export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] {
const effectiveHistoryWithIndices = getEffectiveApiHistoryWithIndices(messages)
return effectiveHistoryWithIndices.map(({ message }) => message)
}

export function getEffectiveApiHistoryIndices(messages: ApiMessage[]): number[] {
const effectiveHistoryWithIndices = getEffectiveApiHistoryWithIndices(messages)
return effectiveHistoryWithIndices.map(({ index }) => index)
}

function getEffectiveApiHistoryWithIndices(messages: ApiMessage[]): Array<{ message: ApiMessage; index: number }> {
// Find the most recent summary message
const lastSummary = findLast(messages, (msg) => msg.isSummary === true)

if (lastSummary) {
// Fresh start model: return only messages from the summary onwards
const summaryIndex = messages.indexOf(lastSummary)
let messagesFromSummary = messages.slice(summaryIndex)
let messagesFromSummary = messages.slice(summaryIndex).map((message, offset) => ({
message,
index: summaryIndex + offset,
}))

// Collect all tool_use IDs from assistant messages in the result
// This is needed to filter out orphan tool_result blocks that reference
// tool_use IDs from messages that were condensed away
const toolUseIds = new Set<string>()
for (const msg of messagesFromSummary) {
for (const { message } of messagesFromSummary) {
const msg = message
if (msg.role === "assistant" && Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === "tool_use" && (block as Anthropic.Messages.ToolUseBlockParam).id) {
Expand All @@ -568,7 +582,8 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] {

// Filter out orphan tool_result blocks from user messages
messagesFromSummary = messagesFromSummary
.map((msg) => {
.map(({ message, index }) => {
const msg = message
if (msg.role === "user" && Array.isArray(msg.content)) {
const filteredContent = msg.content.filter((block) => {
if (block.type === "tool_result") {
Expand All @@ -582,22 +597,24 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] {
}
// If some content was filtered, return updated message
if (filteredContent.length !== msg.content.length) {
return { ...msg, content: filteredContent }
return { message: { ...msg, content: filteredContent }, index }
}
}
return msg
return { message: msg, index }
})
.filter((msg): msg is ApiMessage => msg !== null)
.filter((entry): entry is { message: ApiMessage; index: number } => entry !== null)

// Still need to filter out any truncated messages within this range
const existingTruncationIds = new Set<string>()
for (const msg of messagesFromSummary) {
for (const { message } of messagesFromSummary) {
const msg = message
if (msg.isTruncationMarker && msg.truncationId) {
existingTruncationIds.add(msg.truncationId)
}
}

return messagesFromSummary.filter((msg) => {
return messagesFromSummary.filter(({ message }) => {
const msg = message
// Filter out truncated messages if their truncation marker exists
if (msg.truncationParent && existingTruncationIds.has(msg.truncationParent)) {
return false
Expand Down Expand Up @@ -626,17 +643,20 @@ export function getEffectiveApiHistory(messages: ApiMessage[]): ApiMessage[] {
// Filter out messages whose condenseParent points to an existing summary
// or whose truncationParent points to an existing truncation marker.
// Messages with orphaned parents (summary/marker was deleted) are included.
return messages.filter((msg) => {
// Filter out condensed messages if their summary exists
if (msg.condenseParent && existingSummaryIds.has(msg.condenseParent)) {
return false
}
// Filter out truncated messages if their truncation marker exists
if (msg.truncationParent && existingTruncationIds.has(msg.truncationParent)) {
return false
}
return true
})
return messages
.map((message, index) => ({ message, index }))
.filter(({ message }) => {
const msg = message
// Filter out condensed messages if their summary exists
if (msg.condenseParent && existingSummaryIds.has(msg.condenseParent)) {
return false
}
// Filter out truncated messages if their truncation marker exists
if (msg.truncationParent && existingTruncationIds.has(msg.truncationParent)) {
return false
}
return true
})
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,22 @@ describe("Context Management", () => {
TelemetryService.createInstance([])
}
})

const countEffectiveHistoryTokens = async (messages: ApiMessage[], systemPrompt: string) => {
const effectiveMessages = condenseModule.getEffectiveApiHistory(messages)
let total = await estimateTokenCount([{ type: "text", text: systemPrompt }], mockApiHandler)

for (const message of effectiveMessages) {
if (Array.isArray(message.content)) {
total += await estimateTokenCount(message.content, mockApiHandler)
} else if (typeof message.content === "string") {
total += await estimateTokenCount([{ type: "text", text: message.content }], mockApiHandler)
}
}

return total
}

/**
* Tests for the truncateConversation function
*/
Expand Down Expand Up @@ -172,6 +188,35 @@ describe("Context Management", () => {
// Last message should NOT be tagged (now at index 4)
expect(result.messages[4].truncationParent).toBeUndefined()
})

it("should truncate from the effective history when earlier messages were condensed", () => {
const condenseId = "prior-condense"
const messages: ApiMessage[] = [
{ role: "user", content: "Hidden user", condenseParent: condenseId },
{ role: "assistant", content: "Hidden assistant", condenseParent: condenseId },
{ role: "user", content: "Earlier summary", isSummary: true, condenseId },
{ role: "assistant", content: "Visible assistant 1" },
{ role: "user", content: "Visible user 1" },
{ role: "assistant", content: "Visible assistant 2" },
{ role: "user", content: "Visible user 2" },
]

const result = truncateConversation(messages, 0.5, taskId)

expect(result.messagesRemoved).toBe(2)

// Messages hidden behind the summary should stay untouched.
expect(result.messages[0].truncationParent).toBeUndefined()
expect(result.messages[1].truncationParent).toBeUndefined()

// The summary remains the anchor, and truncation starts after it.
expect(result.messages[2].isSummary).toBe(true)
expect(result.messages[3].truncationParent).toBe(result.truncationId)
expect(result.messages[4].truncationParent).toBe(result.truncationId)
expect(result.messages[5].isTruncationMarker).toBe(true)
expect(result.messages[6].truncationParent).toBeUndefined()
expect(result.messages[7].truncationParent).toBeUndefined()
})
})

/**
Expand Down Expand Up @@ -1700,5 +1745,49 @@ describe("Context Management", () => {
// With system prompt included, we expect roughly 50% of the messages remaining
expect(result.newContextTokensAfterTruncation).toBeGreaterThan(0)
})

it("should count only the effective API history after truncation when prior condenses exist", async () => {
const modelInfo = createModelInfo(100000, 30000)
const totalTokens = 70001
const condenseId = "prior-condense"
const hiddenContent = "hidden historical content ".repeat(4000)
const visibleContent = "visible content that should actually be truncated ".repeat(200)

const messages: ApiMessage[] = [
{ role: "user", content: hiddenContent, condenseParent: condenseId },
{ role: "assistant", content: hiddenContent, condenseParent: condenseId },
{ role: "user", content: hiddenContent, condenseParent: condenseId },
{ role: "user", content: "Earlier summary", isSummary: true, condenseId },
{ role: "assistant", content: visibleContent },
{ role: "user", content: visibleContent },
{ role: "assistant", content: visibleContent },
{ role: "user", content: "" },
]

const systemPrompt = "System prompt for truncation recount"
const effectiveTokensBefore = await countEffectiveHistoryTokens(messages, systemPrompt)

const result = await manageContext({
messages,
totalTokens,
contextWindow: modelInfo.contextWindow,
maxTokens: modelInfo.maxTokens,
apiHandler: mockApiHandler,
autoCondenseContext: false,
autoCondenseContextPercent: 100,
systemPrompt,
taskId,
profileThresholds: {},
currentProfileId: "default",
})

expect(result.truncationId).toBeDefined()
expect(result.newContextTokensAfterTruncation).toBeDefined()

const expectedEffectiveTokens = await countEffectiveHistoryTokens(result.messages, systemPrompt)
expect(result.newContextTokensAfterTruncation).toBe(expectedEffectiveTokens)
expect(result.newContextTokensAfterTruncation).toBeLessThan(effectiveTokensBefore)
expect(result.newContextTokensAfterTruncation).toBeLessThan(result.prevContextTokens)
})
})
})
44 changes: 44 additions & 0 deletions src/core/context-management/__tests__/truncation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,50 @@ describe("Non-Destructive Sliding Window Truncation", () => {
const result = truncateConversation(manyMessages, 0.5, "test-task-id")
expect(result.messagesRemoved).toBe(4)
})

it("should ignore orphan tool_result-only messages when truncating fresh-start history", () => {
const condenseId = "condense-1"
const freshStartMessages: ApiMessage[] = [
{ role: "user", content: "Original task", ts: 1000, condenseParent: condenseId },
{
role: "assistant",
content: [{ type: "tool_use", id: "tool-orphan", name: "read_file", input: { path: "a.ts" } }],
ts: 1100,
condenseParent: condenseId,
},
{
role: "user",
content: [{ type: "text", text: "Summary" }],
ts: 1200,
isSummary: true,
condenseId,
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "tool-orphan", content: "orphan result" }],
ts: 1300,
},
{ role: "assistant", content: "Visible assistant 1", ts: 1400 },
{ role: "user", content: "Visible user 1", ts: 1500 },
{ role: "assistant", content: "Visible assistant 2", ts: 1600 },
{ role: "user", content: "Visible user 2", ts: 1700 },
]

const effectiveBefore = getEffectiveApiHistory(freshStartMessages)
expect(effectiveBefore).toHaveLength(5)

const result = truncateConversation(freshStartMessages, 0.5, "test-task-id")

expect(result.messagesRemoved).toBe(2)
expect(result.messages[3].truncationParent).toBeUndefined()
expect(result.messages[4].truncationParent).toBe(result.truncationId)
expect(result.messages[5].truncationParent).toBe(result.truncationId)

const effectiveAfter = getEffectiveApiHistory(result.messages)
expect(effectiveAfter).toHaveLength(4)
expect(effectiveAfter[0].isSummary).toBe(true)
expect(effectiveAfter[1].isTruncationMarker).toBe(true)
})
})

describe("getEffectiveApiHistory()", () => {
Expand Down
39 changes: 22 additions & 17 deletions src/core/context-management/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import crypto from "crypto"
import { TelemetryService } from "@roo-code/telemetry"

import { ApiHandler, ApiHandlerCreateMessageMetadata } from "../../api"
import { MAX_CONDENSE_THRESHOLD, MIN_CONDENSE_THRESHOLD, summarizeConversation, SummarizeResponse } from "../condense"
import {
MAX_CONDENSE_THRESHOLD,
MIN_CONDENSE_THRESHOLD,
getEffectiveApiHistory,
getEffectiveApiHistoryIndices,
summarizeConversation,
SummarizeResponse,
} from "../condense"
import { ApiMessage } from "../task-persistence/apiMessages"
import { ANTHROPIC_DEFAULT_MAX_TOKENS } from "@roo-code/types"
import { RooIgnoreController } from "../ignore/RooIgnoreController"
Expand Down Expand Up @@ -52,9 +59,10 @@ export type TruncationResult = {
/**
* Truncates a conversation by tagging messages as hidden instead of removing them.
*
* The first message is always retained, and a specified fraction (rounded to an even number)
* of messages from the beginning (excluding the first) is tagged with truncationParent.
* A truncation marker is inserted to track where truncation occurred.
* The first message in the effective API history is always retained, and a specified fraction
* (rounded to an even number) of messages from the beginning of that effective history
* (excluding the first effective message) is tagged with truncationParent. A truncation marker
* is inserted to track where truncation occurred.
*
* This implements non-destructive sliding window truncation, allowing messages to be
* restored if the user rewinds past the truncation point.
Expand All @@ -69,14 +77,12 @@ export function truncateConversation(messages: ApiMessage[], fracToRemove: numbe

const truncationId = crypto.randomUUID()

// Filter to only visible messages (those not already truncated)
// We need to track original indices to correctly tag messages in the full array
const visibleIndices: number[] = []
messages.forEach((msg, index) => {
if (!msg.truncationParent && !msg.isTruncationMarker) {
visibleIndices.push(index)
}
})
// Only truncate messages that are still part of the effective API history.
// Prior condensed history remains stored for rewind, but it should not consume
// the fallback truncation slice.
const visibleIndices = getEffectiveApiHistoryIndices(messages).filter(
(index) => !messages[index].isTruncationMarker,
)

// Calculate how many visible messages to truncate (excluding first visible message)
const visibleCount = visibleIndices.length
Expand Down Expand Up @@ -334,11 +340,10 @@ export async function manageContext({
if (prevContextTokens > allowedTokens) {
const truncationResult = truncateConversation(messages, 0.5, taskId)

// Calculate new context tokens after truncation by counting non-truncated messages
// Messages with truncationParent are hidden, so we count only those without it
const effectiveMessages = truncationResult.messages.filter(
(msg) => !msg.truncationParent && !msg.isTruncationMarker,
)
// Calculate new context tokens after truncation from the effective API history.
// This keeps the post-truncation recount aligned with the same filtered history
// we actually send to the provider, including prior condense/truncation layers.
const effectiveMessages = getEffectiveApiHistory(truncationResult.messages)
Comment thread
roomote[bot] marked this conversation as resolved.

// Include system prompt tokens so this value matches what we send to the API.
// Note: `prevContextTokens` is computed locally here (totalTokens + lastMessageTokens).
Expand Down
Loading