Compare commits

...

9 Commits

Author SHA1 Message Date
Toshii 8040d2516d Merge branch 'main' into to/context-management-switch 2025-08-20 13:13:02 -07:00
0xtoshii 9a0f5a5251 updating feature section comment 2025-08-19 14:36:00 -07:00
0xtoshii ca1e309478 undo biome invalid changes 2025-08-19 14:32:54 -07:00
0xtoshii 69ae9ebeb4 apply biome fixes and add back in removed comments 2025-08-19 14:30:49 -07:00
Toshii 6e649ea6db Merge branch 'main' into to/context-management-switch 2025-08-19 14:19:01 -07:00
0xtoshii 2365039cfb menu 2025-08-18 19:12:39 -07:00
0xtoshii b31d43a526 linting 2025-08-18 18:25:58 -07:00
0xtoshii 3933885d55 base swapping 2025-08-18 18:25:10 -07:00
0xtoshii 1f4818c7e9 truncate first user message 2025-08-18 16:31:26 -07:00
11 changed files with 198 additions and 72 deletions
+1
View File
@@ -124,6 +124,7 @@ message UpdateSettingsRequest {
optional OpenaiReasoningEffort openai_reasoning_effort = 15;
optional bool strict_plan_mode_enabled = 16;
optional FocusChainSettings focus_chain_settings = 17;
optional bool use_auto_condense = 18;
}
// Complete API Configuration message
@@ -135,7 +135,7 @@ export class ContextManager {
maxContextWindow: number
} | null {
// Use provided triggerIndex or fallback to automatic detection
let targetIndex
let targetIndex: number
if (triggerIndex !== undefined) {
targetIndex = triggerIndex
} else {
@@ -174,13 +174,73 @@ export class ContextManager {
*/
async getNewContextMessagesAndMetadata(
apiConversationHistory: Anthropic.Messages.MessageParam[],
_clineMessages: ClineMessage[],
_api: ApiHandler,
clineMessages: ClineMessage[],
api: ApiHandler,
conversationHistoryDeletedRange: [number, number] | undefined,
_previousApiReqIndex: number,
_taskDirectory: string,
previousApiReqIndex: number,
taskDirectory: string,
useAutoCondense: boolean, // option to use new auto-condense or old programmatic context management
) {
const updatedConversationHistoryDeletedRange = false
let updatedConversationHistoryDeletedRange = false
if (!useAutoCondense) {
// If the previous API request's total token usage is close to the context window, truncate the conversation history to free up space for the new request
if (previousApiReqIndex >= 0) {
const previousRequest = clineMessages[previousApiReqIndex]
if (previousRequest && previousRequest.text) {
const timestamp = previousRequest.ts
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
const { maxAllowedSize } = getContextWindowInfo(api)
// This is the most reliable way to know when we're close to hitting the context window.
if (totalTokens >= maxAllowedSize) {
// Since the user may switch between models with different context windows, truncating half may not be enough (ie if switching from claude 200k to deepseek 64k, half truncation will only remove 100k tokens, but we need to remove much more)
// So if totalTokens/2 is greater than maxAllowedSize, we truncate 3/4 instead of 1/2
const keep = totalTokens / 2 > maxAllowedSize ? "quarter" : "half"
// we later check how many chars we trim to determine if we should still truncate history
let [anyContextUpdates, uniqueFileReadIndices] = this.applyContextOptimizations(
apiConversationHistory,
conversationHistoryDeletedRange ? conversationHistoryDeletedRange[1] + 1 : 2,
timestamp,
)
let needToTruncate = true
if (anyContextUpdates) {
// determine whether we've saved enough chars to not truncate
const charactersSavedPercentage = this.calculateContextOptimizationMetrics(
apiConversationHistory,
conversationHistoryDeletedRange,
uniqueFileReadIndices,
)
if (charactersSavedPercentage >= 0.3) {
needToTruncate = false
}
}
if (needToTruncate) {
// go ahead with truncation
anyContextUpdates = this.applyStandardContextTruncationNoticeChange(timestamp) || anyContextUpdates
// NOTE: it's okay that we overwriteConversationHistory in resume task since we're only ever removing the last user message and not anything in the middle which would affect this range
conversationHistoryDeletedRange = this.getNextTruncationRange(
apiConversationHistory,
conversationHistoryDeletedRange,
keep,
)
updatedConversationHistoryDeletedRange = true
}
// if we alter the context history, save the updated version to disk
if (anyContextUpdates) {
await this.saveContextHistory(taskDirectory)
}
}
}
}
}
const truncatedConversationHistory = this.getAndAlterTruncatedMessages(
apiConversationHistory,
@@ -593,8 +653,7 @@ export class ContextManager {
let foundMatch = false
const filePaths: string[] = []
let match
while ((match = pattern.exec(secondBlockText)) !== null) {
for (const match of secondBlockText.matchAll(pattern)) {
foundMatch = true
const filePath = match[1]
@@ -835,7 +894,7 @@ export class ContextManager {
// if block was just altered, then calculate savings
if (hasNewAlterations) {
let originalTextLength
let originalTextLength: number
if (updates.length > 1) {
originalTextLength = updates[updates.length - 2][2][0].length // handles case if we have multiple updates for same text block
} else {
+4
View File
@@ -174,6 +174,7 @@ export class Controller {
const isNewUser = this.cacheService.getGlobalStateKey("isNewUser")
const taskHistory = this.cacheService.getGlobalStateKey("taskHistory")
const strictPlanModeEnabled = this.cacheService.getGlobalStateKey("strictPlanModeEnabled")
const useAutoCondense = this.cacheService.getGlobalStateKey("useAutoCondense")
const NEW_USER_TASK_COUNT_THRESHOLD = 10
@@ -211,6 +212,7 @@ export class Controller {
openaiReasoningEffort,
mode,
strictPlanModeEnabled ?? false,
useAutoCondense ?? true,
shellIntegrationTimeout,
terminalReuseEnabled ?? true,
terminalOutputLineLimit ?? 500,
@@ -584,6 +586,7 @@ export class Controller {
const openaiReasoningEffort = this.cacheService.getGlobalStateKey("openaiReasoningEffort")
const mode = this.cacheService.getGlobalStateKey("mode")
const strictPlanModeEnabled = this.cacheService.getGlobalStateKey("strictPlanModeEnabled")
const useAutoCondense = this.cacheService.getGlobalStateKey("useAutoCondense")
const userInfo = this.cacheService.getGlobalStateKey("userInfo")
const mcpMarketplaceEnabled = this.cacheService.getGlobalStateKey("mcpMarketplaceEnabled")
const mcpDisplayMode = this.cacheService.getGlobalStateKey("mcpDisplayMode")
@@ -641,6 +644,7 @@ export class Controller {
openaiReasoningEffort,
mode,
strictPlanModeEnabled,
useAutoCondense,
userInfo,
mcpMarketplaceEnabled,
mcpDisplayMode,
@@ -139,6 +139,14 @@ export async function updateSettings(controller: Controller, request: UpdateSett
controller.cacheService.setGlobalState("strictPlanModeEnabled", request.strictPlanModeEnabled)
}
// Update auto-condense setting
if (request.useAutoCondense !== undefined) {
if (controller.task) {
controller.task.updateUseAutoCondense(request.useAutoCondense)
}
controller.cacheService.setGlobalState("useAutoCondense", request.useAutoCondense)
}
// Update focus chain settings
if (request.focusChainSettings !== undefined) {
const remoteEnabled = controller.cacheService.getGlobalStateKey("focusChainFeatureFlagEnabled")
+1
View File
@@ -736,6 +736,7 @@ export class CacheService {
const globalStateFields = {
// Extension state fields
strictPlanModeEnabled: state.strictPlanModeEnabled,
useAutoCondense: state.useAutoCondense,
isNewUser: state.isNewUser,
welcomeViewCompleted: state.welcomeViewCompleted,
autoApprovalSettings: state.autoApprovalSettings || DEFAULT_AUTO_APPROVAL_SETTINGS,
+2
View File
@@ -103,6 +103,7 @@ export type GlobalStateKey =
| "sapAiResourceGroup"
| "claudeCodePath"
| "strictPlanModeEnabled"
| "useAutoCondense"
| "focusChainSettings"
| "focusChainFeatureFlagEnabled"
// Settings around plan/act and ephemeral model configuration
@@ -227,6 +228,7 @@ export interface GlobalState {
sapAiResourceGroup: string | undefined
claudeCodePath: string | undefined
strictPlanModeEnabled: boolean
useAutoCondense: boolean
preferredLanguage: string
openaiReasoningEffort: OpenaiReasoningEffort
mode: Mode
+2
View File
@@ -14,6 +14,7 @@ import { SecretKey } from "../state-keys"
export async function readStateFromDisk(context: ExtensionContext) {
// Get all global state values
const strictPlanModeEnabled = context.globalState.get("strictPlanModeEnabled") as boolean | undefined
const useAutoCondense = context.globalState.get("useAutoCondense") as boolean | undefined
const isNewUser = context.globalState.get("isNewUser") as boolean | undefined
const welcomeViewCompleted = context.globalState.get("welcomeViewCompleted") as boolean | undefined
const awsRegion = context.globalState.get("awsRegion") as string | undefined
@@ -373,6 +374,7 @@ export async function readStateFromDisk(context: ExtensionContext) {
actModeBasetenModelInfo,
},
strictPlanModeEnabled: strictPlanModeEnabled ?? false,
useAutoCondense: useAutoCondense ?? true,
isNewUser: isNewUser ?? true,
welcomeViewCompleted,
lastShownAnnouncementId,
+95 -63
View File
@@ -121,6 +121,9 @@ export class Task {
// Focus Chain
private FocusChainManager?: FocusChainManager
// Context Management
private useAutoCondense: boolean
// Callbacks
private updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>
private postStateToWebview: () => Promise<void>
@@ -155,6 +158,7 @@ export class Task {
openaiReasoningEffort: OpenaiReasoningEffort,
mode: Mode,
strictPlanModeEnabled: boolean,
useAutoCondense: boolean,
shellIntegrationTimeout: number,
terminalReuseEnabled: boolean,
terminalOutputLineLimit: number,
@@ -206,6 +210,7 @@ export class Task {
this.enableCheckpoints = enableCheckpointsSetting
this.cwd = cwd
this.cacheService = cacheService
this.useAutoCondense = useAutoCondense
// Set up MCP notification callback for real-time notifications
this.mcpHub.setNotificationCallback(async (serverName: string, _level: string, message: string) => {
@@ -377,6 +382,10 @@ export class Task {
this.toolExecutor.updateStrictPlanModeEnabled(strictPlanModeEnabled)
}
public updateUseAutoCondense(useAutoCondense: boolean): void {
this.useAutoCondense = useAutoCondense
}
// While a task is ref'd by a controller, it will always have access to the extension context
// This error is thrown if the controller derefs the task after e.g., aborting the task
private getContext(): vscode.ExtensionContext {
@@ -1717,6 +1726,7 @@ export class Task {
this.taskState.conversationHistoryDeletedRange,
previousApiReqIndex,
await ensureTaskDirectoryExists(this.getContext(), this.taskId),
this.useAutoCondense,
)
if (contextManagementMetadata.updatedConversationHistoryDeletedRange) {
@@ -2142,84 +2152,106 @@ export class Task {
// No explicit UI message here, error message will be in ExtensionState.
}
// when we initially trigger the context cleanup, we will be increasing the context window size, so we need some state `currentlySummarizing`
// to store whether we have already started the context summarization flow, so we don't attempt to summarize again. additionally, immediately
// post summarizing we need to increment the conversationHistoryDeletedRange to mask out the summarization-trigger user & assistant response messaages
let shouldCompact = false
if (this.taskState.currentlySummarizing) {
this.taskState.currentlySummarizing = false
// Separate logic when using the auto-condense context management vs the original context management methods
if (this.useAutoCondense) {
// when we initially trigger the context cleanup, we will be increasing the context window size, so we need some state `currentlySummarizing`
// to store whether we have already started the context summarization flow, so we don't attempt to summarize again. additionally, immediately
// post summarizing we need to increment the conversationHistoryDeletedRange to mask out the summarization-trigger user & assistant response messaages
let shouldCompact = false
if (this.taskState.currentlySummarizing) {
this.taskState.currentlySummarizing = false
if (this.taskState.conversationHistoryDeletedRange) {
const [start, end] = this.taskState.conversationHistoryDeletedRange
const apiHistory = this.messageStateHandler.getApiConversationHistory()
if (this.taskState.conversationHistoryDeletedRange) {
const [start, end] = this.taskState.conversationHistoryDeletedRange
const apiHistory = this.messageStateHandler.getApiConversationHistory()
// we want to increment the deleted range to remove the pre-summarization tool call output, with additional safety check
const safeEnd = Math.min(end + 2, apiHistory.length - 1)
if (end + 2 <= safeEnd) {
this.taskState.conversationHistoryDeletedRange = [start, end + 2]
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
// we want to increment the deleted range to remove the pre-summarization tool call output, with additional safety check
const safeEnd = Math.min(end + 2, apiHistory.length - 1)
if (end + 2 <= safeEnd) {
this.taskState.conversationHistoryDeletedRange = [start, end + 2]
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
}
}
} else {
shouldCompact = this.contextManager.shouldCompactContextWindow(
this.messageStateHandler.getClineMessages(),
this.api,
previousApiReqIndex,
)
// There is an edge case where the summarize_task tool call completes but the user cancels the next request before it finishes
// this will result in this.taskState.currentlySummarizing being false, and we also failed to update the context window token
// estimate, which require a full new message to be completed along with gathering the latest usage block. A proxy for whether
// we just summarized would be to check the number of in-range messages, which itself has some extreme edge case (e.g. what if
// first+second user messages take up entire context-window, but in this case there's already an issue). TODO: Examine other
// approaches such as storing this.taskState.currentlySummarizing on disk in the clineMessages. This was intentionally not done
// for now to prevent additional disk from needing to be used.
// The worse case scenario is effectively cline summarizing a summary, which is bad UX, but doesn't break other logic.
if (shouldCompact && this.taskState.conversationHistoryDeletedRange) {
const apiHistory = this.messageStateHandler.getApiConversationHistory()
const activeMessageCount = apiHistory.length - this.taskState.conversationHistoryDeletedRange[1] - 1
// IMPORTANT - we didn't append this next user message yet so the last message in this array is an assistant message
// that's why we are comparing to an even number of messages (0, 2) rather than odd (1, 3)
if (activeMessageCount <= 2) {
shouldCompact = false
}
}
}
} else {
shouldCompact = this.contextManager.shouldCompactContextWindow(
this.messageStateHandler.getClineMessages(),
this.api,
previousApiReqIndex,
)
// There is an edge case where the summarize_task tool call completes but the user cancels the next request before it finishes
// this will result in this.taskState.currentlySummarizing being false, and we also failed to update the context window token
// estimate, which require a full new message to be completed along with gathering the latest usage block. A proxy for whether
// we just summarized would be to check the number of in-range messages, which itself has some extreme edge case (e.g. what if
// first+second user messages take up entire context-window, but in this case there's already an issue). TODO: Examine other
// approaches such as storing this.taskState.currentlySummarizing on disk in the clineMessages. This was intentionally not done
// for now to prevent additional disk from needing to be used.
// The worse case scenario is effectively cline summarizing a summary, which is bad UX, but doesn't break other logic.
if (shouldCompact && this.taskState.conversationHistoryDeletedRange) {
const apiHistory = this.messageStateHandler.getApiConversationHistory()
const activeMessageCount = apiHistory.length - this.taskState.conversationHistoryDeletedRange[1] - 1
let parsedUserContent: UserContent
let environmentDetails: string
let clinerulesError: boolean
// IMPORTANT - we didn't append this next user message yet so the last message in this array is an assistant message
// that's why we are comparing to an even number of messages (0, 2) rather than odd (1, 3)
if (activeMessageCount <= 2) {
shouldCompact = false
}
// when summarizing the context window, we do not want to inject updated to the context
if (shouldCompact) {
parsedUserContent = userContent
environmentDetails = ""
clinerulesError = false
this.taskState.lastAutoCompactTriggerIndex = previousApiReqIndex
} else {
;[parsedUserContent, environmentDetails, clinerulesError] = await this.loadContext(
userContent,
includeFileDetails,
)
}
}
let parsedUserContent: UserContent
let environmentDetails: string
let clinerulesError: boolean
// error handling if the user uses the /newrule command & their .clinerules is a file, for file read operations didnt work properly
if (clinerulesError === true) {
await this.say(
"error",
"Issue with processing the /newrule command. Double check that, if '.clinerules' already exists, it's a directory and not a file. Otherwise there was an issue referencing this file/directory.",
)
}
// when summarizing the context window, we do not want to inject updated to the context
if (shouldCompact) {
parsedUserContent = userContent
environmentDetails = ""
clinerulesError = false
this.taskState.lastAutoCompactTriggerIndex = previousApiReqIndex
userContent = parsedUserContent
// add environment details as its own text block, separate from tool results
// do not add environment details to the message which we are compacting the context window
if (!shouldCompact) {
userContent.push({ type: "text", text: environmentDetails })
}
if (shouldCompact) {
userContent.push({ type: "text", text: summarizeTask(this.focusChainSettings.enabled) })
}
} else {
;[parsedUserContent, environmentDetails, clinerulesError] = await this.loadContext(userContent, includeFileDetails)
}
// error handling if the user uses the /newrule command & their .clinerules is a file, for file read operations didnt work properly
if (clinerulesError === true) {
await this.say(
"error",
"Issue with processing the /newrule command. Double check that, if '.clinerules' already exists, it's a directory and not a file. Otherwise there was an issue referencing this file/directory.",
const [parsedUserContent, environmentDetails, clinerulesError] = await this.loadContext(
userContent,
includeFileDetails,
)
}
userContent = parsedUserContent
// add environment details as its own text block, separate from tool results
// do not add environment details to the message which we are compacting the context window
if (!shouldCompact) {
if (clinerulesError === true) {
await this.say(
"error",
"Issue with processing the /newrule command. Double check that, if '.clinerules' already exists, it's a directory and not a file. Otherwise there was an issue referencing this file/directory.",
)
}
userContent = parsedUserContent
userContent.push({ type: "text", text: environmentDetails })
}
if (shouldCompact) {
userContent.push({ type: "text", text: summarizeTask(this.focusChainSettings.enabled) })
}
await this.messageStateHandler.addToApiConversationHistory({
role: "user",
content: userContent,
+1
View File
@@ -67,6 +67,7 @@ export interface ExtensionState {
localWindsurfRulesToggles: ClineRulesToggles
mcpResponsesCollapsed?: boolean
strictPlanModeEnabled?: boolean
useAutoCondense?: boolean
focusChainSettings: FocusChainSettings
focusChainFeatureFlagEnabled?: boolean
}
@@ -19,6 +19,7 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
mcpResponsesCollapsed,
openaiReasoningEffort,
strictPlanModeEnabled,
useAutoCondense,
focusChainSettings,
focusChainFeatureFlagEnabled,
} = useExtensionState()
@@ -167,6 +168,20 @@ const FeatureSettingsSection = ({ renderSectionHeader }: FeatureSettingsSectionP
</p>
</div>
)}
<div style={{ marginTop: 10 }}>
<VSCodeCheckbox
checked={useAutoCondense}
onChange={(e: any) => {
const checked = e.target.checked === true
updateSetting("useAutoCondense", checked)
}}>
Enable auto condense
</VSCodeCheckbox>
<p className="text-xs text-[var(--vscode-descriptionForeground)]">
Enables advanced context management system which uses LLM based condensing, instead of rule-based
truncation.
</p>
</div>
</div>
</Section>
</div>
@@ -197,6 +197,7 @@ export const ExtensionStateContextProvider: React.FC<{
welcomeViewCompleted: false,
mcpResponsesCollapsed: false, // Default value (expanded), will be overwritten by extension state
strictPlanModeEnabled: false,
useAutoCondense: true,
})
const [didHydrateState, setDidHydrateState] = useState(false)
const [showWelcome, setShowWelcome] = useState(false)