diff --git a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts index e7ad0a3101a..940d739e960 100644 --- a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts +++ b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts @@ -13,6 +13,7 @@ import { isAnthropicFamily } from '../../../platform/endpoint/common/chatModelCa import { IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider'; import { rawPartAsThinkingData } from '../../../platform/endpoint/common/thinkingDataContainer'; import { ILogService } from '../../../platform/log/common/logService'; +import { ContextManagementResponse } from '../../../platform/networking/common/anthropic'; import { OpenAiFunctionDef } from '../../../platform/networking/common/fetch'; import { IMakeChatRequestOptions } from '../../../platform/networking/common/networking'; import { IRequestLogger } from '../../../platform/requestLogger/node/requestLogger'; @@ -28,7 +29,7 @@ import { IInstantiationService } from '../../../util/vs/platform/instantiation/c import { ChatResponsePullRequestPart, LanguageModelDataPart2, LanguageModelPartAudience, LanguageModelToolResult2, MarkdownString } from '../../../vscodeTypes'; import { InteractionOutcomeComputer } from '../../inlineChat/node/promptCraftingTypes'; import { ChatVariablesCollection } from '../../prompt/common/chatVariablesCollection'; -import { Conversation, IResultMetadata, ResponseStreamParticipant, TurnStatus } from '../../prompt/common/conversation'; +import { ContextEditingMetadata, Conversation, IResultMetadata, ResponseStreamParticipant, TurnStatus } from '../../prompt/common/conversation'; import { IBuildPromptContext, InternalToolReference, IToolCall, IToolCallRound } from '../../prompt/common/intents'; import { cancelText, IToolCallIterationIncrease } from '../../prompt/common/specialRequestTypes'; import { ThinkingDataItem, ToolCallRound } from '../../prompt/common/toolCallRound'; @@ -435,6 +436,7 @@ export abstract class ToolCallingLoop 0) { + const totalClearedTokens = contextManagementResponse.applied_edits.reduce( + (sum, edit) => sum + (edit.cleared_input_tokens || 0), + 0 + ); + if (totalClearedTokens > 0) { + this.turn.setMetadata(new ContextEditingMetadata( + totalClearedTokens, + contextManagementResponse.applied_edits.length + )); + } + } const toolInputRetry = isToolInputFailure ? (this.toolCallRounds.at(-1)?.toolInputRetry || 0) + 1 : 0; if (fetchResult.type === ChatFetchResponseType.Success) { thinkingItem?.updateWithFetchResult(fetchResult); diff --git a/extensions/copilot/src/extension/prompt/common/conversation.ts b/extensions/copilot/src/extension/prompt/common/conversation.ts index ca26aafe20a..4eeedbc4791 100644 --- a/extensions/copilot/src/extension/prompt/common/conversation.ts +++ b/extensions/copilot/src/extension/prompt/common/conversation.ts @@ -387,6 +387,22 @@ export class GlobalContextMessageMetadata { ) { } } +/** + * Metadata capturing context editing information from Anthropic Messages API. + * When context editing clears tokens on a turn, this metadata is stored so that + * subsequent turns can use the cleared token count to adjust their budget calculation. + */ +export class ContextEditingMetadata extends PromptMetadata { + constructor( + /** Total number of tokens cleared by context editing */ + readonly clearedTokens: number, + /** Number of context edits applied */ + readonly editCount: number, + ) { + super(); + } +} + export function getGlobalContextCacheKey(accessor: ServicesAccessor): string { const workspaceService = accessor.get(IWorkspaceService); return workspaceService.getWorkspaceFolders().map(folder => folder.toString()).join(','); diff --git a/extensions/copilot/src/extension/prompt/vscode-node/requestLoggerImpl.ts b/extensions/copilot/src/extension/prompt/vscode-node/requestLoggerImpl.ts index 744f910d60a..190576f1797 100644 --- a/extensions/copilot/src/extension/prompt/vscode-node/requestLoggerImpl.ts +++ b/extensions/copilot/src/extension/prompt/vscode-node/requestLoggerImpl.ts @@ -53,6 +53,39 @@ function processDeltasToMessage(deltas: IResponseDelta[]): string { }).join('\n'); } + // Handle context management + if (d.contextManagement) { + if (i > 0 || text.length > 0) { + text += '\n'; + } + + const totalClearedTokens = d.contextManagement.applied_edits.reduce( + (sum, edit) => sum + (edit.cleared_input_tokens || 0), + 0 + ); + const totalClearedToolUses = d.contextManagement.applied_edits.reduce( + (sum, edit) => sum + (edit.cleared_tool_uses || 0), + 0 + ); + const totalClearedThinkingTurns = d.contextManagement.applied_edits.reduce( + (sum, edit) => sum + (edit.cleared_thinking_turns || 0), + 0 + ); + + const details: string[] = []; + if (totalClearedTokens > 0) { + details.push(`${totalClearedTokens} tokens`); + } + if (totalClearedToolUses > 0) { + details.push(`${totalClearedToolUses} tool uses`); + } + if (totalClearedThinkingTurns > 0) { + details.push(`${totalClearedThinkingTurns} thinking turns`); + } + + text += `🧹 Context cleared: ${details.join(', ')}`; + } + return text; }).join(''); } diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 4008495045b..c677986d739 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -798,6 +798,16 @@ export namespace ConfigKey { export const AskAgent = defineSetting('chat.advanced.enableAskAgent', ConfigType.ExperimentBased, false); export const RetryNetworkErrors = defineSetting('chat.advanced.enableRetryNetworkErrors', ConfigType.ExperimentBased, true); export const WorkspaceEnableCodeSearchExternalIngest = defineTeamInternalSetting('chat.advanced.workspace.codeSearchExternalIngest.enabled', ConfigType.ExperimentBased, false); + + /** Context editing for Anthropic Messages API */ + export const AnthropicContextEditingEnabled = defineTeamInternalSetting('chat.advanced.anthropic.contextEditing.enabled', ConfigType.ExperimentBased, false); + export const AnthropicContextEditingToolResultTriggerType = defineTeamInternalSetting<'input_tokens' | 'tool_uses'>('chat.advanced.anthropic.contextEditing.toolResult.triggerType', ConfigType.ExperimentBased, 'input_tokens'); + export const AnthropicContextEditingToolResultTriggerValue = defineTeamInternalSetting('chat.advanced.anthropic.contextEditing.toolResult.triggerValue', ConfigType.ExperimentBased, 100000); + export const AnthropicContextEditingToolResultKeepCount = defineTeamInternalSetting('chat.advanced.anthropic.contextEditing.toolResult.keepCount', ConfigType.ExperimentBased, 3); + export const AnthropicContextEditingToolResultClearAtLeastTokens = defineTeamInternalSetting('chat.advanced.anthropic.contextEditing.toolResult.clearAtLeastTokens', ConfigType.ExperimentBased, 25000); + export const AnthropicContextEditingToolResultExcludeTools = defineTeamInternalSetting('chat.advanced.anthropic.contextEditing.toolResult.excludeTools', ConfigType.Simple, []); + export const AnthropicContextEditingToolResultClearInputs = defineTeamInternalSetting('chat.advanced.anthropic.contextEditing.toolResult.clearInputs', ConfigType.ExperimentBased, false); + export const AnthropicContextEditingThinkingKeepTurns = defineTeamInternalSetting('chat.advanced.anthropic.contextEditing.thinking.keepTurns', ConfigType.ExperimentBased, 1); } export const Enable = defineSetting<{ [key: string]: boolean }>('enable', ConfigType.Simple, { diff --git a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts index 4dfd63be86f..938a728ec88 100644 --- a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts @@ -171,8 +171,23 @@ export class ChatEndpoint implements IChatEndpoint { public getExtraHeaders(): Record { const headers: Record = { ...this.modelMetadata.requestHeaders }; - if (this.useMessagesApi && this._getThinkingBudget()) { - headers['anthropic-beta'] = 'interleaved-thinking-2025-05-14'; + if (this.useMessagesApi) { + const betaFeatures: string[] = []; + + // Add thinking beta if enabled + if (this._getThinkingBudget()) { + betaFeatures.push('interleaved-thinking-2025-05-14'); + } + + // Add context management beta if enabled + const contextEditingEnabled = this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.AnthropicContextEditingEnabled, this._expService); + if (contextEditingEnabled) { + betaFeatures.push('context-management-2025-06-27'); + } + + if (betaFeatures.length > 0) { + headers['anthropic-beta'] = betaFeatures.join(','); + } } return headers; @@ -316,7 +331,7 @@ export class ChatEndpoint implements IChatEndpoint { if (this.useResponsesApi) { return processResponseFromChatEndpoint(this._instantiationService, telemetryService, logService, response, expectedNumChoices, finishCallback, telemetryData); } else if (this.useMessagesApi) { - return processResponseFromMessagesEndpoint(this._instantiationService, telemetryService, logService, response, expectedNumChoices, finishCallback, telemetryData); + return processResponseFromMessagesEndpoint(this._instantiationService, response, finishCallback, telemetryData); } else if (!this._supportsStreaming) { return defaultNonStreamChatResponseProcessor(response, finishCallback, telemetryData); } else { diff --git a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts index c70a79d6df2..ef1ed23f6c8 100644 --- a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts @@ -12,12 +12,11 @@ import { SSEParser } from '../../../util/vs/base/common/sseParser'; import { generateUuid } from '../../../util/vs/base/common/uuid'; import { IInstantiationService, ServicesAccessor } from '../../../util/vs/platform/instantiation/common/instantiation'; import { ConfigKey, IConfigurationService } from '../../configuration/common/configurationService'; -import { ILogService } from '../../log/common/logService'; -import { AnthropicMessagesTool, FinishedCallback, IResponseDelta } from '../../networking/common/fetch'; +import { AnthropicMessagesTool, buildContextManagement, ContextManagementResponse } from '../../networking/common/anthropic'; +import { FinishedCallback, IResponseDelta } from '../../networking/common/fetch'; import { IChatEndpoint, ICreateEndpointBodyOptions, IEndpointBody } from '../../networking/common/networking'; import { ChatCompletion, FinishedCompletionReason } from '../../networking/common/openai'; import { IExperimentationService } from '../../telemetry/common/nullExperimentationService'; -import { ITelemetryService } from '../../telemetry/common/telemetry'; import { TelemetryData } from '../../telemetry/common/telemetryData'; interface AnthropicStreamEvent { @@ -54,6 +53,7 @@ interface AnthropicStreamEvent { cache_creation_input_tokens?: number; cache_read_input_tokens?: number; }; + context_management?: ContextManagementResponse; } export function createMessagesRequestBody(accessor: ServicesAccessor, options: ICreateEndpointBodyOptions, model: string, endpoint: IChatEndpoint): IEndpointBody { @@ -80,6 +80,19 @@ export function createMessagesRequestBody(accessor: ServicesAccessor, options: I ? Math.min(32000, maxTokens - 1, normalizedBudget) : undefined; + // Build context management configuration + const contextEditingEnabled = configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.AnthropicContextEditingEnabled, experimentationService); + const contextEditingConfig = { + triggerType: configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.AnthropicContextEditingToolResultTriggerType, experimentationService) as 'input_tokens' | 'tool_uses', + triggerValue: configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.AnthropicContextEditingToolResultTriggerValue, experimentationService), + keepCount: configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.AnthropicContextEditingToolResultKeepCount, experimentationService), + clearAtLeastTokens: configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.AnthropicContextEditingToolResultClearAtLeastTokens, experimentationService), + excludeTools: configurationService.getConfig(ConfigKey.TeamInternal.AnthropicContextEditingToolResultExcludeTools), + clearInputs: configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.AnthropicContextEditingToolResultClearInputs, experimentationService), + thinkingKeepTurns: configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.AnthropicContextEditingThinkingKeepTurns, experimentationService), + }; + const contextManagement = contextEditingEnabled ? buildContextManagement(contextEditingConfig, thinkingBudget, endpoint.modelMaxPromptTokens) : undefined; + return { model, ...rawMessagesToMessagesAPI(options.messages), @@ -91,6 +104,7 @@ export function createMessagesRequestBody(accessor: ServicesAccessor, options: I type: 'enabled', budget_tokens: thinkingBudget, } : undefined, + context_management: contextManagement, }; } @@ -251,10 +265,7 @@ function contentBlockSupportsCacheControl(block: ContentBlockParam): block is Ex export async function processResponseFromMessagesEndpoint( instantiationService: IInstantiationService, - telemetryService: ITelemetryService, - logService: ILogService, response: Response, - expectedNumChoices: number, finishCallback: FinishedCallback, telemetryData: TelemetryData ): Promise> { @@ -270,7 +281,6 @@ export async function processResponseFromMessagesEndpoint( return; } - logService.trace(`SSE: ${trimmed}`); const parsed = JSON.parse(trimmed) as Partial; const type = parsed.type ?? ev.type; if (!type) { @@ -303,6 +313,7 @@ export class AnthropicMessagesProcessor { private inputTokens: number = 0; private outputTokens: number = 0; private cachedTokens: number = 0; + private contextManagementResponse?: ContextManagementResponse; constructor( private readonly telemetryData: TelemetryData, @@ -408,8 +419,29 @@ export class AnthropicMessagesProcessor { if (chunk.usage) { this.outputTokens = chunk.usage.output_tokens; } + if (chunk.context_management) { + this.contextManagementResponse = chunk.context_management; + // Report context management via delta so it gets logged to request logger + return onProgress({ + text: '', + contextManagement: chunk.context_management + }); + } return; case 'message_stop': + // Add context management info to telemetry if available + if (this.contextManagementResponse) { + const totalClearedTokens = this.contextManagementResponse.applied_edits.reduce( + (sum, edit) => sum + (edit.cleared_input_tokens || 0), + 0 + ); + this.telemetryData.extendedBy({ + contextEditingApplied: 'true', + contextEditingClearedTokens: totalClearedTokens.toString(), + contextEditingEditCount: this.contextManagementResponse.applied_edits.length.toString(), + }); + } + return { blockFinished: true, choiceIndex: 0, diff --git a/extensions/copilot/src/platform/networking/common/anthropic.ts b/extensions/copilot/src/platform/networking/common/anthropic.ts new file mode 100644 index 00000000000..3f48d7f86c3 --- /dev/null +++ b/extensions/copilot/src/platform/networking/common/anthropic.ts @@ -0,0 +1,121 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Types for Anthropic Messages API + * Based on https://platform.claude.com/docs/en/api/messages + */ + +export interface AnthropicMessagesTool { + name: string; + description?: string; + input_schema: { + type: 'object'; + properties?: Record; + required?: string[]; + }; +} + +/** + * Context management types for Anthropic Messages API + * Based on https://platform.claude.com/docs/en/build-with-claude/context-editing + */ + +export type ContextManagementTrigger = + | { type: 'input_tokens'; value: number } + | { type: 'tool_uses'; value: number }; + +export type ContextManagementKeep = + | { type: 'tool_uses'; value: number } + | { type: 'thinking_turns'; value: number } + | 'all'; + +export type ContextManagementClearAtLeast = { + type: 'input_tokens'; + value: number; +}; + +export interface ClearToolUsesEdit { + type: 'clear_tool_uses_20250919'; + trigger?: ContextManagementTrigger; + keep?: ContextManagementKeep; + clear_at_least?: ContextManagementClearAtLeast; + exclude_tools?: string[]; + clear_tool_inputs?: boolean; +} + +export interface ClearThinkingEdit { + type: 'clear_thinking_20251015'; + keep?: ContextManagementKeep; +} + +export type ContextManagementEdit = ClearToolUsesEdit | ClearThinkingEdit; + +export interface ContextManagement { + edits: ContextManagementEdit[]; +} + +export interface AppliedContextEdit { + type: 'clear_thinking_20251015' | 'clear_tool_uses_20250919'; + cleared_thinking_turns?: number; + cleared_tool_uses?: number; + cleared_input_tokens?: number; +} + +export interface ContextManagementResponse { + applied_edits: AppliedContextEdit[]; +} + +export interface ContextEditingConfig { + triggerType: 'input_tokens' | 'tool_uses'; + triggerValue: number; + keepCount: number; + clearAtLeastTokens: number | undefined; + excludeTools: string[]; + clearInputs: boolean; + thinkingKeepTurns: number; +} + +/** + * Builds the context_management configuration object for the Messages API request. + * @param config The context editing configuration from individual settings + * @param hasThinking Whether extended thinking is enabled (the thinking budget value) + * @param modelMaxTokens The maximum input tokens supported by the model + * @returns The context_management object to include in the request, or undefined if no edits + */ +export function buildContextManagement( + config: ContextEditingConfig, + hasThinking: number | undefined, + modelMaxTokens: number +): ContextManagement | undefined { + const edits: ContextManagementEdit[] = []; + + // Add thinking block clearing if extended thinking is enabled + if (hasThinking) { + const thinkingKeepTurns = config.thinkingKeepTurns; + edits.push({ + type: 'clear_thinking_20251015', + keep: { type: 'thinking_turns', value: Math.max(1, thinkingKeepTurns) }, + }); + } + + // Add tool result clearing configuration + const { triggerType, triggerValue, keepCount, clearAtLeastTokens, excludeTools, clearInputs } = config; + + // Build trigger based on type - use configured values directly (defaults match Anthropic's recommendations) + const trigger: ContextManagementTrigger = { type: triggerType, value: triggerValue }; + + const toolEdit: ContextManagementEdit = { + type: 'clear_tool_uses_20250919', + trigger, + keep: { type: 'tool_uses', value: keepCount }, + ...(clearAtLeastTokens ? { clear_at_least: { type: 'input_tokens' as const, value: clearAtLeastTokens } } : {}), + ...(excludeTools.length > 0 ? { exclude_tools: excludeTools } : {}), + ...(clearInputs ? { clear_tool_inputs: clearInputs } : {}), + }; + edits.push(toolEdit); + + return edits.length > 0 ? { edits } : undefined; +} diff --git a/extensions/copilot/src/platform/networking/common/fetch.ts b/extensions/copilot/src/platform/networking/common/fetch.ts index 13f16a97505..c44ab1f1a5b 100644 --- a/extensions/copilot/src/platform/networking/common/fetch.ts +++ b/extensions/copilot/src/platform/networking/common/fetch.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { EncryptedThinkingDelta, ThinkingData, ThinkingDelta } from '../../thinking/common/thinking'; +import { AnthropicMessagesTool, ContextManagementResponse } from './anthropic'; import { Response } from './fetcherService'; import { ChoiceLogProbs, FilterReason } from './openai'; @@ -135,6 +136,8 @@ export interface IResponseDelta { retryReason?: FilterReason | 'network_error'; /** Marker for the current response, which should be presented in `IMakeChatRequestOptions` on the next call */ statefulMarker?: string; + /** Context management information from Anthropic Messages API */ + contextManagement?: ContextManagementResponse; } export const enum ResponsePartKind { @@ -268,16 +271,6 @@ export function isOpenAiFunctionTool(tool: OpenAiResponsesFunctionTool | OpenAiF return (tool as OpenAiFunctionTool).function !== undefined; } -export interface AnthropicMessagesTool { - name: string; - description?: string; - input_schema: { - type: 'object'; - properties?: Record; - required?: string[]; - }; -} - /** * Options for streaming response. Only set this when you set stream: true. * diff --git a/extensions/copilot/src/platform/networking/common/networking.ts b/extensions/copilot/src/platform/networking/common/networking.ts index ff19fde1362..734aeb975e6 100644 --- a/extensions/copilot/src/platform/networking/common/networking.ts +++ b/extensions/copilot/src/platform/networking/common/networking.ts @@ -17,7 +17,8 @@ import { CustomModel, EndpointEditToolName } from '../../endpoint/common/endpoin import { ILogService } from '../../log/common/logService'; import { ITelemetryService, TelemetryProperties } from '../../telemetry/common/telemetry'; import { TelemetryData } from '../../telemetry/common/telemetryData'; -import { AnthropicMessagesTool, FinishedCallback, OpenAiFunctionTool, OpenAiResponsesFunctionTool, OptionalChatRequestParams, Prediction } from './fetch'; +import { FinishedCallback, OpenAiFunctionTool, OpenAiResponsesFunctionTool, OptionalChatRequestParams, Prediction } from './fetch'; +import { AnthropicMessagesTool, ContextManagement } from './anthropic'; import { FetcherId, FetchOptions, IAbortController, IFetcherService, PaginationOptions, Response } from './fetcherService'; import { ChatCompletion, RawMessageConversionCallback, rawMessageToCAPI } from './openai'; @@ -111,6 +112,7 @@ export interface IEndpointBody { type: 'enabled' | 'disabled'; budget_tokens?: number; }; + context_management?: ContextManagement; /** ChatCompletions API for Anthropic models */ thinking_budget?: number;