Path: blob/main/extensions/copilot/src/extension/prompts/node/panel/toolCalling.tsx
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { RequestMetadata, RequestType } from '@vscode/copilot-api';6import { AssistantMessage, BasePromptElementProps, PromptRenderer as BasePromptRenderer, Chunk, IfEmpty, Image, JSONTree, PromptElement, PromptElementProps, PromptMetadata, PromptPiece, PromptSizing, TokenLimit, ToolCall, ToolMessage, useKeepWith, UserMessage } from '@vscode/prompt-tsx';7import type { ChatParticipantToolToken, LanguageModelToolInvocationOptions, LanguageModelToolResult2, LanguageModelToolTokenizationOptions } from 'vscode';8import { IAuthenticationService } from '../../../../platform/authentication/common/authentication';9import { IChatHookService, IPreToolUseHookResult } from '../../../../platform/chat/common/chatHookService';10import { ISessionTranscriptService } from '../../../../platform/chat/common/sessionTranscriptService';11import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';12import { modelCanUseMcpResultImageURL } from '../../../../platform/endpoint/common/chatModelCapabilities';13import { CompactionDataContainer } from '../../../../platform/endpoint/common/compactionDataContainer';14import { IEndpointProvider } from '../../../../platform/endpoint/common/endpointProvider';15import { CacheType } from '../../../../platform/endpoint/common/endpointTypes';16import { PhaseDataContainer } from '../../../../platform/endpoint/common/phaseDataContainer';17import { StatefulMarkerContainer } from '../../../../platform/endpoint/common/statefulMarkerContainer';18import { ThinkingDataContainer } from '../../../../platform/endpoint/common/thinkingDataContainer';19import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService';20import { IIgnoreService } from '../../../../platform/ignore/common/ignoreService';21import { IImageService } from '../../../../platform/image/common/imageService';22import { ILogService } from '../../../../platform/log/common/logService';23import { IOTelService } from '../../../../platform/otel/common/otelService';24import { IExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService';25import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry';26import { toErrorMessage } from '../../../../util/common/errorMessage';27import { ITokenizer } from '../../../../util/common/tokenizer';28import { CancellationToken } from '../../../../util/vs/base/common/cancellation';29import { isCancellationError } from '../../../../util/vs/base/common/errors';30import { getExtensionForMimeType } from '../../../../util/vs/base/common/mime';31import { URI, UriComponents } from '../../../../util/vs/base/common/uri';32import { IInstantiationService, ServicesAccessor } from '../../../../util/vs/platform/instantiation/common/instantiation';33import { ServiceCollection } from '../../../../util/vs/platform/instantiation/common/serviceCollection';34import { LanguageModelDataPart, LanguageModelDataPart2, LanguageModelPartAudience, LanguageModelPromptTsxPart, LanguageModelTextPart, LanguageModelTextPart2, LanguageModelToolMCPSource, LanguageModelToolResult } from '../../../../vscodeTypes';35import { isImageDataPart } from '../../../conversation/common/languageModelChatMessageHelpers';36import { IResultMetadata } from '../../../prompt/common/conversation';37import { IBuildPromptContext, IToolCall, IToolCallRound } from '../../../prompt/common/intents';38import { toJsonSchema } from '../../../tools/common/toJsonSchema';39import { ToolName } from '../../../tools/common/toolNames';40import { CopilotToolMode } from '../../../tools/common/toolsRegistry';41import { IToolsService } from '../../../tools/common/toolsService';42import { IChatDiskSessionResources } from '../../common/chatDiskSessionResources';43import { IPromptEndpoint } from '../base/promptRenderer';44import { Tag } from '../base/tag';4546export interface ChatToolCallsProps extends BasePromptElementProps {47readonly promptContext: IBuildPromptContext;48readonly toolCallRounds: readonly IToolCallRound[] | undefined;49readonly toolCallResults: Record<string, LanguageModelToolResult2> | undefined;50readonly isHistorical?: boolean;51readonly toolCallMode?: CopilotToolMode;52readonly enableCacheBreakpoints?: boolean;53readonly truncateAt?: number;54}5556const MAX_INPUT_VALIDATION_RETRIES = 5;5758/**59* Render one round of the assistant response's tool calls.60* One assistant response "turn" which contains multiple rounds of assistant message text, tool calls, and tool results.61*/62export class ChatToolCalls extends PromptElement<ChatToolCallsProps, void> {63constructor(64props: PromptElementProps<ChatToolCallsProps>,65@IToolsService private readonly toolsService: IToolsService,66@IPromptEndpoint private readonly promptEndpoint: IPromptEndpoint,67@IInstantiationService private readonly instantiationService: IInstantiationService68) {69super(props);70}7172override async render(state: void, sizing: PromptSizing, _progress?: unknown, token?: CancellationToken): Promise<PromptPiece<any, any> | undefined> {73if (!this.props.promptContext.tools || !this.props.toolCallRounds?.length) {74return;75}7677// Create a child instantiation service with IBuildPromptContext registered78const hydratedInstantiationService = this.instantiationService.createChild(79new ServiceCollection([IBuildPromptContext, this.props.promptContext])80);8182// Shared budget to limit total image data across all tool results in this turn.83// Prevents 413 errors when many image-returning tools run in parallel.84const sharedImageBudget: SharedImageBudget = { remaining: CAPI_IMAGE_BUDGET_BYTES };8586const toolCallRounds = this.props.toolCallRounds.flatMap((round, i) => {87return this.renderOneToolCallRound(round, i, this.props.toolCallRounds!.length, hydratedInstantiationService, sharedImageBudget, token);88});89if (!toolCallRounds.length) {90return;91}9293const KeepWith = useKeepWith();94return <>95<KeepWith priority={1} flexGrow={1}>96{toolCallRounds}97</KeepWith>98</>;99}100101/**102* Render one round of tool calling: the assistant message text, its tool calls, and the results of those tool calls.103*/104private renderOneToolCallRound(round: IToolCallRound, index: number, total: number, hydratedInstantiationService: IInstantiationService, sharedImageBudget: SharedImageBudget, token?: CancellationToken): PromptElement[] {105let fixedNameToolCalls = round.toolCalls.map(tc => ({ ...tc, name: this.toolsService.validateToolName(tc.name) ?? tc.name }));106if (this.props.isHistorical) {107fixedNameToolCalls = fixedNameToolCalls.filter(tc => tc.id && this.props.toolCallResults?.[tc.id]);108}109110if (round.toolCalls.length && !fixedNameToolCalls.length) {111return [];112}113114const assistantToolCalls: Required<ToolCall>[] = fixedNameToolCalls.map(tc => ({115type: 'function',116function: { name: tc.name, arguments: tc.arguments },117id: tc.id!,118keepWith: useKeepWith(),119}));120const children: PromptElement[] = [];121122// Don't include this when rendering and triggering summarization123const statefulMarker = round.statefulMarker && <StatefulMarkerContainer statefulMarker={{ modelId: this.promptEndpoint.model, marker: round.statefulMarker }} />;124const thinking = (!this.props.isHistorical) && round.thinking && <ThinkingDataContainer thinking={round.thinking} />;125const phase = (round.phase && round.phaseModelId === this.promptEndpoint.model) ? <PhaseDataContainer phase={round.phase} /> : undefined;126const compaction = round.compaction && <CompactionDataContainer compaction={round.compaction} />;127children.push(128<AssistantMessage toolCalls={assistantToolCalls}>129{statefulMarker}130{thinking}131{phase}132{compaction}133{round.response}134</AssistantMessage>);135136// Tool call elements should be rendered with the later elements first, allowed to grow to fill the available space137// Each tool 'reserves' 1/(N*4) of the available space just so that newer tool calls don't completely elimate138// older tool calls.139const reserve1N = (1 / (total * 4)) / fixedNameToolCalls.length;140// todo@connor4312: historical tool calls don't need to reserve and can all be flexed together141for (const [i, toolCall] of fixedNameToolCalls.entries()) {142const KeepWith = assistantToolCalls[i].keepWith;143children.push(144<KeepWith priority={index} flexGrow={index + 1} flexReserve={`/${1 / reserve1N}`}>145{hydratedInstantiationService.invokeFunction(buildToolResultElement, {146toolCall: toolCall,147toolInvocationToken: this.props.promptContext.tools!.toolInvocationToken,148toolCallResult: this.props.toolCallResults?.[toolCall.id!],149allowInvokingTool: !this.props.isHistorical,150validateInput: round.toolInputRetry < MAX_INPUT_VALIDATION_RETRIES,151requestId: this.props.promptContext.requestId,152toolCallMode: this.props.toolCallMode ?? CopilotToolMode.PartialContext,153isLast: !this.props.isHistorical && i === fixedNameToolCalls.length - 1 && index === total - 1,154enableCacheBreakpoints: this.props.enableCacheBreakpoints ?? false,155truncateAt: this.props.truncateAt,156sessionId: this.props.promptContext.request?.sessionId,157// Strip images from historical turns to avoid 413 errors158stripImages: !!this.props.isHistorical,159sharedImageBudget,160token: token ?? CancellationToken.None,161})}162</KeepWith>,163);164}165166// If a hook added context after this round, render it as a user message167if (round.hookContext) {168children.push(<UserMessage>{round.hookContext}</UserMessage>);169}170171return children;172}173}174175/**176* Half the CAPI body-size limit (5 MB), used to cap image data so the rest177* of the prompt still fits. Shared by both the per-tool and cross-tool budgets.178*/179const CAPI_IMAGE_BUDGET_BYTES = (5 * 1024 * 1024) / 2;180181/**182* Shared mutable counter that limits the total image data rendered across183* all tool results within a turn, preventing 413 (request too large) errors184* when many image-returning tools (e.g. view_image) run in parallel.185*/186interface SharedImageBudget {187remaining: number;188}189190interface ToolResultOpts {191readonly toolCall: IToolCall;192readonly toolInvocationToken: ChatParticipantToolToken | undefined;193readonly toolCallResult: LanguageModelToolResult2 | undefined;194readonly allowInvokingTool?: boolean;195readonly validateInput?: boolean;196readonly requestId?: string;197readonly toolCallMode: CopilotToolMode;198readonly isLast: boolean;199readonly enableCacheBreakpoints: boolean;200readonly truncateAt?: number;201readonly sessionId: string | undefined;202readonly stripImages?: boolean;203readonly sharedImageBudget?: SharedImageBudget;204readonly token: CancellationToken;205}206207const toolErrorSuffix = '\nPlease check your input and try again.';208209/**210* Creates a <ToolResult /> element. Eagerly starts the tool call if we know211* that the tool will not need/consume sizing information (e.g. MCP calls) and212* therefore don't need to wait for other elements to sequentially render.213*/214function buildToolResultElement(accessor: ServicesAccessor, props: ToolResultOpts) {215const toolsService: IToolsService = accessor.get(IToolsService);216const logService: ILogService = accessor.get(ILogService);217const telemetryService: ITelemetryService = accessor.get(ITelemetryService);218const endpointProvider: IEndpointProvider = accessor.get(IEndpointProvider);219const promptEndpoint: IPromptEndpoint = accessor.get(IPromptEndpoint);220const promptContext: IBuildPromptContext = accessor.get(IBuildPromptContext);221const sessionTranscriptService = accessor.get(ISessionTranscriptService);222const chatHookService = accessor.get(IChatHookService);223const otelService = accessor.get(IOTelService);224const tool = toolsService.getTool(props.toolCall.name);225226async function getToolResult(sizing: PromptSizing) {227const tokenizationOptions: LanguageModelToolTokenizationOptions = {228tokenBudget: sizing.tokenBudget,229countTokens: async (content: string) => sizing.countTokens(content),230};231232if (!props.toolCallResult && !props.allowInvokingTool) {233throw new Error(`Missing tool call result for "${props.toolCall.id}" (${props.toolCall.name})`);234}235236const extraMetadata: PromptMetadata[] = [];237let isCancelled = false;238let toolResult = props.toolCallResult;239const copilotTool = toolsService.getCopilotTool(props.toolCall.name as ToolName);240if (toolResult === undefined) {241let inputObj: unknown;242let validation: ToolValidationOutcome = ToolValidationOutcome.Unknown;243if (props.validateInput) {244const validationResult = toolsService.validateToolInput(props.toolCall.name, props.toolCall.arguments);245if ('error' in validationResult) {246validation = ToolValidationOutcome.Invalid;247extraMetadata.push(new ToolFailureEncountered(props.toolCall.id));248toolResult = textToolResult(validationResult.error + toolErrorSuffix);249} else {250validation = ToolValidationOutcome.Valid;251inputObj = validationResult.inputObj;252}253} else {254inputObj = JSON.parse(props.toolCall.arguments);255}256257let outcome: ToolInvocationOutcome = toolResult === undefined ? ToolInvocationOutcome.Success : ToolInvocationOutcome.InvalidInput;258if (toolResult === undefined) {259try {260if (promptContext.tools && !promptContext.tools.availableTools.find(t => t.name === props.toolCall.name)) {261outcome = ToolInvocationOutcome.DisabledByUser;262throw new Error(`Tool ${props.toolCall.name} is currently disabled by the user, and cannot be called.`);263}264265if (copilotTool?.resolveInput) {266inputObj = await copilotTool.resolveInput(inputObj, promptContext, props.toolCallMode);267}268269// Execute preToolUse hook before invoking the tool270const hookResult = await chatHookService.executePreToolUseHook(271props.toolCall.name, inputObj, props.toolCall.id,272promptContext.request?.hooks, promptContext.conversation?.sessionId,273props.token,274promptContext.stream275);276277// Apply updatedInput from hook (input modification takes effect before invocation)278if (hookResult?.updatedInput) {279inputObj = hookResult.updatedInput;280}281282const subAgentInvocationId = promptContext.request?.subAgentInvocationId;283// Capture the active trace context (from the invoke_agent span) so that284// the execute_tool span is properly parented even when async context285// propagation doesn't carry the active span.286const parentTraceContext = otelService.getActiveTraceContext();287const invocationOptions: LanguageModelToolInvocationOptions<unknown> = {288input: inputObj,289toolInvocationToken: props.toolInvocationToken,290tokenizationOptions,291chatRequestId: props.requestId,292subAgentInvocationId,293// Split on `__vscode` so it's the chat stream id294// TODO @lramos15 - This is a gross hack295chatStreamToolCallId: props.toolCall.id.split('__vscode')[0],296preToolUseResult: hookResult ? {297permissionDecision: hookResult.permissionDecision,298permissionDecisionReason: hookResult.permissionDecisionReason,299updatedInput: hookResult.updatedInput,300} : undefined,301};302// Attach trace context for span parenting (not in the VS Code API type)303(invocationOptions as { parentTraceContext?: { traceId: string; spanId: string } }).parentTraceContext = parentTraceContext;304305const transcriptSessionId = promptContext.conversation?.sessionId;306if (transcriptSessionId) {307let parsedArgs: unknown;308try { parsedArgs = JSON.parse(props.toolCall.arguments); } catch { parsedArgs = props.toolCall.arguments; }309sessionTranscriptService.logToolExecutionStart(transcriptSessionId, props.toolCall.id, props.toolCall.name, parsedArgs);310}311312toolResult = await toolsService.invokeToolWithEndpoint(props.toolCall.name, invocationOptions, promptEndpoint, props.token);313sendInvokedToolTelemetry(promptEndpoint.acquireTokenizer(), telemetryService, props.toolCall.name, toolResult);314315// Run hook context handling after tool execution316appendHookContext(toolResult, hookResult, chatHookService, props, inputObj, promptContext);317318if (transcriptSessionId) {319sessionTranscriptService.logToolExecutionComplete(transcriptSessionId, props.toolCall.id, true);320}321} catch (err) {322const errResult = toolCallErrorToResult(err);323toolResult = errResult.result;324isCancelled = errResult.isCancelled ?? false;325if (errResult.isCancelled) {326outcome = ToolInvocationOutcome.Cancelled;327} else {328outcome = outcome === ToolInvocationOutcome.DisabledByUser ? outcome : ToolInvocationOutcome.Error;329extraMetadata.push(new ToolFailureEncountered(props.toolCall.id));330logService.error(`Error from tool ${props.toolCall.name} with args ${props.toolCall.arguments}`, toErrorMessage(err, true));331}332if (promptContext.conversation?.sessionId) {333sessionTranscriptService.logToolExecutionComplete(promptContext.conversation.sessionId, props.toolCall.id, false);334}335}336}337338sendToolCallTelemetry(props, promptContext, outcome, validation, endpointProvider, telemetryService);339}340341return { toolResult, isCancelled, extraMetadata };342}343344let call: IToolResultElementActualProps['call'];345if (tool?.source instanceof LanguageModelToolMCPSource || tool?.name && toolsCalledInParallel.has(tool.name as ToolName)) {346const promise = getToolResult({ tokenBudget: 1, countTokens: () => 1, endpoint: { modelMaxPromptTokens: 1 } });347call = () => promise;348} else {349call = getToolResult;350}351352return <ToolResultElement353call={call}354enableCacheBreakpoints={props.enableCacheBreakpoints}355truncateAt={props.truncateAt}356toolCall={props.toolCall}357isLast={props.isLast}358sessionId={props.sessionId}359stripImages={props.stripImages}360sharedImageBudget={props.sharedImageBudget}361/>;362}363364const toolsCalledInParallel = new Set<ToolName>([365ToolName.CoreRunSubagent,366ToolName.ReadFile,367ToolName.FindFiles,368ToolName.FindTextInFiles,369ToolName.ListDirectory,370ToolName.Codebase,371ToolName.GetErrors,372ToolName.GetScmChanges,373ToolName.GetNotebookSummary,374ToolName.ReadCellOutput,375ToolName.InstallExtension,376ToolName.FetchWebPage,377]);378379async function sendToolCallTelemetry(props: ToolResultOpts, promptContext: IBuildPromptContext, invokeOutcome: ToolInvocationOutcome, validateOutcome: ToolValidationOutcome, endpointProvider: IEndpointProvider, telemetryService: ITelemetryService) {380const model = promptContext.request?.model && (await endpointProvider.getChatEndpoint(promptContext.request?.model)).model;381const toolName = props.toolCall.name;382383/* __GDPR__384"toolInvoke" : {385"owner": "donjayamanne",386"comment": "Details about invocation of tools",387"validateOutcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The outcome of the tool input validation. valid, invalid and unknown" },388"invokeOutcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The outcome of the tool Invokcation. invalidInput, disabledByUser, success, error, cancelled" },389"toolName": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The name of the tool being invoked." },390"model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model that invoked the tool" }391}392*/393telemetryService.sendMSFTTelemetryEvent('toolInvoke',394{395validateOutcome,396invokeOutcome,397toolName,398model399}400);401402if (toolName === ToolName.EditNotebook) {403sendNotebookEditToolValidationTelemetry(invokeOutcome, validateOutcome, props.toolCall.arguments, telemetryService, model);404}405}406407interface IToolResultElementActualProps {408call(sizing: PromptSizing): Promise<{409toolResult: LanguageModelToolResult2;410isCancelled: boolean;411extraMetadata: PromptMetadata[];412}>;413enableCacheBreakpoints: boolean;414truncateAt: number | undefined;415toolCall: IToolCall;416sessionId: string | undefined;417isLast: boolean;418stripImages?: boolean;419sharedImageBudget?: SharedImageBudget;420}421422function buildImageUri(sessionId: string | undefined, toolCallId: string | undefined, imageIndex: number | undefined, mimeType: string): string | undefined {423if (!sessionId || !toolCallId || imageIndex === undefined) {424return undefined;425}426const coreToolCallId = toolCallId.split('__vscode')[0];427return buildToolImageResourceUri(sessionId, coreToolCallId, imageIndex, getExtensionForMimeType(mimeType) ?? '.bin');428}429430/**431* Replaces image data parts with text placeholders in tool results.432* Used for historical turns to prevent large base64 image data from433* accumulating and causing 413 (request too large) errors from the API.434*/435function replaceImagesWithPlaceholders(436content: LanguageModelToolResult2['content'],437toolCallId: string | undefined,438sessionId: string | undefined,439): LanguageModelToolResult2['content'] {440if (!content.some(part => isImageDataPart(part))) {441return content;442}443return content.map((part, index) => {444if (!isImageDataPart(part)) {445return part;446}447const uri = buildImageUri(sessionId, toolCallId, index, part.mimeType);448const uriRef = uri ? ` Image URI: ${uri}` : '';449return new LanguageModelTextPart(`[Image was previously shown to you.${uriRef}]`);450});451}452453/**454* One tool call result, which either comes from the cache or from invoking the tool.455*/456class ToolResultElement extends PromptElement<IToolResultElementActualProps & BasePromptElementProps, void> {457async render(state: void, sizing: PromptSizing) {458const { extraMetadata, toolResult, isCancelled } = await this.props.call(sizing);459460// For historical turns, replace image data with text placeholders461// to avoid accumulating large base64 payloads across conversation turns (413 errors)462const content = this.props.stripImages463? replaceImagesWithPlaceholders(toolResult.content, this.props.toolCall.id, this.props.sessionId)464: toolResult.content;465466const toolResultElement = this.props.enableCacheBreakpoints ?467<>468<Chunk>469<ToolResult content={content} truncate={this.props.truncateAt} toolCallId={this.props.toolCall.id} sessionId={this.props.sessionId} toolName={this.props.toolCall.name} sharedImageBudget={this.props.sharedImageBudget} />470</Chunk>471</> :472<ToolResult content={content} truncate={this.props.truncateAt} toolCallId={this.props.toolCall.id} sessionId={this.props.sessionId} toolName={this.props.toolCall.name} sharedImageBudget={this.props.sharedImageBudget} />;473474return (475<ToolMessage toolCallId={this.props.toolCall.id!}>476<meta value={new ToolResultMetadata(this.props.toolCall.id!, toolResult, isCancelled)} />477{...extraMetadata.map(m => <meta value={m} />)}478{toolResultElement}479{this.props.isLast && this.props.enableCacheBreakpoints && <cacheBreakpoint type={CacheType} />}480</ToolMessage>481);482}483}484485export function sendInvokedToolTelemetry(tokenizer: ITokenizer, telemetry: ITelemetryService, toolName: string, toolResult: LanguageModelToolResult2) {486new BasePromptRenderer(487{ modelMaxPromptTokens: Infinity },488class extends PromptElement {489render() {490return <UserMessage><PrimitiveToolResult content={toolResult.content} /></UserMessage>;491}492},493{},494tokenizer,495).render().then(({ tokenCount }) => {496/* __GDPR__497"agent.tool.responseLength" : {498"owner": "connor4312",499"comment": "Counts the number of tokens generated by tools",500"toolName": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The name of the tool being invoked." },501"tokenCount": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Number of tokens used.", "isMeasurement": true }502}503*/504telemetry.sendMSFTTelemetryEvent('agent.tool.responseLength', { toolName }, { tokenCount });505});506}507508enum ToolValidationOutcome {509Valid = 'valid',510Invalid = 'invalid',511Unknown = 'unknown'512}513514enum ToolInvocationOutcome {515InvalidInput = 'invalidInput',516DisabledByUser = 'disabledByUser',517Success = 'success',518Error = 'error',519Cancelled = 'cancelled',520}521522export async function imageDataPartToTSX(part: LanguageModelDataPart, githubToken?: string, urlOrRequestMetadata?: string | RequestMetadata, logService?: ILogService, imageService?: IImageService) {523if (isImageDataPart(part)) {524let imageData: Uint8Array = part.data;525let mimeType = part.mimeType;526527if (imageService) {528try {529const resized = await imageService.resizeImage(imageData, mimeType);530imageData = resized.data;531mimeType = resized.mimeType;532} catch (error) {533logService?.warn(`Image resize failed, using original: ${error}`);534}535}536537const base64 = Buffer.from(imageData).toString('base64');538let imageSource = `data:${mimeType};base64,${base64}`;539const isChatRequest = typeof urlOrRequestMetadata !== 'string' && (540urlOrRequestMetadata?.type === RequestType.ChatCompletions ||541urlOrRequestMetadata?.type === RequestType.ChatResponses ||542urlOrRequestMetadata?.type === RequestType.ChatMessages);543if (githubToken && isChatRequest && imageService) {544try {545const uri = await imageService.uploadChatImageAttachment(imageData, 'tool-result-image', mimeType ?? 'image/png', githubToken);546if (uri) {547imageSource = uri.toString();548}549} catch (error) {550if (logService) {551logService.warn(`Image upload failed, using base64 fallback: ${error}`);552}553}554}555556return <Image src={imageSource} mimeType={mimeType} />;557}558}559560/**561* Appends hook context to a tool result after execution.562* Handles preToolUse additionalContext and executes the postToolUse hook,563* appending block messages and additionalContext as `<*-context>` tags.564*/565async function appendHookContext(566toolResult: LanguageModelToolResult2,567preHookResult: IPreToolUseHookResult | undefined,568chatHookService: IChatHookService,569props: ToolResultOpts,570toolInput: unknown,571promptContext: IBuildPromptContext,572): Promise<void> {573// Append additional context from preToolUse hook574if (preHookResult?.additionalContext) {575for (const context of preHookResult.additionalContext) {576toolResult.content.push(new LanguageModelTextPart('\n<PreToolUse-context>\n' + context + '\n</PreToolUse-context>'));577}578}579580// Skip postToolUse hook if preToolUse denied the tool — no tool actually ran581if (preHookResult?.permissionDecision === 'deny') {582return;583}584585// Execute postToolUse hook after successful tool execution586const postHookResult = await chatHookService.executePostToolUseHook(587props.toolCall.name,588toolInput,589toolResultToText(toolResult),590props.toolCall.id,591promptContext.request?.hooks,592promptContext.conversation?.sessionId,593props.token,594promptContext.stream595);596if (postHookResult?.decision === 'block') {597const blockReason = postHookResult.reason ?? 'Hook blocked tool result';598const blockMessage = `The PostToolUse hook blocked this tool result. Reason: ${blockReason}`;599toolResult.content.push(new LanguageModelTextPart('\n<PostToolUse-context>\n' + blockMessage + '\n</PostToolUse-context>'));600}601if (postHookResult?.additionalContext) {602for (const context of postHookResult.additionalContext) {603toolResult.content.push(new LanguageModelTextPart('\n<PostToolUse-context>\n' + context + '\n</PostToolUse-context>'));604}605}606}607608function toolResultToText(result: LanguageModelToolResult2): string {609return result.content610.filter((part): part is LanguageModelTextPart | LanguageModelTextPart2 =>611part instanceof LanguageModelTextPart || part instanceof LanguageModelTextPart2)612.map(part => part.value)613.join('\n');614}615616function textToolResult(text: string): LanguageModelToolResult {617return new LanguageModelToolResult([new LanguageModelTextPart(text)]);618}619620export function toolCallErrorToResult(err: unknown) {621if (isCancellationError(err)) {622return { result: textToolResult('The user cancelled the tool call.'), isCancelled: true };623} else {624const errorMessage = err instanceof Error ? err.message : String(err);625return { result: textToolResult(`ERROR while calling tool: ${errorMessage}${toolErrorSuffix}`) };626}627}628629export class ToolFailureEncountered extends PromptMetadata {630constructor(631public toolCallId: string632) {633super();634}635}636637export class ToolResultMetadata extends PromptMetadata {638constructor(639public readonly toolCallId: string,640public readonly result: LanguageModelToolResult2,641public isCancelled?: boolean642) {643super();644}645}646647// Some MCP servers return a ton of resources as a 'download' action.648// Only include them all eagerly if we have a manageable number.649const DONT_INCLUDE_RESOURCE_CONTENT_IF_TOOL_HAS_MORE_THAN = 9;650651class McpLinkedResourceToolResult extends PromptElement<{ resourceUri: URI; mimeType: string | undefined; count: number } & BasePromptElementProps> {652public static readonly mimeType = 'application/vnd.code.resource-link';653private static MAX_PREVIEW_LINES = 500;654655constructor(656props: { resourceUri: URI; mimeType: string | undefined; count: number } & BasePromptElementProps,657@IFileSystemService private readonly fileSystemService: IFileSystemService,658@IIgnoreService private readonly ignoreService: IIgnoreService,659) {660super(props);661}662663async render() {664if (await this.ignoreService.isCopilotIgnored(this.props.resourceUri)) {665return null;666}667668if (this.props.count > DONT_INCLUDE_RESOURCE_CONTENT_IF_TOOL_HAS_MORE_THAN) {669return <Tag name='resource' attrs={{ uri: this.props.resourceUri.toString() }} />;670}671672let contents: Uint8Array;673try {674contents = await this.fileSystemService.readFile(this.props.resourceUri);675} catch (e) {676const isNotFound = e instanceof Error && ('code' in e && (e.code === 'FileNotFound' || e.code === 'EntryNotFound'));677const message = isNotFound678? 'resource not found - the file may have been deleted or become inaccessible'679: `failed to read resource - ${toErrorMessage(e)}`;680return <Tag name='resource' attrs={{ uri: this.props.resourceUri.toString() }}>681{message}682</Tag>;683}684const lines = new TextDecoder().decode(contents).split(/\r?\n/g);685const maxLines = McpLinkedResourceToolResult.MAX_PREVIEW_LINES;686687return <>688<Tag name='resource' attrs={{ uri: this.props.resourceUri.toString(), isTruncated: lines.length > maxLines }}>689{lines.slice(0, maxLines).join('\n')}690</Tag>691</>;692}693}694695interface IPrimitiveToolResultProps extends BasePromptElementProps {696content: LanguageModelToolResult2['content'];697/**698* Shared budget limiting total image data across all tool results in a turn.699*/700sharedImageBudget?: SharedImageBudget;701}702703class PrimitiveToolResult<T extends IPrimitiveToolResultProps> extends PromptElement<T> {704protected readonly linkedResources: LanguageModelDataPart[];705706/**707* Some models do not yet support CAPI image uploads. For these cases,708* track the number of images bytes we're sending and truncate any images709* that would exceed that budget.710*/711private imageSizeBudgetLeft = CAPI_IMAGE_BUDGET_BYTES;712713constructor(714props: T,715@IPromptEndpoint protected readonly endpoint: IPromptEndpoint,716@IAuthenticationService private readonly authService: IAuthenticationService,717@ILogService private readonly logService?: ILogService,718@IImageService private readonly imageService?: IImageService,719@IConfigurationService private readonly configurationService?: IConfigurationService,720@IExperimentationService private readonly experimentationService?: IExperimentationService721) {722super(props);723this.linkedResources = this.props.content.filter((c): c is LanguageModelDataPart => c instanceof LanguageModelDataPart && c.mimeType === McpLinkedResourceToolResult.mimeType);724}725726async render(): Promise<PromptPiece | undefined> {727728return (729<>730<IfEmpty alt='(empty)'>731{await Promise.all(this.props.content.filter(part => this.hasAssistantAudience(part)).map(async part => {732if (part instanceof LanguageModelTextPart) {733return await this.onText(part.value);734} else if (part instanceof LanguageModelPromptTsxPart) {735return await this.onTSX(part.value as JSONTree.PromptElementJSON);736} else if (isImageDataPart(part)) {737return await this.onImage(part, this.props.content.indexOf(part));738} else if (part instanceof LanguageModelDataPart) {739return await this.onData(part);740}741}))}742{this.linkedResources.length > 0 && `\n\nHint: you can read the full contents of any ${this.linkedResources.length > DONT_INCLUDE_RESOURCE_CONTENT_IF_TOOL_HAS_MORE_THAN ? '' : 'truncated '}resources by passing their URIs as the absolutePath to the ${ToolName.ReadFile}.\n`}743</IfEmpty>744</>745);746}747748private hasAssistantAudience(part: LanguageModelTextPart2 | LanguageModelPromptTsxPart | LanguageModelDataPart2 | unknown): boolean {749if (part instanceof LanguageModelPromptTsxPart) {750return true;751}752if (!(part instanceof LanguageModelDataPart2 || part instanceof LanguageModelTextPart2) || !part.audience) {753return true;754}755return part.audience.includes(LanguageModelPartAudience.Assistant);756}757758protected async onData(part: LanguageModelDataPart) {759if (part.mimeType === McpLinkedResourceToolResult.mimeType) {760return this.onResourceLink(new TextDecoder().decode(part.data));761} else {762return '';763}764}765766protected async onImage(part: LanguageModelDataPart, _imageIndex?: number) {767if (!this.endpoint.supportsVision) {768return '[Image content is not available because vision is not supported by the current model or is disabled by your organization.]';769}770771const uploadsEnabled = this.configurationService && this.experimentationService772? this.configurationService.getExperimentBasedConfig(ConfigKey.EnableChatImageUpload, this.experimentationService)773: false;774775// Anthropic (from CAPI) currently does not support image uploads from tool calls.776const canUpload = uploadsEnabled && modelCanUseMcpResultImageURL(this.endpoint);777778// Enforce image budgets only when images will be inlined as base64.779// When uploads are available, the request body stays small (URL reference).780if (!canUpload) {781// Enforce shared cross-tool budget (prevents 413s when many tools return images)782const sharedBudget = this.props.sharedImageBudget;783if (sharedBudget) {784if (sharedBudget.remaining < 0) {785return this.sharedBudgetPlaceholder();786} else if (part.data.length > sharedBudget.remaining) {787sharedBudget.remaining = -1;788return this.sharedBudgetPlaceholder();789}790sharedBudget.remaining -= part.data.length;791}792793// Enforce per-tool budget794if (this.imageSizeBudgetLeft < 0) {795return ''; // already exceeded and messages about it796} else if (part.data.length > this.imageSizeBudgetLeft) {797this.imageSizeBudgetLeft = -1; // just now exceeding798return 'Additional images are available, but there is no more space in the context. Try requesting a smaller amount of data, if possible.';799} else {800this.imageSizeBudgetLeft -= part.data.length; // bookkeep801}802}803804// Only call getGitHubSession when uploads are potentially available805let uploadToken: string | undefined;806if (canUpload) {807uploadToken = (await this.authService.getGitHubSession('any', { silent: true }))?.accessToken;808}809810return Promise.resolve(imageDataPartToTSX(part, uploadToken, this.endpoint.urlOrRequestMetadata, this.logService, this.imageService));811}812813protected onTSX(part: JSONTree.PromptElementJSON) {814return Promise.resolve(<elementJSON data={part} />);815}816817protected onText(part: string) {818return Promise.resolve(part);819}820821protected onResourceLink(data: string) {822return '';823}824825protected sharedBudgetPlaceholder(): string {826return '[Image omitted — context image budget exceeded. Try viewing fewer images at once.]';827}828}829830export interface IToolResultProps extends IPrimitiveToolResultProps {831/**832* Number of tokens at which truncation will be triggered for string content.833*/834truncate?: number;835/**836* The tool call associated with this result.837*/838toolCallId: string | undefined;839/**840* The session ID associated with this result.841*/842sessionId?: string;843/**844* The name of the tool that produced this result.845*/846toolName?: string;847}848849850/**851* Inlined from prompt-tsx. In prompt-tsx it does `require('vscode)` for the instanceof checks which breaks in vitest852* and unfortunately I can't figure out how to work around that with the tools we have!853*/854export class ToolResult extends PrimitiveToolResult<IToolResultProps> {855constructor(856props: IToolResultProps,857@IPromptEndpoint endpoint: IPromptEndpoint,858@IAuthenticationService authService: IAuthenticationService,859@ILogService private readonly _logService: ILogService,860@IImageService imageService: IImageService,861@IConfigurationService private readonly _configurationService: IConfigurationService,862@IExperimentationService private readonly _experimentationService: IExperimentationService,863@IChatDiskSessionResources private readonly diskSessionResources: IChatDiskSessionResources,864) {865super(props, endpoint, authService, _logService, imageService, _configurationService, _experimentationService);866}867868protected override async onTSX(part: JSONTree.PromptElementJSON): Promise<any> {869if (this.props.truncate) {870return <TokenLimit max={this.props.truncate}>{await super.onTSX(part)}</TokenLimit>;871}872873return super.onTSX(part);874}875876protected override async onImage(part: LanguageModelDataPart, imageIndex?: number): Promise<PromptPiece | undefined> {877const image = await super.onImage(part, imageIndex);878if (!image || imageIndex === undefined || !this.props.toolCallId || !this.props.sessionId) {879return image;880}881const uri = buildImageUri(this.props.sessionId, this.props.toolCallId, imageIndex, part.mimeType);882return <>{image}{uri && `\n[Image URI: ${uri}]`}</>;883}884885protected override sharedBudgetPlaceholder(): string {886return '[Image omitted — context image budget exceeded. Try viewing fewer images at once or reference this image by URI.]';887}888889protected override async onText(content: string): Promise<string> {890const isDiskCachingEnabled = this._configurationService.getExperimentBasedConfig(891ConfigKey.Advanced.LargeToolResultsToDiskEnabled,892this._experimentationService893);894// Exempt the search and execution subagents and memory tool from disk caching as their results are often ignored if not written directly to the conversation895if (isDiskCachingEnabled && this.diskSessionResources && this.props.toolCallId && this.props.sessionId && this.props.toolName !== ToolName.SearchSubagent && this.props.toolName !== ToolName.ExecutionSubagent && this.props.toolName !== ToolName.Memory) {896const thresholdBytes = this._configurationService.getExperimentBasedConfig(897ConfigKey.Advanced.LargeToolResultsToDiskThreshold,898this._experimentationService899);900901if (content.length > thresholdBytes) {902try {903const sessionId = this.props.sessionId ?? 'unknown';904const toolCallId = this.props.toolCallId;905906let contentFile = 'content.txt';907let schema: string | undefined;908try {909const parsed = JSON.parse(content);910schema = JSON.stringify(toJsonSchema(parsed));911// re-stringify it as it's more friendly to line-based offsets in the read_file tool912content = JSON.stringify(parsed, null, 2);913contentFile = 'content.json';914} catch {915// ignored916}917918const fileUri = await this.diskSessionResources.ensure(919sessionId,920toolCallId,921{ [contentFile]: content, 'schema.json': schema },922);923924const filePath = fileUri.fsPath;925const contentFileUri = URI.joinPath(fileUri, contentFile);926const schemaFileUri = schema ? URI.joinPath(fileUri, 'schema.json') : undefined;927this._logService?.debug(`[ToolResult] Large tool result (${content.length} bytes) written to disk: ${filePath}`);928929return `Large tool result (${Math.round(content.length / 1024)}KB) written to file. Use the ${ToolName.ReadFile} tool to access the content at: ${contentFileUri.fsPath}${schemaFileUri ? `\n\nData schema found at: ${schemaFileUri.fsPath}` : ''}`;930} catch (err) {931this._logService?.warn(`[ToolResult] Failed to write large tool result to disk: ${toErrorMessage(err)}`);932// Fall through to normal truncation933}934}935}936937// Standard truncation logic938const truncateAtTokens = this.props.truncate;939if (!truncateAtTokens || content.length < truncateAtTokens) { // always >=1 character per token, early bail-out940return content;941}942943const tokens = await this.endpoint.acquireTokenizer().tokenLength(content);944if (tokens < truncateAtTokens) {945return content;946}947948const approxCharsPerToken = content.length / tokens;949const removedMessage = '\n[Tool response was too long and was truncated.]\n';950const targetChars = Math.round(approxCharsPerToken * (truncateAtTokens - removedMessage.length));951952const keepInFirstHalf = Math.round(targetChars * 0.4);953const keepInSecondHalf = targetChars - keepInFirstHalf;954955return content.slice(0, keepInFirstHalf) + removedMessage + content.slice(-keepInSecondHalf);956}957958protected override onResourceLink(data: string) {959// https://github.com/microsoft/vscode/blob/34e38b4a78a751d006b99acee1a95d76117fec7b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts#L846960let parsed: {961uri: UriComponents;962underlyingMimeType?: string;963};964965try {966parsed = JSON.parse(data);967} catch {968return null;969}970971return <McpLinkedResourceToolResult resourceUri={URI.revive(parsed.uri)} mimeType={parsed.underlyingMimeType} count={this.linkedResources.length} />;972}973}974975export interface IToolCallResultWrapperProps extends BasePromptElementProps {976toolCallResults: IResultMetadata['toolCallResults'];977}978979// Wrapper around ToolResult to allow rendering prompts980export class ToolCallResultWrapper extends PromptElement<IToolCallResultWrapperProps> {981async render(): Promise<PromptPiece | undefined> {982return (983<>984{Object.entries(this.props.toolCallResults ?? {}).map(([toolCallId, toolCallResult]) => (985<ToolMessage toolCallId={toolCallId}>986<ToolResult content={toolCallResult.content} toolCallId={undefined} />987</ToolMessage>988))}989</>990);991}992}993994function sendNotebookEditToolValidationTelemetry(invokeOutcome: ToolInvocationOutcome, validationResult: ToolValidationOutcome, toolArgs: string, telemetryService: ITelemetryService, model?: string): void {995let editType: 'insert' | 'delete' | 'edit' | 'unknown' = 'unknown';996let explanation: 'provided' | 'empty' | 'unknown' = 'unknown';997let newCodeType: 'string' | 'string[]' | 'object' | 'object[]' | 'unknown' | '' = 'unknown';998let cellId: 'TOP' | 'BOTTOM' | 'cellid' | 'unknown' | 'empty' = 'unknown';999let inputParsed = 0;1000const knownProps = ['editType', 'explanation', 'newCode', 'cellId', 'filePath', 'language'];1001let missingProps: string[] = [];1002let unknownProps: string[] = [];1003try {1004const args = JSON.parse(toolArgs);1005if (args && typeof args === 'object' && !Array.isArray(args) && Object.keys(args).length > 0) {1006const props = Object.keys(args);1007unknownProps = props.filter(key => !knownProps.includes(key));1008unknownProps.sort();1009missingProps = knownProps.filter(key => !props.includes(key));1010missingProps.sort();1011}1012inputParsed = 1;1013if (args.editType) {1014editType = args.editType;1015}1016if (args.explanation) {1017explanation = 'provided';1018} else {1019explanation = 'empty';1020}1021if (args.newCode || typeof args.newCode === 'string') {1022if (typeof args.newCode === 'string') {1023newCodeType = 'string';1024} else if (Array.isArray(args.newCode) && (args.newCode as any[]).every(item => typeof item === 'string')) {1025newCodeType = 'string[]';1026} else if (Array.isArray(args.newCode)) {1027newCodeType = 'object[]';1028} else if (typeof args.newCode === 'object') {1029newCodeType = 'object';1030}1031}1032if (editType === 'delete') {1033newCodeType = '';1034}1035const cellIdValue = args.cellId;1036if (typeof cellIdValue === 'string') {1037if (cellIdValue === 'TOP' || cellIdValue === 'BOTTOM') {1038cellId = cellIdValue;1039} else {1040cellId = cellIdValue.trim().length === 0 ? 'cellid' : 'empty';1041}1042}1043} catch {1044//1045}10461047/* __GDPR__1048"editNotebook.validation" : {1049"owner": "donjayamanne",1050"comment": "Validation failure for a Edit Notebook tool invocation",1051"validationResult": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The result of the tool input validation. valid, invalid and unknown" },1052"invokeOutcome": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The result of the tool Invocation. invalidInput, disabledByUser, success, error, cancelled" },1053"editType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The type of edit that was attempted. insert, delete, edit or unknown" },1054"unknownProps": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "List of unknown properties in the input" },1055"missingProps": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "List of missing properties in the input" },1056"newCodeType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The type of code, whether its string, string[], object, object[] or unknown" },1057"cellId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The id of the cell, TOP, BOTTOM, cellid, empty or unknown" },1058"explanation": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The explanation for the edit. provided, empty and unknown" },1059"inputParsed": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Whether the input was parsed as JSON" },1060"model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model that invoked the tool" }1061}1062*/10631064telemetryService.sendMSFTTelemetryEvent('editNotebook.validation',1065{1066validationResult,1067invokeOutcome,1068editType,1069newCodeType,1070cellId,1071explanation,1072model,1073unknownProps: unknownProps.join(','),1074missingProps: missingProps.join(','),1075},1076{1077inputParsed,1078}1079);1080}10811082export function buildToolImageResourceUri(sessionId: string, coreToolCallId: string, imageIndex: number, ext: string): string {1083const sessionResource = `vscode-chat-session://local/${Buffer.from(sessionId).toString('base64url')}`;1084const authority = Buffer.from(sessionResource).toString('hex');1085return `vscode-chat-response-resource://${authority}/tool/${coreToolCallId}/${imageIndex}/file${ext}`;1086}108710881089