Path: blob/main/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts
13399 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 * as l10n from '@vscode/l10n';6import { Raw } from '@vscode/prompt-tsx';7import type { CancellationToken, ChatRequest, ChatResponseProgressPart, ChatResponseReferencePart, ChatResponseStream, ChatResult, LanguageModelToolInformation, Progress } from 'vscode';8import { IAuthenticationChatUpgradeService } from '../../../platform/authentication/common/authenticationUpgrade';9import { IChatDebugFileLoggerService } from '../../../platform/chat/common/chatDebugFileLoggerService';10import { IChatHookService, SessionStartHookInput, SessionStartHookOutput, StopHookInput, StopHookOutput, SubagentStartHookInput, SubagentStartHookOutput, SubagentStopHookInput, SubagentStopHookOutput } from '../../../platform/chat/common/chatHookService';11import { FetchStreamSource, IResponsePart } from '../../../platform/chat/common/chatMLFetcher';12import { CanceledResult, ChatFetchResponseType, ChatResponse } from '../../../platform/chat/common/commonTypes';13import { IHistoricalTurn, ISessionTranscriptService, ToolRequest } from '../../../platform/chat/common/sessionTranscriptService';14import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';15import { isAnthropicFamily, isGeminiFamily } from '../../../platform/endpoint/common/chatModelCapabilities';16import { IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider';17import { rawPartAsThinkingData } from '../../../platform/endpoint/common/thinkingDataContainer';18import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';19import { IGitService } from '../../../platform/git/common/gitService';20import { ILogService } from '../../../platform/log/common/logService';21import { isOpenAIContextManagementResponse, OpenAiFunctionDef } from '../../../platform/networking/common/fetch';22import { IMakeChatRequestOptions } from '../../../platform/networking/common/networking';23import { OpenAIContextManagementResponse } from '../../../platform/networking/common/openai';24import { CopilotChatAttr, emitAgentTurnEvent, emitSessionStartEvent, GenAiAttr, GenAiMetrics, GenAiOperationName, GenAiProviderName, resolveWorkspaceOTelMetadata, StdAttr, truncateForOTel, workspaceMetadataToOTelAttributes } from '../../../platform/otel/common/index';25import { IOTelService, ISpanHandle, SpanKind, SpanStatusCode } from '../../../platform/otel/common/otelService';26import { IRequestLogger } from '../../../platform/requestLogger/common/requestLogger';27import { getCurrentCapturingToken } from '../../../platform/requestLogger/node/requestLogger';28import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService';29import { ITelemetryService } from '../../../platform/telemetry/common/telemetry';30import { computePromptTokenDetails } from '../../../platform/tokenizer/node/promptTokenDetails';31import { tryFinalizeResponseStream } from '../../../util/common/chatResponseStreamImpl';32import { ChatExtPerfMark, markChatExt } from '../../../util/common/performance';33import { DeferredPromise, timeout } from '../../../util/vs/base/common/async';34import { CancellationError, isCancellationError } from '../../../util/vs/base/common/errors';35import { Emitter } from '../../../util/vs/base/common/event';36import { Disposable } from '../../../util/vs/base/common/lifecycle';37import { Mutable } from '../../../util/vs/base/common/types';38import { URI } from '../../../util/vs/base/common/uri';39import { generateUuid } from '../../../util/vs/base/common/uuid';40import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';41import { ChatResponsePullRequestPart, LanguageModelDataPart2, LanguageModelPartAudience, LanguageModelToolResult2, MarkdownString } from '../../../vscodeTypes';42import { InteractionOutcomeComputer } from '../../inlineChat/node/promptCraftingTypes';43import { ChatVariablesCollection } from '../../prompt/common/chatVariablesCollection';44import { AnthropicTokenUsageMetadata, Conversation, IResultMetadata, ResponseStreamParticipant, TurnStatus } from '../../prompt/common/conversation';45import { IBuildPromptContext, InternalToolReference, IToolCall, IToolCallRound } from '../../prompt/common/intents';46import { cancelText, IToolCallIterationIncrease } from '../../prompt/common/specialRequestTypes';47import { ThinkingDataItem, ToolCallRound } from '../../prompt/common/toolCallRound';48import { IBuildPromptResult, IResponseProcessor } from '../../prompt/node/intents';49import { PseudoStopStartResponseProcessor } from '../../prompt/node/pseudoStartStopConversationCallback';50import { ResponseProcessorContext } from '../../prompt/node/responseProcessorContext';51import { SummarizedConversationHistoryMetadata } from '../../prompts/node/agent/summarizedConversationHistory';52import { ToolFailureEncountered, ToolResultMetadata } from '../../prompts/node/panel/toolCalling';53import { ToolName } from '../../tools/common/toolNames';54import { IToolsService, ToolCallCancelledError } from '../../tools/common/toolsService';55import { ReadFileParams } from '../../tools/node/readFileTool';56import { isHookAbortError, processHookResults } from './hookResultProcessor';57import { applyConfiguredPromptOverrides } from './promptOverride';5859export const enum ToolCallLimitBehavior {60Confirm,61Stop,62}6364export interface IToolCallingLoopOptions {65conversation: Conversation;66toolCallLimit: number;67/**68* What to do when the limit is hit. Defaults to {@link ToolCallLimitBehavior.Stop}.69* If set to confirm you can use {@link isToolCallLimitCancellation} and70* {@link isToolCallIterationIncrease} to get followup data.71*/72onHitToolCallLimit?: ToolCallLimitBehavior;73/**74* "mixins" that can be used to wrap the response stream.75*/76streamParticipants?: ResponseStreamParticipant[];77/**78* Optional custom response stream processor.79*/80responseProcessor?: IResponseProcessor;81/** Context for the {@link InteractionOutcomeComputer} */82interactionContext?: URI;83/**84* The current chat request85*/86request: ChatRequest;87/**88* A getter that returns true if VS Code has requested the extension to89* gracefully yield. When set, it's likely that the editor will immediately90* follow up with a new request in the same conversation.91*/92yieldRequested?: () => boolean;93}9495export interface IToolCallingResponseEvent {96response: ChatResponse;97interactionOutcome: InteractionOutcomeComputer;98toolCalls: IToolCall[];99}100101export interface IToolCallingBuiltPromptEvent {102result: IBuildPromptResult;103tools: LanguageModelToolInformation[];104}105106export type ToolCallingLoopFetchOptions = Required<Pick<IMakeChatRequestOptions, 'messages' | 'finishedCb' | 'requestOptions' | 'userInitiatedRequest' | 'turnId'>> & Pick<IMakeChatRequestOptions, 'modelCapabilities' | 'summarizedAtRoundId'>;107108interface StartHookResult {109/**110* Additional context to add to the agent's context, if any.111*/112readonly additionalContext?: string;113}114115interface StopHookResult {116/**117* Whether the agent should continue (not stop).118*/119readonly shouldContinue: boolean;120/**121* The reasons the agent should continue, if shouldContinue is true.122* Multiple hooks may block with different reasons.123*/124readonly reasons?: readonly string[];125}126127interface SubagentStartHookResult {128/**129* Additional context to add to the subagent's context, if any.130*/131readonly additionalContext?: string;132}133134interface SubagentStopHookResult {135/**136* Whether the subagent should continue (not stop).137*/138readonly shouldContinue: boolean;139/**140* The reasons the subagent should continue, if shouldContinue is true.141* Multiple hooks may block with different reasons.142*/143readonly reasons?: readonly string[];144}145146/**147* Formats a hook context message from blocking reasons.148* @param reasons The reasons hooks blocked the agent from stopping149* @returns A formatted message for the model to address the requirements150*/151function formatHookContext(reasons: readonly string[]): string {152if (reasons.length === 1) {153return `You were about to complete but a hook blocked you with the following message: "${reasons[0]}". Please address this requirement before completing.`;154}155const formattedReasons = reasons.map((reason, i) => `${i + 1}. ${reason}`).join('\n');156return `You were about to complete but multiple hooks blocked you with the following messages:\n${formattedReasons}\n\nPlease address all of these requirements before completing.`;157}158159/**160* This is a base class that can be used to implement a tool calling loop161* against a model. It requires only that you build a prompt and is decoupled162* from intents (i.e. the {@link DefaultIntentRequestHandler}), allowing easier163* programmatic use.164*/165export abstract class ToolCallingLoop<TOptions extends IToolCallingLoopOptions = IToolCallingLoopOptions> extends Disposable {166private static NextToolCallId = Date.now();167168private static readonly TASK_COMPLETE_TOOL_NAME = 'task_complete';169170private toolCallResults: Record<string, LanguageModelToolResult2> = Object.create(null);171private toolCallRounds: IToolCallRound[] = [];172private stopHookReason: string | undefined;173private additionalHookContext: string | undefined;174private stopHookUserInitiated = false;175private agentSpan: ISpanHandle | undefined;176private chatSessionIdForTools: string | undefined;177private toolsAvailableEmitted = false;178private lastHeaderRequestId: string | undefined;179180public appendAdditionalHookContext(context: string): void {181if (!context) {182return;183}184this.additionalHookContext = this.additionalHookContext185? `${this.additionalHookContext}\n${context}`186: context;187}188189private readonly _onDidBuildPrompt = this._register(new Emitter<{ result: IBuildPromptResult; tools: LanguageModelToolInformation[]; promptTokenLength: number; toolTokenCount: number }>());190public readonly onDidBuildPrompt = this._onDidBuildPrompt.event;191192private readonly _onDidReceiveResponse = this._register(new Emitter<IToolCallingResponseEvent>());193public readonly onDidReceiveResponse = this._onDidReceiveResponse.event;194195protected get currentToolCallRounds(): readonly IToolCallRound[] {196return this.toolCallRounds;197}198199private get turn() {200return this.options.conversation.getLatestTurn();201}202203protected get agentName(): string | undefined {204return (this.options.request as { subAgentName?: string }).subAgentName205?? (this.options.request as { participant?: string }).participant;206}207208constructor(209protected readonly options: TOptions,210@IInstantiationService private readonly _instantiationService: IInstantiationService,211@IEndpointProvider private readonly _endpointProvider: IEndpointProvider,212@ILogService protected readonly _logService: ILogService,213@IRequestLogger private readonly _requestLogger: IRequestLogger,214@IAuthenticationChatUpgradeService private readonly _authenticationChatUpgradeService: IAuthenticationChatUpgradeService,215@ITelemetryService protected readonly _telemetryService: ITelemetryService,216@IConfigurationService protected readonly _configurationService: IConfigurationService,217@IExperimentationService protected readonly _experimentationService: IExperimentationService,218@IChatHookService private readonly _chatHookService: IChatHookService,219@ISessionTranscriptService protected readonly _sessionTranscriptService: ISessionTranscriptService,220@IFileSystemService private readonly _fileSystemService: IFileSystemService,221@IOTelService protected readonly _otelService: IOTelService,222@IGitService private readonly _gitService: IGitService,223) {224super();225}226227/** Builds a prompt with the context. */228protected abstract buildPrompt(buildPromptContext: IBuildPromptContext, progress: Progress<ChatResponseReferencePart | ChatResponseProgressPart>, token: CancellationToken): Promise<IBuildPromptResult>;229230/** Gets the tools that should be callable by the model. */231protected abstract getAvailableTools(outputStream: ChatResponseStream | undefined, token: CancellationToken): Promise<LanguageModelToolInformation[]>;232233/** Creates the prompt context for the request. */234protected createPromptContext(availableTools: LanguageModelToolInformation[], outputStream: ChatResponseStream | undefined): Mutable<IBuildPromptContext> {235const { request } = this.options;236const chatVariables = new ChatVariablesCollection(request.references);237238const isContinuation = this.turn.isContinuation || !!this.stopHookReason;239let query: string;240let hasStopHookQuery = false;241if (this.stopHookReason) {242// Include the stop hook reason as a user message so the model knows what to do.243// Wrap with context so the model understands it needs to take action.244query = formatHookContext([this.stopHookReason]);245this._logService.info(`[ToolCallingLoop] Using stop hook reason as query: ${query}`);246this.stopHookReason = undefined; // Clear after use247hasStopHookQuery = true;248} else if (isContinuation) {249query = 'Please continue';250} else {251query = this.turn.request.message;252}253// exclude turns from the history that errored due to prompt filtration254const history = this.options.conversation.turns.slice(0, -1).filter(turn => turn.responseStatus !== TurnStatus.PromptFiltered);255256return {257requestId: this.turn.id,258query,259history,260toolCallResults: this.toolCallResults,261toolCallRounds: this.toolCallRounds,262editedFileEvents: this.options.request.editedFileEvents,263request: this.options.request,264stream: outputStream,265conversation: this.options.conversation,266chatVariables,267tools: {268toolReferences: request.toolReferences.map(InternalToolReference.from),269toolInvocationToken: request.toolInvocationToken,270availableTools271},272isContinuation,273hasStopHookQuery,274modeInstructions: this.options.request.modeInstructions2,275additionalHookContext: this.additionalHookContext,276parentHeaderRequestId: this.lastHeaderRequestId,277};278}279280protected abstract fetch(281options: ToolCallingLoopFetchOptions,282token: CancellationToken283): Promise<ChatResponse>;284285/**286* The context window widget in chat input should represent only the parent request.287* Subagent usage must stay isolated to avoid inflating the parent widget.288*/289private shouldReportUsageToContextWidget(): boolean {290return !this.options.request.subAgentInvocationId;291}292293/**294* Called before the loop stops to give hooks a chance to block the stop.295* @param input The stop hook input containing stop_hook_active flag296* @param outputStream The output stream for displaying messages297* @param token Cancellation token298* @returns Result indicating whether to continue and the reasons299*/300protected async executeStopHook(input: StopHookInput, sessionId: string, outputStream: ChatResponseStream | undefined, token: CancellationToken): Promise<StopHookResult> {301try {302const results = await this._chatHookService.executeHook('Stop', this.options.request.hooks, input, sessionId, token);303304const blockingReasons = new Set<string>();305processHookResults({306hookType: 'Stop',307results,308outputStream,309logService: this._logService,310onSuccess: (output) => {311if (typeof output === 'object' && output !== null) {312const hookOutput = output as StopHookOutput;313const specific = hookOutput.hookSpecificOutput;314this._logService.trace(`[ToolCallingLoop] Checking hook output: decision=${specific?.decision}, reason=${specific?.reason}`);315if (specific?.decision === 'block' && specific.reason) {316this._logService.trace(`[ToolCallingLoop] Stop hook blocked: ${specific.reason}`);317blockingReasons.add(specific.reason);318}319}320},321// Collect errors as blocking reasons (stderr from exit code != 0)322onError: (errorMessage) => {323if (errorMessage) {324this._logService.trace(`[ToolCallingLoop] Stop hook error collected as blocking reason: ${errorMessage}`);325blockingReasons.add(errorMessage);326}327},328});329330if (blockingReasons.size > 0) {331return { shouldContinue: true, reasons: [...blockingReasons] };332}333return { shouldContinue: false };334} catch (error) {335if (isHookAbortError(error)) {336throw error;337}338this._logService.error('[ToolCallingLoop] Error executing Stop hook', error);339return { shouldContinue: false };340}341}342343/**344* Shows a message when the stop hook blocks the agent from stopping.345* Override in subclasses to customize the display.346* @param outputStream The output stream for displaying messages347* @param reasons The reasons the stop hook blocked stopping348*/349protected showStopHookBlockedMessage(outputStream: ChatResponseStream | undefined, reasons: readonly string[]): void {350if (outputStream) {351if (reasons.length === 1) {352outputStream.hookProgress('Stop', reasons[0]);353} else {354const formattedReasons = reasons.map((r, i) => `${i + 1}. ${r}`).join('\n');355outputStream.hookProgress('Stop', formattedReasons);356}357}358this._logService.trace(`[ToolCallingLoop] Stop hook blocked stopping: ${reasons.join('; ')}`);359}360361private static readonly MAX_AUTOPILOT_RETRIES = 3;362private static readonly MAX_AUTOPILOT_ITERATIONS = 5;363private autopilotRetryCount = 0;364private autopilotIterationCount = 0;365366private taskCompleted = false;367private autopilotStopHookActive = false;368private autopilotProgressDeferred: DeferredPromise<void> | undefined;369370/**371* Autopilot stop hook — the model needs to call `task_complete` to signal it's done.372* If it stops without calling it, we nudge it to keep going. Returns a continuation373* message or `undefined` to let the loop stop.374*/375protected shouldAutopilotContinue(result: IToolCallSingleResult): string | undefined {376if (this.taskCompleted) {377this._logService.info('[ToolCallingLoop] Autopilot: task_complete was called, stopping');378return undefined;379}380381// might have called task_complete alongside other tools in an earlier round382const calledTaskComplete = this.toolCallRounds.some(383round => round.toolCalls.some(tc => tc.name === ToolCallingLoop.TASK_COMPLETE_TOOL_NAME)384);385if (calledTaskComplete) {386this.taskCompleted = true;387this._logService.info('[ToolCallingLoop] Autopilot: task_complete found in history, stopping');388return undefined;389}390391// If the model produced a substantive text response with no tool calls, treat it392// as a final summary and let the loop stop. Nudging in this case typically just393// wastes a turn — the model considers itself done. The user can always continue394// the conversation if it wasn't.395if (result.round.toolCalls.length === 0 && result.round.response.trim().length > 0) {396this._logService.info('[ToolCallingLoop] Autopilot: model produced a text-only response, treating as done');397return undefined;398}399400// safety valve — only give up after exhausting all continuation attempts401if (this.autopilotIterationCount >= ToolCallingLoop.MAX_AUTOPILOT_ITERATIONS) {402this._logService.info(`[ToolCallingLoop] Autopilot: hit max iterations (${ToolCallingLoop.MAX_AUTOPILOT_ITERATIONS}), letting it stop`);403return undefined;404}405406// If we already nudged once and the model still produced no tool calls, the model407// is effectively done — further nudges just waste tokens. Bail out and let the408// loop stop.409if (this.autopilotStopHookActive && result.round.toolCalls.length === 0) {410this._logService.info('[ToolCallingLoop] Autopilot: prior nudge produced no tool calls, stopping to avoid wasted requests');411return undefined;412}413414this.autopilotIterationCount++;415return 'You have not yet marked the task as complete using the task_complete tool. ' +416'You must call task_complete when done — whether the task involved code changes, answering a question, or any other interaction.\n\n' +417'Do NOT repeat or restate your previous response. Pick up where you left off.\n\n' +418'If you were planning, stop planning and start implementing. ' +419'You are not done until you have fully completed the task.\n\n' +420'IMPORTANT: Do NOT call task_complete if:\n' +421'- You have open questions or ambiguities — make good decisions and keep working\n' +422'- You encountered an error — try to resolve it or find an alternative approach\n' +423'- There are remaining steps — complete them first\n\n' +424'When you ARE done, first provide a brief text summary of what was accomplished, then call task_complete. ' +425'Both the summary message and the tool call are required.\n\n' +426'Keep working autonomously until the task is truly finished, then call task_complete.';427}428429/**430* Shows a progress spinner in the chat stream while autopilot continues.431* The spinner resolves to the past-tense message when {@link resolveAutopilotProgress} is called.432*/433private showAutopilotProgress(outputStream: ChatResponseStream | undefined, message: string, pastTenseMessage: string): void {434this.resolveAutopilotProgress();435const deferred = new DeferredPromise<void>();436this.autopilotProgressDeferred = deferred;437outputStream?.progress(message, async () => {438await deferred.p;439return pastTenseMessage;440});441}442443/**444* Resolves any pending autopilot progress spinner, transitioning it to its past-tense message.445*/446private resolveAutopilotProgress(): void {447if (this.autopilotProgressDeferred) {448this.autopilotProgressDeferred.complete(undefined);449this.autopilotProgressDeferred = undefined;450}451}452453/**454* Ensures the `task_complete` tool is present in the available tools when running in455* autopilot mode. If it's missing (e.g. filtered out by the tool picker), it's resolved456* from the tools service and appended so the model can always signal completion.457*/458protected ensureAutopilotTools(availableTools: LanguageModelToolInformation[]): LanguageModelToolInformation[] {459if (this.options.request.permissionLevel !== 'autopilot') {460return availableTools;461}462if (availableTools.some(t => t.name === ToolCallingLoop.TASK_COMPLETE_TOOL_NAME)) {463return availableTools;464}465const taskCompleteTool = this._instantiationService.invokeFunction(466accessor => accessor.get(IToolsService).getTool(ToolCallingLoop.TASK_COMPLETE_TOOL_NAME)467);468if (taskCompleteTool) {469this._logService.info('[ToolCallingLoop] Added task_complete tool for autopilot mode');470return [...availableTools, taskCompleteTool];471}472this._logService.warn('[ToolCallingLoop] task_complete tool not found — autopilot completion may not work');473return availableTools;474}475476/**477* Whether the loop should auto-retry after a failed fetch in auto-approve/autopilot mode.478* Does not retry rate-limited, quota-exceeded, or cancellation errors.479*/480private shouldAutoRetry(response: ChatResponse): boolean {481const permLevel = this.options.request.permissionLevel;482if (permLevel !== 'autoApprove' && permLevel !== 'autopilot') {483return false;484}485if (this.autopilotRetryCount >= ToolCallingLoop.MAX_AUTOPILOT_RETRIES) {486return false;487}488switch (response.type) {489case ChatFetchResponseType.RateLimited:490case ChatFetchResponseType.QuotaExceeded:491case ChatFetchResponseType.Canceled:492case ChatFetchResponseType.OffTopic:493return false;494default:495return response.type !== ChatFetchResponseType.Success;496}497}498499/**500* Called when a session starts to allow hooks to provide additional context.501* @param input The session start hook input containing source502* @param outputStream The output stream for displaying messages503* @param token Cancellation token504* @returns Result containing additional context from hooks505*/506protected async executeSessionStartHook(input: SessionStartHookInput, sessionId: string, outputStream: ChatResponseStream | undefined, token: CancellationToken): Promise<StartHookResult> {507try {508const results = await this._chatHookService.executeHook('SessionStart', this.options.request.hooks, input, sessionId, token);509510const additionalContexts: string[] = [];511processHookResults({512hookType: 'SessionStart',513results,514outputStream,515logService: this._logService,516onSuccess: (output) => {517if (typeof output === 'object' && output !== null) {518const hookOutput = output as SessionStartHookOutput;519const additionalContext = hookOutput.hookSpecificOutput?.additionalContext;520if (additionalContext) {521additionalContexts.push(additionalContext);522this._logService.trace(`[ToolCallingLoop] SessionStart hook provided context: ${additionalContext.substring(0, 100)}...`);523}524}525},526// SessionStart blocking errors and stopReason are silently ignored527ignoreErrors: true,528});529530return {531additionalContext: additionalContexts.length > 0 ? additionalContexts.join('\n') : undefined532};533} catch (error) {534if (isHookAbortError(error)) {535throw error;536}537this._logService.error('[ToolCallingLoop] Error executing SessionStart hook', error);538return {};539}540}541542/**543* Called when a subagent starts to allow hooks to provide additional context.544* @param input The subagent start hook input containing agent_id and agent_type545* @param outputStream The output stream for displaying messages546* @param token Cancellation token547* @returns Result containing additional context from hooks548*/549protected async executeSubagentStartHook(input: SubagentStartHookInput, sessionId: string, outputStream: ChatResponseStream | undefined, token: CancellationToken): Promise<SubagentStartHookResult> {550try {551const results = await this._chatHookService.executeHook('SubagentStart', this.options.request.hooks, input, sessionId, token);552553const additionalContexts: string[] = [];554processHookResults({555hookType: 'SubagentStart',556results,557outputStream,558logService: this._logService,559onSuccess: (output) => {560if (typeof output === 'object' && output !== null) {561const hookOutput = output as SubagentStartHookOutput;562const additionalContext = hookOutput.hookSpecificOutput?.additionalContext;563if (additionalContext) {564additionalContexts.push(additionalContext);565this._logService.trace(`[ToolCallingLoop] SubagentStart hook provided context: ${additionalContext.substring(0, 100)}...`);566}567}568},569// SubagentStart blocking errors and stopReason are silently ignored570ignoreErrors: true,571});572573return {574additionalContext: additionalContexts.length > 0 ? additionalContexts.join('\n') : undefined575};576} catch (error) {577if (isHookAbortError(error)) {578throw error;579}580this._logService.error('[ToolCallingLoop] Error executing SubagentStart hook', error);581return {};582}583}584585/**586* Called before a subagent stops to give hooks a chance to block the stop.587* @param input The subagent stop hook input containing agent_id, agent_type, and stop_hook_active flag588* @param outputStream The output stream for displaying messages589* @param token Cancellation token590* @returns Result indicating whether to continue and the reasons591*/592protected async executeSubagentStopHook(input: SubagentStopHookInput, sessionId: string, outputStream: ChatResponseStream | undefined, token: CancellationToken): Promise<SubagentStopHookResult> {593try {594const results = await this._chatHookService.executeHook('SubagentStop', this.options.request.hooks, input, sessionId, token);595596const blockingReasons = new Set<string>();597processHookResults({598hookType: 'SubagentStop',599results,600outputStream,601logService: this._logService,602onSuccess: (output) => {603if (typeof output === 'object' && output !== null) {604const hookOutput = output as SubagentStopHookOutput;605const specific = hookOutput.hookSpecificOutput;606this._logService.trace(`[ToolCallingLoop] Checking SubagentStop hook output: decision=${specific?.decision}, reason=${specific?.reason}`);607if (specific?.decision === 'block' && specific.reason) {608this._logService.trace(`[ToolCallingLoop] SubagentStop hook blocked: ${specific.reason}`);609blockingReasons.add(specific.reason);610}611}612},613// Collect errors as blocking reasons (stderr from exit code != 0)614onError: (errorMessage) => {615if (errorMessage) {616this._logService.trace(`[ToolCallingLoop] SubagentStop hook error collected as blocking reason: ${errorMessage}`);617blockingReasons.add(errorMessage);618}619},620});621622if (blockingReasons.size > 0) {623return { shouldContinue: true, reasons: [...blockingReasons] };624}625return { shouldContinue: false };626} catch (error) {627if (isHookAbortError(error)) {628throw error;629}630this._logService.error('[ToolCallingLoop] Error executing SubagentStop hook', error);631return { shouldContinue: false };632}633}634635/**636* Shows a message when the subagent stop hook blocks the subagent from stopping.637* Override in subclasses to customize the display.638* @param outputStream The output stream for displaying messages639* @param reasons The reasons the subagent stop hook blocked stopping640*/641protected showSubagentStopHookBlockedMessage(outputStream: ChatResponseStream | undefined, reasons: readonly string[]): void {642if (outputStream) {643if (reasons.length === 1) {644outputStream.hookProgress('SubagentStop', reasons[0]);645} else {646const formattedReasons = reasons.map((r, i) => `${i + 1}. ${r}`).join('\n');647outputStream.hookProgress('SubagentStop', formattedReasons);648}649}650this._logService.trace(`[ToolCallingLoop] SubagentStop hook blocked stopping: ${reasons.join('; ')}`);651}652653private throwIfCancelled(token: CancellationToken) {654if (token.isCancellationRequested) {655this.turn.setResponse(TurnStatus.Cancelled, undefined, undefined, CanceledResult);656throw new CancellationError();657}658}659660/**661* Executes start hooks (SessionStart for regular sessions, SubagentStart for subagents).662* Should be called before run() to allow hooks to provide context before the first prompt.663*664* - For subagents: Always executes SubagentStart hook665* - For regular sessions: Only executes SessionStart hook on the first turn666* @throws HookAbortError if a hook requests the session/subagent to abort667*/668public async runStartHooks(outputStream: ChatResponseStream | undefined, token: CancellationToken): Promise<void> {669const sessionId = this.options.conversation.sessionId;670const hasHooks = this.options.request.hasHooksEnabled;671672// Report which hooks are configured for this request673this._chatHookService.logConfiguredHooks(this.options.request.hooks);674675// Execute SubagentStart hook for subagent requests, or SessionStart hook for first turn of regular sessions676if (this.options.request.subAgentInvocationId) {677const startHookResult = await this.executeSubagentStartHook({678agent_id: this.options.request.subAgentInvocationId,679agent_type: this.options.request.subAgentName ?? 'default',680}, sessionId, outputStream, token);681if (startHookResult.additionalContext) {682this.additionalHookContext = startHookResult.additionalContext;683this._logService.info(`[ToolCallingLoop] SubagentStart hook provided context for subagent ${this.options.request.subAgentInvocationId}`);684}685} else {686const isFirstTurn = this.options.conversation.turns.length === 1;687688if (hasHooks) {689// Build history from prior turns (excluding the current one) for transcript replay690const priorTurns = this.options.conversation.turns.slice(0, -1);691const history: IHistoricalTurn[] = priorTurns.map(turn => ({692userMessage: turn.request.message,693timestamp: turn.startTime,694rounds: turn.rounds.map(round => ({695response: round.response,696toolCalls: round.toolCalls.map(tc => ({697name: tc.name,698arguments: tc.arguments,699id: tc.id,700})),701reasoningText: round.thinking702? (Array.isArray(round.thinking.text) ? round.thinking.text.join('') : round.thinking.text)703: undefined,704timestamp: round.timestamp,705})),706}));707708// Start the transcript (will replay history if no file exists yet)709await this._sessionTranscriptService.startSession(sessionId, undefined, history.length > 0 ? history : undefined);710}711712if (isFirstTurn) {713const startHookResult = await this.executeSessionStartHook({714source: 'new',715model: this.options.request.model?.id ?? 'unknown',716agent_type: this.agentName,717}, sessionId, outputStream, token);718if (startHookResult.additionalContext) {719this.additionalHookContext = startHookResult.additionalContext;720this._logService.info('[ToolCallingLoop] SessionStart hook provided context for session');721}722}723}724725// Log the user message for the transcript (no-ops if session was not started)726this._sessionTranscriptService.logUserMessage(727sessionId,728this.turn.request.message,729);730}731732public async run(outputStream: ChatResponseStream | undefined, token: CancellationToken): Promise<IToolCallLoopResult> {733const agentName = this.agentName ?? 'GitHub Copilot Chat';734735// Extract custom mode name for debug logging (kept separate from agentName to avoid metric cardinality)736const modeInstructions = (this.options.request as { modeInstructions2?: { name?: string; isBuiltin?: boolean } }).modeInstructions2;737const customModeName = modeInstructions?.name && !modeInstructions.isBuiltin ? modeInstructions.name : undefined;738739// If this is a subagent request, look up the parent trace context stored by the parent agent's execute_tool span740// Try subAgentInvocationId first (unique per subagent, supports parallel), then request-level key741const subAgentInvocationId = this.options.request.subAgentInvocationId;742const parentRequestId = this.options.request.parentRequestId;743const parentTraceContext = (subAgentInvocationId744? this._otelService.getStoredTraceContext(`subagent:invocation:${subAgentInvocationId}`)745: undefined)746?? (() => {747// For request-level fallback, read and re-store so parallel subagents can all read it748if (!parentRequestId) { return undefined; }749const ctx = this._otelService.getStoredTraceContext(`subagent:request:${parentRequestId}`);750if (ctx) { this._otelService.storeTraceContext(`subagent:request:${parentRequestId}`, ctx); }751return ctx;752})();753754// Get the VS Code chat session ID from the CapturingToken (same mechanism as old debug panel)755const chatSessionId = getCurrentCapturingToken()?.chatSessionId;756const parentChatSessionId = getCurrentCapturingToken()?.parentChatSessionId;757const debugLogLabel = getCurrentCapturingToken()?.debugLogLabel;758759return this._otelService.startActiveSpan(760`invoke_agent ${agentName}`,761{762kind: SpanKind.INTERNAL,763attributes: {764[GenAiAttr.OPERATION_NAME]: GenAiOperationName.INVOKE_AGENT,765[GenAiAttr.PROVIDER_NAME]: GenAiProviderName.GITHUB,766[GenAiAttr.AGENT_NAME]: agentName,767[GenAiAttr.CONVERSATION_ID]: this.options.conversation.sessionId,768[CopilotChatAttr.SESSION_ID]: this.options.conversation.sessionId,769...(chatSessionId ? { [CopilotChatAttr.CHAT_SESSION_ID]: chatSessionId } : {}),770...(parentChatSessionId ? { [CopilotChatAttr.PARENT_CHAT_SESSION_ID]: parentChatSessionId } : {}),771...(debugLogLabel ? { [CopilotChatAttr.DEBUG_LOG_LABEL]: debugLogLabel } : {}),772...(customModeName ? { [CopilotChatAttr.MODE_NAME]: customModeName } : {}),773...workspaceMetadataToOTelAttributes(resolveWorkspaceOTelMetadata(this._gitService)),774},775parentTraceContext,776},777async (span) => {778const otelStartTime = Date.now();779780// Register this session as a child of its parent so that debug781// log entries are routed to a dedicated child JSONL file.782// parentChatSessionId is only set on subagent requests783// (see CapturingToken setup in defaultIntentRequestHandler).784if (chatSessionId) {785const fileLogger = this._instantiationService.invokeFunction(accessor =>786accessor.get(IChatDebugFileLoggerService));787788// Register this session as a child of its parent so that debug789// log entries are routed to a dedicated child JSONL file.790// parentChatSessionId is only set on subagent requests791// (see CapturingToken setup in defaultIntentRequestHandler).792if (parentChatSessionId) {793const childLabel = debugLogLabel ?? `runSubagent-${agentName}`;794fileLogger.startChildSession(795chatSessionId, parentChatSessionId, childLabel, parentTraceContext?.spanId);796// Also register the invoke_agent span's ID so that hook spans797// (whose parentSpanId is this span) are routed to the child session.798const invokeSpanId = span.getSpanContext()?.spanId;799if (invokeSpanId) {800fileLogger.registerSpanSession(invokeSpanId, chatSessionId);801}802} else {803// For top-level agent invocations (not subagents), start a debug804// file logging session so entries are flushed to JSONL on disk.805// This is idempotent — calling startSession on an already-started806// session just promotes it if needed.807fileLogger.startSession(chatSessionId).catch(() => { /* best effort */ });808}809}810811// Emit session start event and metric for top-level agent invocations (not subagents)812if (!parentTraceContext) {813GenAiMetrics.incrementSessionCount(this._otelService);814try {815const endpoint = await this._endpointProvider.getChatEndpoint(this.options.request);816emitSessionStartEvent(this._otelService, this.options.conversation.sessionId, endpoint.model, agentName);817} catch {818emitSessionStartEvent(this._otelService, this.options.conversation.sessionId, 'unknown', agentName);819}820}821822// Set request model from the endpoint823try {824const endpoint = await this._endpointProvider.getChatEndpoint(this.options.request);825span.setAttribute(GenAiAttr.REQUEST_MODEL, endpoint.model);826} catch { /* endpoint not available yet, will be set on response */ }827828// Always capture user input message for the debug panel829{830const userMessage = this.turn.request.message;831span.setAttribute(GenAiAttr.INPUT_MESSAGES, truncateForOTel(JSON.stringify([832{ role: 'user', parts: [{ type: 'text', content: userMessage }] }833])));834// Set USER_REQUEST so event translator can emit user.message835if (userMessage) {836span.setAttribute(CopilotChatAttr.USER_REQUEST, truncateForOTel(userMessage));837}838// Emit user_message span event for real-time debug panel streaming839if (userMessage) {840span.addEvent('user_message', { content: userMessage, ...(chatSessionId ? { [CopilotChatAttr.CHAT_SESSION_ID]: chatSessionId } : {}) });841}842}843844// Accumulate token usage across all LLM turns per GenAI agent span spec845let totalInputTokens = 0;846let totalOutputTokens = 0;847let totalCacheReadTokens = 0;848let totalCacheCreationTokens = 0;849let lastResolvedModel: string | undefined;850let turnIndex = 0;851const tokenListener = this.onDidReceiveResponse(({ response }) => {852const turnInputTokens = response.type === ChatFetchResponseType.Success ? (response.usage?.prompt_tokens || 0) : 0;853const turnOutputTokens = response.type === ChatFetchResponseType.Success ? (response.usage?.completion_tokens || 0) : 0;854if (response.type === ChatFetchResponseType.Success && response.usage) {855totalInputTokens += turnInputTokens;856totalOutputTokens += turnOutputTokens;857totalCacheReadTokens += (response.usage.prompt_tokens_details?.cached_tokens || 0);858totalCacheCreationTokens += (response.usage.prompt_tokens_details?.cache_creation_input_tokens || 0);859}860if (response.type === ChatFetchResponseType.Success && response.resolvedModel) {861lastResolvedModel = response.resolvedModel;862}863emitAgentTurnEvent(this._otelService, turnIndex, turnInputTokens, turnOutputTokens, 0);864turnIndex++;865});866867try {868const result = await this._runLoop(outputStream, token, span, chatSessionId);869span.setAttributes({870[CopilotChatAttr.TURN_COUNT]: result.toolCallRounds.length,871[GenAiAttr.USAGE_INPUT_TOKENS]: totalInputTokens,872[GenAiAttr.USAGE_OUTPUT_TOKENS]: totalOutputTokens,873...(totalCacheReadTokens ? { [GenAiAttr.USAGE_CACHE_READ_INPUT_TOKENS]: totalCacheReadTokens } : {}),874...(totalCacheCreationTokens ? { [GenAiAttr.USAGE_CACHE_CREATION_INPUT_TOKENS]: totalCacheCreationTokens } : {}),875...(lastResolvedModel ? { [GenAiAttr.RESPONSE_MODEL]: lastResolvedModel } : {}),876});877// Always capture agent output message and tool definitions for the debug panel878{879const lastRound = result.toolCallRounds.at(-1);880if (lastRound?.response) {881const responseText = Array.isArray(lastRound.response) ? lastRound.response.join('') : lastRound.response;882span.setAttribute(GenAiAttr.OUTPUT_MESSAGES, truncateForOTel(JSON.stringify([883{ role: 'assistant', parts: [{ type: 'text', content: responseText }] }884])));885}886// Log tool definitions once on the agent span (same set across all turns).887// Includes `parameters` (inputSchema) per OTel GenAI semantic convention so888// trace viewers can render full tool signatures (issue #300318).889if (result.availableTools.length > 0) {890span.setAttribute(GenAiAttr.TOOL_DEFINITIONS, truncateForOTel(JSON.stringify(891result.availableTools.map(t => ({892type: 'function',893name: t.name,894description: t.description,895parameters: t.inputSchema,896}))897)));898}899}900span.setStatus(SpanStatusCode.OK);901902// Record agent-level metrics903const durationSec = (Date.now() - otelStartTime) / 1000;904GenAiMetrics.recordAgentDuration(this._otelService, agentName, durationSec);905GenAiMetrics.recordAgentTurnCount(this._otelService, agentName, result.toolCallRounds.length);906907return result;908} catch (err) {909span.setStatus(SpanStatusCode.ERROR, err instanceof Error ? err.message : String(err));910span.setAttribute(StdAttr.ERROR_TYPE, err instanceof Error ? err.constructor.name : 'Error');911throw err;912} finally {913tokenListener.dispose();914}915},916);917}918919private async _runLoop(outputStream: ChatResponseStream | undefined, token: CancellationToken, agentSpan?: ISpanHandle, chatSessionId?: string): Promise<IToolCallLoopResult> {920let i = 0;921let lastResult: IToolCallSingleResult | undefined;922let lastRequestMessagesStartingIndexForRun: number | undefined;923let stopHookActive = false;924const sessionId = this.options.conversation.sessionId;925926// Store span context so runOne() can emit tools_available on first call927this.agentSpan = agentSpan;928this.chatSessionIdForTools = chatSessionId;929this.toolsAvailableEmitted = false;930931while (true) {932if (lastResult && i++ >= this.options.toolCallLimit) {933// In Autopilot mode, silently increase the limit and continue934// without showing the confirmation dialog, up to a hard cap.935const permLevel = this.options.request.permissionLevel;936if (permLevel === 'autopilot' && this.options.toolCallLimit < 200) {937this.options.toolCallLimit = Math.min(Math.round(this.options.toolCallLimit * 3 / 2), 200);938this.showAutopilotProgress(outputStream, l10n.t('Autopilot: extending tool call limit\u2026'), l10n.t('Autopilot extended tool call limit'));939} else {940lastResult = this.hitToolCallLimit(outputStream, lastResult);941break;942}943}944945// Check if VS Code has requested we gracefully yield before starting the next iteration.946// In autopilot mode, don't yield until the task is actually complete.947if (lastResult && this.options.yieldRequested?.()) {948if (this.options.request.permissionLevel !== 'autopilot' || this.taskCompleted) {949break;950}951}952953try {954const turnId = String(i);955this._sessionTranscriptService.logAssistantTurnStart(sessionId, turnId);956agentSpan?.addEvent('turn_start', { turnId, ...(chatSessionId ? { [CopilotChatAttr.CHAT_SESSION_ID]: chatSessionId } : {}) });957this.resolveAutopilotProgress();958const result = await this.runOne(outputStream, i, token);959if (lastRequestMessagesStartingIndexForRun === undefined) {960lastRequestMessagesStartingIndexForRun = result.lastRequestMessages.length - 1;961}962lastResult = {963...result,964hadIgnoredFiles: lastResult?.hadIgnoredFiles || result.hadIgnoredFiles965};966967this.toolCallRounds.push(result.round);968this._sessionTranscriptService.logAssistantTurnEnd(sessionId, turnId);969agentSpan?.addEvent('turn_end', { turnId, ...(chatSessionId ? { [CopilotChatAttr.CHAT_SESSION_ID]: chatSessionId } : {}) });970971// If the model produced productive (non-task_complete) tool calls after being nudged,972// reset the stop hook flag and iteration count so it can be nudged again.973if (this.autopilotStopHookActive && result.round.toolCalls.length && !result.round.toolCalls.some(tc => tc.name === ToolCallingLoop.TASK_COMPLETE_TOOL_NAME)) {974this.autopilotStopHookActive = false;975this.autopilotIterationCount = 0;976}977978if (!result.round.toolCalls.length || result.response.type !== ChatFetchResponseType.Success) {979// If cancelled, don't run stop hooks - just break immediately980if (token.isCancellationRequested) {981break;982}983984// In auto-approve modes, auto-retry on transient errors (not rate-limited or quota-exceeded)985if (result.response.type !== ChatFetchResponseType.Success && this.shouldAutoRetry(result.response)) {986this.autopilotRetryCount++;987this._logService.info(`[ToolCallingLoop] Auto-retrying on error (attempt ${this.autopilotRetryCount}/${ToolCallingLoop.MAX_AUTOPILOT_RETRIES}): ${result.response.type}`);988if (this.options.request.permissionLevel === 'autopilot') {989this.showAutopilotProgress(outputStream, l10n.t('Autopilot: recovering from a request error\u2026'), l10n.t('Autopilot recovered from a request error'));990} else {991this.showAutopilotProgress(outputStream, l10n.t('Recovering from a request error\u2026'), l10n.t('Recovered from a request error'));992}993await timeout(1000, token);994continue;995}996997// Before stopping, execute the stop hook998if (this.options.request.subAgentInvocationId) {999const stopHookResult = await this.executeSubagentStopHook({1000agent_id: this.options.request.subAgentInvocationId,1001agent_type: this.options.request.subAgentName ?? 'default',1002stop_hook_active: stopHookActive,1003}, sessionId, outputStream, token);1004const joinedReasons = stopHookResult.reasons?.join('; ');1005this._logService.info(`[ToolCallingLoop] Subagent stop hook result: shouldContinue=${stopHookResult.shouldContinue}, reasons=${joinedReasons}`);1006if (stopHookResult.shouldContinue && stopHookResult.reasons?.length) {1007// The stop hook blocked stopping - show reasons and continue1008this.showSubagentStopHookBlockedMessage(outputStream, stopHookResult.reasons);1009// Store the joined reasons so it can be passed to the model in the next prompt1010this.stopHookReason = joinedReasons;1011// Also persist on the round so it survives across turns1012result.round.hookContext = formatHookContext(stopHookResult.reasons);1013this._logService.info(`[ToolCallingLoop] Subagent stop hook blocked, continuing with reasons: ${joinedReasons}`);1014stopHookActive = true;1015continue;1016}1017} else {1018const stopHookResult = await this.executeStopHook({ stop_hook_active: stopHookActive }, sessionId, outputStream, token);1019const joinedReasons = stopHookResult.reasons?.join('; ');1020this._logService.info(`[ToolCallingLoop] Stop hook result: shouldContinue=${stopHookResult.shouldContinue}, reasons=${joinedReasons}`);1021if (stopHookResult.shouldContinue && stopHookResult.reasons?.length) {1022// The stop hook blocked stopping - show reasons and continue1023this.showStopHookBlockedMessage(outputStream, stopHookResult.reasons);1024// Store the joined reasons so it can be passed to the model in the next prompt1025this.stopHookReason = joinedReasons;1026// Also persist on the round so it survives across turns1027result.round.hookContext = formatHookContext(stopHookResult.reasons);1028this._logService.info(`[ToolCallingLoop] Stop hook blocked, continuing with reasons: ${joinedReasons}`);1029stopHookActive = true;1030this.stopHookUserInitiated = true;1031continue;1032}1033}10341035// In Autopilot mode, check if the task is actually done before stopping.1036// This acts as an internal stop hook that keeps the agent churning until completion.1037if (this.options.request.permissionLevel === 'autopilot' && result.response.type === ChatFetchResponseType.Success) {1038const autopilotContinue = this.shouldAutopilotContinue(result);1039if (autopilotContinue) {1040this._logService.info(`[ToolCallingLoop] Autopilot internal stop hook: continuing because task may not be complete`);1041this.showAutopilotProgress(outputStream, l10n.t('Autopilot: verifying task is done\u2026'), l10n.t('Autopilot continued working'));1042this.stopHookReason = autopilotContinue;1043result.round.hookContext = formatHookContext([autopilotContinue]);1044this.autopilotStopHookActive = true;1045continue;1046}1047}10481049break;1050}1051} catch (e) {1052if (isCancellationError(e) && lastResult) {1053break;1054}10551056throw e;1057}1058}10591060this.resolveAutopilotProgress();10611062this.emitReadFileTrajectories().catch(err => {1063this._logService.error('Error emitting read file trajectories', err);1064});10651066const toolCallRoundsToDisplay = lastResult.lastRequestMessages.slice(lastRequestMessagesStartingIndexForRun ?? 0).filter((m): m is Raw.ToolChatMessage => m.role === Raw.ChatRole.Tool);1067for (const toolRound of toolCallRoundsToDisplay) {1068const result = this.toolCallResults[toolRound.toolCallId];1069if (result instanceof LanguageModelToolResult2) {1070for (const part of result.content) {1071if (part instanceof LanguageModelDataPart2 && part.mimeType === 'application/pull-request+json' && part.audience?.includes(LanguageModelPartAudience.User)) {1072const data: { uri: string; title: string; description: string; author: string; linkTag: string } = JSON.parse(part.data.toString());1073outputStream?.push(new ChatResponsePullRequestPart({ command: 'github.copilot.chat.openPullRequestReroute', title: l10n.t('View Pull Request {0}', data.linkTag), arguments: [Number(data.linkTag.substring(1))] }, data.title, data.description, data.author, data.linkTag));1074}1075}1076}1077}1078return { ...lastResult, toolCallRounds: this.toolCallRounds, toolCallResults: this.toolCallResults };1079}10801081private async emitReadFileTrajectories() {1082// We are tuning our `read_file` tool to read files more effectively and efficiently.1083// This is a likely-temporary function that emits trajectory telemetry read_files1084// at the end of each agentic loop so that we can do so, in addition to the1085// per-call telemetry in ReadFileTool10861087function tryGetRFArgs(call: IToolCall): ReadFileParams | undefined {1088if (call.name !== ToolName.ReadFile) {1089return undefined;1090}1091try {1092return JSON.parse(call.arguments);1093} catch {1094return undefined;1095}1096}10971098const consumed = new Set<string>();1099const tcrs = this.toolCallRounds;1100for (let i = 0; i < tcrs.length; i++) {1101const { toolCalls } = tcrs[i];1102for (const call of toolCalls) {1103if (consumed.has(call.id)) {1104continue;1105}1106const args = tryGetRFArgs(call);1107if (!args) {1108continue;1109}11101111const seqArgs = [args];1112consumed.add(call.id);11131114for (let k = i + 1; k < tcrs.length; k++) {1115for (const call2 of tcrs[k].toolCalls) {1116if (consumed.has(call2.id)) {1117continue;1118}11191120const args2 = tryGetRFArgs(call2);1121if (!args2 || args2.filePath !== args.filePath) {1122continue;1123}11241125consumed.add(call2.id);1126seqArgs.push(args2);1127}1128}11291130let chunkSizeTotal = 0;1131let chunkSizeNo = 0;1132for (const arg of seqArgs) {1133if ('startLine' in arg) {1134chunkSizeNo++;1135chunkSizeTotal += arg.endLine - arg.startLine + 1;1136} else if (arg.limit) {1137chunkSizeNo++;1138chunkSizeTotal += arg.limit;1139}1140}11411142/* __GDPR__1143"readFileTrajectory" : {1144"owner": "connor4312",1145"comment": "read_file tool invokation trajectory",1146"model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model that invoked the tool" },1147"rounds": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The number of times the file was read sequentially" },1148"avgChunkSize": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The number of lines read at a time" }1149}1150*/1151this._telemetryService.sendMSFTTelemetryEvent('readFileTrajectory',1152{1153// model will be undefined in the simulator1154model: this.options.request.model?.id,1155},1156{1157rounds: seqArgs.length,1158avgChunkSize: chunkSizeNo > 0 ? Math.round(chunkSizeTotal / chunkSizeNo) : -1,1159}1160);1161}1162}1163}11641165private hitToolCallLimit(stream: ChatResponseStream | undefined, lastResult: IToolCallSingleResult) {1166if (stream && this.options.onHitToolCallLimit === ToolCallLimitBehavior.Confirm) {1167const messageString = new MarkdownString(l10n.t({1168message: 'Copilot has been working on this problem for a while. It can continue to iterate, or you can send a new message to refine your prompt. [Configure max requests]({0}).',1169args: [`command:workbench.action.openSettings?${encodeURIComponent('["chat.agent.maxRequests"]')}`],1170comment: 'Link to workbench settings for chat.maxRequests, which controls the maximum number of requests Copilot will make before stopping. This is used in the tool calling loop to determine when to stop iterating on a problem.'1171}));1172messageString.isTrusted = { enabledCommands: ['workbench.action.openSettings'] };11731174stream.confirmation(1175l10n.t('Continue to iterate?'),1176messageString,1177{ copilotRequestedRoundLimit: Math.round(this.options.toolCallLimit * 3 / 2) } satisfies IToolCallIterationIncrease,1178[1179l10n.t('Continue'),1180cancelText(),1181]1182);1183}11841185lastResult.chatResult = {1186...lastResult.chatResult,1187metadata: {1188...lastResult.chatResult?.metadata,1189maxToolCallsExceeded: true1190} satisfies Partial<IResultMetadata>,1191};11921193return lastResult;1194}11951196/** Runs a single iteration of the tool calling loop. */1197public async runOne(outputStream: ChatResponseStream | undefined, iterationNumber: number, token: CancellationToken): Promise<IToolCallSingleResult> {1198let availableTools = await this.getAvailableTools(outputStream, token);11991200// Emit tools_available on the agent span once, before the first CHAT span1201// starts in fetch(). This lets the debug logger write tools_*.json early.1202if (!this.toolsAvailableEmitted && this.agentSpan && availableTools.length > 0) {1203this.toolsAvailableEmitted = true;1204this.agentSpan.addEvent('tools_available', {1205toolDefinitions: truncateForOTel(JSON.stringify(availableTools.map(t => ({1206type: 'function',1207name: t.name,1208description: t.description,1209parameters: t.inputSchema,1210})))),1211...(this.chatSessionIdForTools ? { [CopilotChatAttr.CHAT_SESSION_ID]: this.chatSessionIdForTools } : {}),1212});1213}12141215const context = this.createPromptContext(availableTools, outputStream);1216const isContinuation = context.isContinuation || false;1217markChatExt(this.options.conversation.sessionId, ChatExtPerfMark.WillBuildPrompt);1218let buildPromptResult: IBuildPromptResult;1219try {1220buildPromptResult = await this.buildPrompt2(context, outputStream, token);1221} finally {1222markChatExt(this.options.conversation.sessionId, ChatExtPerfMark.DidBuildPrompt);1223}1224this.throwIfCancelled(token);1225this.turn.addReferences(buildPromptResult.references);1226// Possible the tool call resulted in new tools getting added.1227availableTools = await this.getAvailableTools(outputStream, token);12281229// Apply debug prompt/tool overrides from either inline YAML text or a YAML file.1230const promptOverride = this._configurationService.getConfig(ConfigKey.Advanced.DebugPromptOverrideString);1231const promptOverrideFile = this._configurationService.getConfig(ConfigKey.Advanced.DebugPromptOverrideFile);1232let effectiveBuildPromptResult: IBuildPromptResult = buildPromptResult;1233if (promptOverride || promptOverrideFile) {1234const overrideResult = await applyConfiguredPromptOverrides(1235promptOverride,1236promptOverrideFile,1237buildPromptResult.messages,1238availableTools,1239this._fileSystemService,1240this._logService,1241);1242effectiveBuildPromptResult = { ...buildPromptResult, messages: overrideResult.messages };1243availableTools = overrideResult.tools;1244}12451246// Ensure task_complete is available in autopilot mode so the model can signal completion1247availableTools = this.ensureAutopilotTools(availableTools);12481249const isToolInputFailure = effectiveBuildPromptResult.metadata.get(ToolFailureEncountered);1250const conversationSummary = effectiveBuildPromptResult.metadata.get(SummarizedConversationHistoryMetadata);1251if (conversationSummary) {1252this.turn.setMetadata(conversationSummary);1253}12541255// Find the latest summarized round.1256let summarizedAtRoundId: string | undefined;1257for (let i = this.toolCallRounds.length - 1; i >= 0; i--) {1258if (this.toolCallRounds[i].summary) {1259summarizedAtRoundId = this.toolCallRounds[i].id;1260break;1261}1262}1263if (!summarizedAtRoundId) {1264for (const turn of [...context.history].reverse()) {1265for (const round of [...turn.rounds].reverse()) {1266if (round.summary) {1267summarizedAtRoundId = round.id;1268break;1269}1270}1271if (summarizedAtRoundId) {1272break;1273}1274}1275}12761277const endpoint = await this._endpointProvider.getChatEndpoint(this.options.request);1278const tokenizer = endpoint.acquireTokenizer();1279const promptTokenLength = await tokenizer.countMessagesTokens(effectiveBuildPromptResult.messages);1280const toolTokenCount = availableTools.length > 0 ? await tokenizer.countToolTokens(availableTools) : 0;1281this.throwIfCancelled(token);1282this._onDidBuildPrompt.fire({ result: effectiveBuildPromptResult, tools: availableTools, promptTokenLength, toolTokenCount });1283this._logService.trace('Built prompt');12841285// Tool calls happen during prompt building. Check yield again here to see if we should abort prior to sending off the next request.1286if (iterationNumber > 0 && this.options.yieldRequested?.()) {1287throw new CancellationError();1288}12891290// todo@connor4312: can interaction outcome logic be implemented in a more generic way?1291const interactionOutcomeComputer = new InteractionOutcomeComputer(this.options.interactionContext);12921293const that = this;1294const responseProcessor = new class implements IResponseProcessor {12951296private readonly context = new ResponseProcessorContext(that.options.conversation.sessionId, that.turn, effectiveBuildPromptResult.messages, interactionOutcomeComputer);12971298async processResponse(_context: unknown, inputStream: AsyncIterable<IResponsePart>, responseStream: ChatResponseStream, token: CancellationToken): Promise<ChatResult | void> {1299let chatResult: ChatResult | void = undefined;1300if (that.options.responseProcessor) {1301chatResult = await that.options.responseProcessor.processResponse(this.context, inputStream, responseStream, token);1302} else {1303const responseProcessor = that._instantiationService.createInstance(PseudoStopStartResponseProcessor, [], undefined, { subagentInvocationId: that.options.request.subAgentInvocationId });1304await responseProcessor.processResponse(this.context, inputStream, responseStream, token);1305}1306return chatResult;1307}1308}();13091310this._logService.trace('Sending prompt to model');13111312const streamParticipants = outputStream ? [outputStream] : [];1313let fetchStreamSource: FetchStreamSource | undefined;1314let processResponsePromise: Promise<ChatResult | void> | undefined;1315let stopEarly = false;1316if (outputStream) {1317this.options.streamParticipants?.forEach(fn => {1318streamParticipants.push(fn(streamParticipants[streamParticipants.length - 1]));1319});1320const stream = streamParticipants[streamParticipants.length - 1];13211322fetchStreamSource = new FetchStreamSource();1323processResponsePromise = responseProcessor.processResponse(undefined, fetchStreamSource.stream, stream, token);13241325// Allows the response processor to do an early stop of the LLM request.1326processResponsePromise.finally(() => {1327// The response processor indicates that it has finished processing the response,1328// so let's stop the request if it's still in flight.1329stopEarly = true;1330});1331}13321333if (effectiveBuildPromptResult.messages.length === 0) {1334// /fixTestFailure relies on this check running after processResponse1335fetchStreamSource?.resolve();1336await processResponsePromise;1337await finalizeStreams(streamParticipants);1338throw new EmptyPromptError();1339}13401341const promptContextTools = availableTools.length ? availableTools.map(toolInfo => {1342return {1343name: toolInfo.name,1344description: toolInfo.description,1345parameters: toolInfo.inputSchema,1346} satisfies OpenAiFunctionDef;1347}) : undefined;13481349let statefulMarker: string | undefined;1350const toolCalls: IToolCall[] = [];1351let thinkingItem: ThinkingDataItem | undefined;1352const shouldDisableThinking = isContinuation && isAnthropicFamily(endpoint) && !ToolCallingLoop.messagesContainThinking(effectiveBuildPromptResult.messages);1353const enableThinking = !shouldDisableThinking;1354let phase: string | undefined;1355let compaction: OpenAIContextManagementResponse | undefined;1356markChatExt(this.options.conversation.sessionId, ChatExtPerfMark.WillFetch);1357const fetchResult = await this.fetch({1358messages: this.applyMessagePostProcessing(effectiveBuildPromptResult.messages, { stripOrphanedToolCalls: isGeminiFamily(endpoint) }),1359turnId: this.turn.id,1360summarizedAtRoundId,1361finishedCb: async (text, index, delta) => {1362fetchStreamSource?.update(text, delta);1363if (delta.copilotToolCalls) {1364toolCalls.push(...delta.copilotToolCalls.map((call): IToolCall => ({1365...call,1366id: this.createInternalToolCallId(call.id),1367arguments: call.arguments === '' ? '{}' : call.arguments1368})));1369}1370if (delta.statefulMarker) {1371statefulMarker = delta.statefulMarker;1372}1373if (delta.thinking) {1374thinkingItem = ThinkingDataItem.createOrUpdate(thinkingItem, delta.thinking);1375}1376if (delta.phase) {1377phase = delta.phase;1378}1379if (delta.contextManagement && isOpenAIContextManagementResponse(delta.contextManagement)) {1380compaction = delta.contextManagement;1381}1382return stopEarly ? text.length : undefined;1383},1384requestOptions: {1385tools: promptContextTools?.map(tool => ({1386function: {1387name: tool.name,1388description: tool.description,1389parameters: tool.parameters && Object.keys(tool.parameters).length ? tool.parameters : undefined1390},1391type: 'function',1392})),1393},1394userInitiatedRequest: (iterationNumber === 0 && !isContinuation && !this.options.request.subAgentInvocationId && !this.options.request.isSystemInitiated) || this.stopHookUserInitiated,1395modelCapabilities: {1396enableThinking,1397},1398}, token).finally(() => {1399this.stopHookUserInitiated = false;1400});1401markChatExt(this.options.conversation.sessionId, ChatExtPerfMark.DidFetch);14021403// Store the headerRequestId from the fetch response for subagent telemetry linking.1404// Use requestId (the client-generated UUID sent as X-Request-Id header), not serverRequestId1405// (the server's response header value), because requestId is what appears as headerRequestId1406// across all telemetry events.1407if (fetchResult.type === ChatFetchResponseType.Success) {1408this.lastHeaderRequestId = fetchResult.requestId;1409}14101411const promptTokenDetails = await computePromptTokenDetails({1412messages: effectiveBuildPromptResult.messages,1413tokenizer,1414tools: availableTools,1415});1416fetchStreamSource?.resolve();1417const chatResult = await processResponsePromise ?? undefined;14181419// Report token usage to the stream for rendering the context window widget1420const stream = streamParticipants[streamParticipants.length - 1];1421if (fetchResult.type === ChatFetchResponseType.Success && fetchResult.usage && stream && this.shouldReportUsageToContextWidget()) {1422stream.usage({1423completionTokens: fetchResult.usage.completion_tokens,1424promptTokens: fetchResult.usage.prompt_tokens,1425outputBuffer: endpoint.maxOutputTokens,1426promptTokenDetails,1427});1428}14291430// Validate authentication session upgrade and handle accordingly1431if (1432outputStream &&1433toolCalls.some(tc => tc.name === ToolName.Codebase) &&1434await this._authenticationChatUpgradeService.shouldRequestPermissiveSessionUpgrade()1435) {1436this._authenticationChatUpgradeService.showPermissiveSessionUpgradeInChat(outputStream, this.options.request);1437throw new ToolCallCancelledError(new CancellationError());1438}14391440await finalizeStreams(streamParticipants);1441this._onDidReceiveResponse.fire({ interactionOutcome: interactionOutcomeComputer, response: fetchResult, toolCalls });14421443this.turn.setMetadata(interactionOutcomeComputer.interactionOutcome);14441445const toolInputRetry = isToolInputFailure ? (this.toolCallRounds.at(-1)?.toolInputRetry || 0) + 1 : 0;1446if (fetchResult.type === ChatFetchResponseType.Success) {1447// Store token usage metadata for Anthropic models using Messages API1448if (fetchResult.usage && isAnthropicFamily(endpoint)) {1449this.turn.setMetadata(new AnthropicTokenUsageMetadata(1450fetchResult.usage.prompt_tokens,1451fetchResult.usage.completion_tokens1452));1453}14541455thinkingItem?.updateWithFetchResult(fetchResult);14561457// Log the assistant message to the transcript1458const transcriptToolRequests: ToolRequest[] = toolCalls.map(tc => ({1459toolCallId: tc.id,1460name: tc.name,1461arguments: tc.arguments,1462type: 'function' as const,1463}));1464this._sessionTranscriptService.logAssistantMessage(1465this.options.conversation.sessionId,1466fetchResult.value,1467transcriptToolRequests,1468thinkingItem ? (Array.isArray(thinkingItem.text) ? thinkingItem.text.join('') : thinkingItem.text) : undefined,1469);14701471return {1472response: fetchResult,1473round: ToolCallRound.create({1474response: fetchResult.value,1475toolCalls,1476toolInputRetry,1477statefulMarker,1478thinking: thinkingItem,1479phase,1480phaseModelId: phase ? endpoint.model : undefined,1481compaction,1482}),1483chatResult,1484hadIgnoredFiles: buildPromptResult.hasIgnoredFiles,1485lastRequestMessages: effectiveBuildPromptResult.messages,1486availableTools,1487};1488}14891490return {1491response: fetchResult,1492hadIgnoredFiles: buildPromptResult.hasIgnoredFiles,1493lastRequestMessages: effectiveBuildPromptResult.messages,1494availableTools,1495round: new ToolCallRound('', toolCalls, toolInputRetry),1496};1497}14981499/**1500* Sometimes 4o reuses tool call IDs, so make sure they are unique. Really we should restructure how tool calls and results are represented1501* to not expect them to be globally unique.1502*/1503private createInternalToolCallId(toolCallId: string): string {1504// Note- if this code is ever removed, these IDs will still exist in persisted session metadata!1505return toolCallId + `__vscode-${ToolCallingLoop.NextToolCallId++}`;1506}15071508private applyMessagePostProcessing(messages: Raw.ChatMessage[], options?: { stripOrphanedToolCalls?: boolean }): Raw.ChatMessage[] {1509return this.validateToolMessages(1510ToolCallingLoop.stripInternalToolCallIds(messages), options);1511}15121513public static stripInternalToolCallIds(messages: Raw.ChatMessage[]): Raw.ChatMessage[] {1514return messages.map(m => {1515if (m.role === Raw.ChatRole.Assistant) {1516return {1517...m,1518toolCalls: m.toolCalls?.map(tc => ({1519...tc,1520id: tc.id.split('__vscode-')[0]1521}))1522};1523} else if (m.role === Raw.ChatRole.Tool) {1524return {1525...m,1526toolCallId: m.toolCallId?.split('__vscode-')[0]1527};1528}15291530return m;1531});1532}15331534public static messagesContainThinking(messages: Raw.ChatMessage[]): boolean {1535let lastUserMessageIndex = -1;1536for (let i = messages.length - 1; i >= 0; i--) {1537if (messages[i].role === Raw.ChatRole.User) {1538lastUserMessageIndex = i;1539break;1540}1541}15421543// If no user message found, return false to disable thinking1544if (lastUserMessageIndex === -1) {1545return false;1546}15471548for (let i = lastUserMessageIndex + 1; i < messages.length; i++) {1549const m = messages[i];1550if (m.role !== Raw.ChatRole.Assistant) {1551continue;1552}1553return Array.isArray(m.content) && m.content.some(part =>1554part.type === Raw.ChatCompletionContentPartKind.Opaque && rawPartAsThinkingData(part) !== undefined1555);1556}1557return false;1558}15591560/**1561* Apparently we can render prompts which have a tool message which is out of place.1562* Don't know why this is happening, but try to detect this and fix it up.1563*1564* Validates tool messages in the conversation, ensuring:1565* 1. Tool result messages have a matching tool_call in the preceding assistant message1566* 2. (When stripOrphanedToolCalls is set) Every tool_call in an assistant message has1567* a matching tool result message. This prevents errors with models like Gemini which1568* strictly require 1:1 function_call ↔ function_response pairing.1569*1570* Returns the validated messages and an array of reasons for any corrections made.1571*/1572public static validateToolMessagesCore(messages: Raw.ChatMessage[], options?: { stripOrphanedToolCalls?: boolean }): { messages: Raw.ChatMessage[]; filterReasons: string[]; strippedToolCallCount: number } {1573const filterReasons: string[] = [];1574let strippedToolCallCount = 0;1575let previousAssistantMessage: Raw.AssistantChatMessage | undefined;1576const filtered = messages.filter(m => {1577if (m.role === Raw.ChatRole.Assistant) {1578previousAssistantMessage = m;1579} else if (m.role === Raw.ChatRole.Tool) {1580if (!previousAssistantMessage) {1581// No previous assistant message1582filterReasons.push('noPreviousAssistantMessage');1583return false;1584}15851586if (!previousAssistantMessage.toolCalls?.length) {1587// The assistant did not call any tools1588filterReasons.push('noToolCalls');1589return false;1590}15911592const toolCall = previousAssistantMessage.toolCalls.find(tc => tc.id === m.toolCallId);1593if (!toolCall) {1594// This tool call is excluded1595return false;1596}1597}15981599return true;1600});16011602// Second pass: strip tool_calls from assistant messages that lack matching tool result messages.1603// This prevents sending orphaned tool_calls that would cause errors with models like Gemini1604// which strictly require every function_call to have a corresponding function_response.1605// Gated behind stripOrphanedToolCalls to limit scope to models that need it.1606if (!options?.stripOrphanedToolCalls) {1607return { messages: filtered, filterReasons, strippedToolCallCount };1608}16091610for (let i = 0; i < filtered.length; i++) {1611const m = filtered[i];1612if (m.role !== Raw.ChatRole.Assistant || !m.toolCalls?.length) {1613continue;1614}16151616// Collect tool result IDs that follow this assistant message (up to the next assistant message)1617const toolResultIds = new Set<string>();1618for (let j = i + 1; j < filtered.length; j++) {1619const next = filtered[j];1620if (next.role === Raw.ChatRole.Assistant) {1621break;1622}1623if (next.role === Raw.ChatRole.Tool && next.toolCallId !== undefined) {1624toolResultIds.add(next.toolCallId);1625}1626}16271628const orphanedToolCalls = m.toolCalls.filter(tc => !toolResultIds.has(tc.id));1629if (orphanedToolCalls.length > 0) {1630strippedToolCallCount += orphanedToolCalls.length;1631const validToolCalls = m.toolCalls.filter(tc => toolResultIds.has(tc.id));1632// Mutate in place — the assistant message was already shallow-copied by stripInternalToolCallIds1633(m as Mutable<Raw.AssistantChatMessage>).toolCalls = validToolCalls.length > 0 ? validToolCalls : undefined;1634}1635}16361637return { messages: filtered, filterReasons, strippedToolCallCount };1638}16391640private validateToolMessages(messages: Raw.ChatMessage[], options?: { stripOrphanedToolCalls?: boolean }): Raw.ChatMessage[] {1641const { messages: filtered, filterReasons, strippedToolCallCount } = ToolCallingLoop.validateToolMessagesCore(messages, options);16421643if (filterReasons.length || strippedToolCallCount > 0) {1644const allReasons = strippedToolCallCount > 0 ? [...filterReasons, `orphanedToolCalls:${strippedToolCallCount}`] : filterReasons;1645const filterReasonsStr = allReasons.join(', ');1646this._logService.warn('Filtered invalid tool messages: ' + filterReasonsStr);1647/* __GDPR__1648"toolCalling.invalidToolMessages" : {1649"owner": "roblourens",1650"comment": "Provides info about invalid tool messages that were rendered in a prompt",1651"filterReasons": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Reasons for filtering the messages and stripping orphaned tool calls." },1652"filterCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Count of filtered messages." },1653"strippedToolCallCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Count of orphaned tool_calls stripped from assistant messages." }1654}1655*/1656this._telemetryService.sendMSFTTelemetryEvent('toolCalling.invalidToolMessages', {1657filterReasons: filterReasonsStr,1658}, {1659filterCount: filterReasons.length,1660strippedToolCallCount,1661});1662}16631664return filtered;1665}16661667private async buildPrompt2(buildPromptContext: IBuildPromptContext, stream: ChatResponseStream | undefined, token: CancellationToken): Promise<IBuildPromptResult> {1668const progress: Progress<ChatResponseReferencePart | ChatResponseProgressPart> = {1669report(obj) {1670stream?.push(obj);1671}1672};16731674const buildPromptResult = await this.buildPrompt(buildPromptContext, progress, token);1675for (const metadata of buildPromptResult.metadata.getAll(ToolResultMetadata)) {1676this.logToolResult(buildPromptContext, metadata);1677this.toolCallResults[metadata.toolCallId] = metadata.result;1678}16791680if (buildPromptResult.metadata.getAll(ToolResultMetadata).some(r => r.isCancelled)) {1681throw new CancellationError();1682}16831684return buildPromptResult;1685}168616871688private logToolResult(buildPromptContext: IBuildPromptContext, metadata: ToolResultMetadata) {1689if (this.toolCallResults[metadata.toolCallId]) {1690return; // already logged this on a previous turn1691}16921693const lastTurn = this.toolCallRounds.at(-1);1694let originalCall = lastTurn?.toolCalls.find(tc => tc.id === metadata.toolCallId);1695if (!originalCall) {1696const byRef = buildPromptContext.tools?.toolReferences.find(r => r.id === metadata.toolCallId);1697if (byRef) {1698originalCall = { id: byRef.id, arguments: JSON.stringify(byRef.input), name: byRef.name };1699}1700}17011702if (originalCall) {1703this._requestLogger.logToolCall(originalCall.id || generateUuid(), originalCall.name, originalCall.arguments, metadata.result, lastTurn?.thinking);1704}1705}1706}17071708async function finalizeStreams(streams: readonly ChatResponseStream[]) {1709for (const stream of streams) {1710await tryFinalizeResponseStream(stream);1711}1712}17131714export class EmptyPromptError extends Error {1715constructor() {1716super('Empty prompt');1717}1718}17191720export interface IToolCallSingleResult {1721response: ChatResponse;1722round: IToolCallRound;1723chatResult?: ChatResult; // TODO should just be metadata1724hadIgnoredFiles: boolean;1725lastRequestMessages: Raw.ChatMessage[];1726availableTools: readonly LanguageModelToolInformation[];1727}17281729export interface IToolCallLoopResult extends IToolCallSingleResult {1730toolCallRounds: IToolCallRound[];1731toolCallResults: Record<string, LanguageModelToolResult2>;1732}173317341735