Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as l10n from '@vscode/l10n';
7
import { Raw } from '@vscode/prompt-tsx';
8
import type { CancellationToken, ChatRequest, ChatResponseProgressPart, ChatResponseReferencePart, ChatResponseStream, ChatResult, LanguageModelToolInformation, Progress } from 'vscode';
9
import { IAuthenticationChatUpgradeService } from '../../../platform/authentication/common/authenticationUpgrade';
10
import { IChatDebugFileLoggerService } from '../../../platform/chat/common/chatDebugFileLoggerService';
11
import { IChatHookService, SessionStartHookInput, SessionStartHookOutput, StopHookInput, StopHookOutput, SubagentStartHookInput, SubagentStartHookOutput, SubagentStopHookInput, SubagentStopHookOutput } from '../../../platform/chat/common/chatHookService';
12
import { FetchStreamSource, IResponsePart } from '../../../platform/chat/common/chatMLFetcher';
13
import { CanceledResult, ChatFetchResponseType, ChatResponse } from '../../../platform/chat/common/commonTypes';
14
import { IHistoricalTurn, ISessionTranscriptService, ToolRequest } from '../../../platform/chat/common/sessionTranscriptService';
15
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
16
import { isAnthropicFamily, isGeminiFamily } from '../../../platform/endpoint/common/chatModelCapabilities';
17
import { IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider';
18
import { rawPartAsThinkingData } from '../../../platform/endpoint/common/thinkingDataContainer';
19
import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';
20
import { IGitService } from '../../../platform/git/common/gitService';
21
import { ILogService } from '../../../platform/log/common/logService';
22
import { isOpenAIContextManagementResponse, OpenAiFunctionDef } from '../../../platform/networking/common/fetch';
23
import { IMakeChatRequestOptions } from '../../../platform/networking/common/networking';
24
import { OpenAIContextManagementResponse } from '../../../platform/networking/common/openai';
25
import { CopilotChatAttr, emitAgentTurnEvent, emitSessionStartEvent, GenAiAttr, GenAiMetrics, GenAiOperationName, GenAiProviderName, resolveWorkspaceOTelMetadata, StdAttr, truncateForOTel, workspaceMetadataToOTelAttributes } from '../../../platform/otel/common/index';
26
import { IOTelService, ISpanHandle, SpanKind, SpanStatusCode } from '../../../platform/otel/common/otelService';
27
import { IRequestLogger } from '../../../platform/requestLogger/common/requestLogger';
28
import { getCurrentCapturingToken } from '../../../platform/requestLogger/node/requestLogger';
29
import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService';
30
import { ITelemetryService } from '../../../platform/telemetry/common/telemetry';
31
import { computePromptTokenDetails } from '../../../platform/tokenizer/node/promptTokenDetails';
32
import { tryFinalizeResponseStream } from '../../../util/common/chatResponseStreamImpl';
33
import { ChatExtPerfMark, markChatExt } from '../../../util/common/performance';
34
import { DeferredPromise, timeout } from '../../../util/vs/base/common/async';
35
import { CancellationError, isCancellationError } from '../../../util/vs/base/common/errors';
36
import { Emitter } from '../../../util/vs/base/common/event';
37
import { Disposable } from '../../../util/vs/base/common/lifecycle';
38
import { Mutable } from '../../../util/vs/base/common/types';
39
import { URI } from '../../../util/vs/base/common/uri';
40
import { generateUuid } from '../../../util/vs/base/common/uuid';
41
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
42
import { ChatResponsePullRequestPart, LanguageModelDataPart2, LanguageModelPartAudience, LanguageModelToolResult2, MarkdownString } from '../../../vscodeTypes';
43
import { InteractionOutcomeComputer } from '../../inlineChat/node/promptCraftingTypes';
44
import { ChatVariablesCollection } from '../../prompt/common/chatVariablesCollection';
45
import { AnthropicTokenUsageMetadata, Conversation, IResultMetadata, ResponseStreamParticipant, TurnStatus } from '../../prompt/common/conversation';
46
import { IBuildPromptContext, InternalToolReference, IToolCall, IToolCallRound } from '../../prompt/common/intents';
47
import { cancelText, IToolCallIterationIncrease } from '../../prompt/common/specialRequestTypes';
48
import { ThinkingDataItem, ToolCallRound } from '../../prompt/common/toolCallRound';
49
import { IBuildPromptResult, IResponseProcessor } from '../../prompt/node/intents';
50
import { PseudoStopStartResponseProcessor } from '../../prompt/node/pseudoStartStopConversationCallback';
51
import { ResponseProcessorContext } from '../../prompt/node/responseProcessorContext';
52
import { SummarizedConversationHistoryMetadata } from '../../prompts/node/agent/summarizedConversationHistory';
53
import { ToolFailureEncountered, ToolResultMetadata } from '../../prompts/node/panel/toolCalling';
54
import { ToolName } from '../../tools/common/toolNames';
55
import { IToolsService, ToolCallCancelledError } from '../../tools/common/toolsService';
56
import { ReadFileParams } from '../../tools/node/readFileTool';
57
import { isHookAbortError, processHookResults } from './hookResultProcessor';
58
import { applyConfiguredPromptOverrides } from './promptOverride';
59
60
export const enum ToolCallLimitBehavior {
61
Confirm,
62
Stop,
63
}
64
65
export interface IToolCallingLoopOptions {
66
conversation: Conversation;
67
toolCallLimit: number;
68
/**
69
* What to do when the limit is hit. Defaults to {@link ToolCallLimitBehavior.Stop}.
70
* If set to confirm you can use {@link isToolCallLimitCancellation} and
71
* {@link isToolCallIterationIncrease} to get followup data.
72
*/
73
onHitToolCallLimit?: ToolCallLimitBehavior;
74
/**
75
* "mixins" that can be used to wrap the response stream.
76
*/
77
streamParticipants?: ResponseStreamParticipant[];
78
/**
79
* Optional custom response stream processor.
80
*/
81
responseProcessor?: IResponseProcessor;
82
/** Context for the {@link InteractionOutcomeComputer} */
83
interactionContext?: URI;
84
/**
85
* The current chat request
86
*/
87
request: ChatRequest;
88
/**
89
* A getter that returns true if VS Code has requested the extension to
90
* gracefully yield. When set, it's likely that the editor will immediately
91
* follow up with a new request in the same conversation.
92
*/
93
yieldRequested?: () => boolean;
94
}
95
96
export interface IToolCallingResponseEvent {
97
response: ChatResponse;
98
interactionOutcome: InteractionOutcomeComputer;
99
toolCalls: IToolCall[];
100
}
101
102
export interface IToolCallingBuiltPromptEvent {
103
result: IBuildPromptResult;
104
tools: LanguageModelToolInformation[];
105
}
106
107
export type ToolCallingLoopFetchOptions = Required<Pick<IMakeChatRequestOptions, 'messages' | 'finishedCb' | 'requestOptions' | 'userInitiatedRequest' | 'turnId'>> & Pick<IMakeChatRequestOptions, 'modelCapabilities' | 'summarizedAtRoundId'>;
108
109
interface StartHookResult {
110
/**
111
* Additional context to add to the agent's context, if any.
112
*/
113
readonly additionalContext?: string;
114
}
115
116
interface StopHookResult {
117
/**
118
* Whether the agent should continue (not stop).
119
*/
120
readonly shouldContinue: boolean;
121
/**
122
* The reasons the agent should continue, if shouldContinue is true.
123
* Multiple hooks may block with different reasons.
124
*/
125
readonly reasons?: readonly string[];
126
}
127
128
interface SubagentStartHookResult {
129
/**
130
* Additional context to add to the subagent's context, if any.
131
*/
132
readonly additionalContext?: string;
133
}
134
135
interface SubagentStopHookResult {
136
/**
137
* Whether the subagent should continue (not stop).
138
*/
139
readonly shouldContinue: boolean;
140
/**
141
* The reasons the subagent should continue, if shouldContinue is true.
142
* Multiple hooks may block with different reasons.
143
*/
144
readonly reasons?: readonly string[];
145
}
146
147
/**
148
* Formats a hook context message from blocking reasons.
149
* @param reasons The reasons hooks blocked the agent from stopping
150
* @returns A formatted message for the model to address the requirements
151
*/
152
function formatHookContext(reasons: readonly string[]): string {
153
if (reasons.length === 1) {
154
return `You were about to complete but a hook blocked you with the following message: "${reasons[0]}". Please address this requirement before completing.`;
155
}
156
const formattedReasons = reasons.map((reason, i) => `${i + 1}. ${reason}`).join('\n');
157
return `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.`;
158
}
159
160
/**
161
* This is a base class that can be used to implement a tool calling loop
162
* against a model. It requires only that you build a prompt and is decoupled
163
* from intents (i.e. the {@link DefaultIntentRequestHandler}), allowing easier
164
* programmatic use.
165
*/
166
export abstract class ToolCallingLoop<TOptions extends IToolCallingLoopOptions = IToolCallingLoopOptions> extends Disposable {
167
private static NextToolCallId = Date.now();
168
169
private static readonly TASK_COMPLETE_TOOL_NAME = 'task_complete';
170
171
private toolCallResults: Record<string, LanguageModelToolResult2> = Object.create(null);
172
private toolCallRounds: IToolCallRound[] = [];
173
private stopHookReason: string | undefined;
174
private additionalHookContext: string | undefined;
175
private stopHookUserInitiated = false;
176
private agentSpan: ISpanHandle | undefined;
177
private chatSessionIdForTools: string | undefined;
178
private toolsAvailableEmitted = false;
179
private lastHeaderRequestId: string | undefined;
180
181
public appendAdditionalHookContext(context: string): void {
182
if (!context) {
183
return;
184
}
185
this.additionalHookContext = this.additionalHookContext
186
? `${this.additionalHookContext}\n${context}`
187
: context;
188
}
189
190
private readonly _onDidBuildPrompt = this._register(new Emitter<{ result: IBuildPromptResult; tools: LanguageModelToolInformation[]; promptTokenLength: number; toolTokenCount: number }>());
191
public readonly onDidBuildPrompt = this._onDidBuildPrompt.event;
192
193
private readonly _onDidReceiveResponse = this._register(new Emitter<IToolCallingResponseEvent>());
194
public readonly onDidReceiveResponse = this._onDidReceiveResponse.event;
195
196
protected get currentToolCallRounds(): readonly IToolCallRound[] {
197
return this.toolCallRounds;
198
}
199
200
private get turn() {
201
return this.options.conversation.getLatestTurn();
202
}
203
204
protected get agentName(): string | undefined {
205
return (this.options.request as { subAgentName?: string }).subAgentName
206
?? (this.options.request as { participant?: string }).participant;
207
}
208
209
constructor(
210
protected readonly options: TOptions,
211
@IInstantiationService private readonly _instantiationService: IInstantiationService,
212
@IEndpointProvider private readonly _endpointProvider: IEndpointProvider,
213
@ILogService protected readonly _logService: ILogService,
214
@IRequestLogger private readonly _requestLogger: IRequestLogger,
215
@IAuthenticationChatUpgradeService private readonly _authenticationChatUpgradeService: IAuthenticationChatUpgradeService,
216
@ITelemetryService protected readonly _telemetryService: ITelemetryService,
217
@IConfigurationService protected readonly _configurationService: IConfigurationService,
218
@IExperimentationService protected readonly _experimentationService: IExperimentationService,
219
@IChatHookService private readonly _chatHookService: IChatHookService,
220
@ISessionTranscriptService protected readonly _sessionTranscriptService: ISessionTranscriptService,
221
@IFileSystemService private readonly _fileSystemService: IFileSystemService,
222
@IOTelService protected readonly _otelService: IOTelService,
223
@IGitService private readonly _gitService: IGitService,
224
) {
225
super();
226
}
227
228
/** Builds a prompt with the context. */
229
protected abstract buildPrompt(buildPromptContext: IBuildPromptContext, progress: Progress<ChatResponseReferencePart | ChatResponseProgressPart>, token: CancellationToken): Promise<IBuildPromptResult>;
230
231
/** Gets the tools that should be callable by the model. */
232
protected abstract getAvailableTools(outputStream: ChatResponseStream | undefined, token: CancellationToken): Promise<LanguageModelToolInformation[]>;
233
234
/** Creates the prompt context for the request. */
235
protected createPromptContext(availableTools: LanguageModelToolInformation[], outputStream: ChatResponseStream | undefined): Mutable<IBuildPromptContext> {
236
const { request } = this.options;
237
const chatVariables = new ChatVariablesCollection(request.references);
238
239
const isContinuation = this.turn.isContinuation || !!this.stopHookReason;
240
let query: string;
241
let hasStopHookQuery = false;
242
if (this.stopHookReason) {
243
// Include the stop hook reason as a user message so the model knows what to do.
244
// Wrap with context so the model understands it needs to take action.
245
query = formatHookContext([this.stopHookReason]);
246
this._logService.info(`[ToolCallingLoop] Using stop hook reason as query: ${query}`);
247
this.stopHookReason = undefined; // Clear after use
248
hasStopHookQuery = true;
249
} else if (isContinuation) {
250
query = 'Please continue';
251
} else {
252
query = this.turn.request.message;
253
}
254
// exclude turns from the history that errored due to prompt filtration
255
const history = this.options.conversation.turns.slice(0, -1).filter(turn => turn.responseStatus !== TurnStatus.PromptFiltered);
256
257
return {
258
requestId: this.turn.id,
259
query,
260
history,
261
toolCallResults: this.toolCallResults,
262
toolCallRounds: this.toolCallRounds,
263
editedFileEvents: this.options.request.editedFileEvents,
264
request: this.options.request,
265
stream: outputStream,
266
conversation: this.options.conversation,
267
chatVariables,
268
tools: {
269
toolReferences: request.toolReferences.map(InternalToolReference.from),
270
toolInvocationToken: request.toolInvocationToken,
271
availableTools
272
},
273
isContinuation,
274
hasStopHookQuery,
275
modeInstructions: this.options.request.modeInstructions2,
276
additionalHookContext: this.additionalHookContext,
277
parentHeaderRequestId: this.lastHeaderRequestId,
278
};
279
}
280
281
protected abstract fetch(
282
options: ToolCallingLoopFetchOptions,
283
token: CancellationToken
284
): Promise<ChatResponse>;
285
286
/**
287
* The context window widget in chat input should represent only the parent request.
288
* Subagent usage must stay isolated to avoid inflating the parent widget.
289
*/
290
private shouldReportUsageToContextWidget(): boolean {
291
return !this.options.request.subAgentInvocationId;
292
}
293
294
/**
295
* Called before the loop stops to give hooks a chance to block the stop.
296
* @param input The stop hook input containing stop_hook_active flag
297
* @param outputStream The output stream for displaying messages
298
* @param token Cancellation token
299
* @returns Result indicating whether to continue and the reasons
300
*/
301
protected async executeStopHook(input: StopHookInput, sessionId: string, outputStream: ChatResponseStream | undefined, token: CancellationToken): Promise<StopHookResult> {
302
try {
303
const results = await this._chatHookService.executeHook('Stop', this.options.request.hooks, input, sessionId, token);
304
305
const blockingReasons = new Set<string>();
306
processHookResults({
307
hookType: 'Stop',
308
results,
309
outputStream,
310
logService: this._logService,
311
onSuccess: (output) => {
312
if (typeof output === 'object' && output !== null) {
313
const hookOutput = output as StopHookOutput;
314
const specific = hookOutput.hookSpecificOutput;
315
this._logService.trace(`[ToolCallingLoop] Checking hook output: decision=${specific?.decision}, reason=${specific?.reason}`);
316
if (specific?.decision === 'block' && specific.reason) {
317
this._logService.trace(`[ToolCallingLoop] Stop hook blocked: ${specific.reason}`);
318
blockingReasons.add(specific.reason);
319
}
320
}
321
},
322
// Collect errors as blocking reasons (stderr from exit code != 0)
323
onError: (errorMessage) => {
324
if (errorMessage) {
325
this._logService.trace(`[ToolCallingLoop] Stop hook error collected as blocking reason: ${errorMessage}`);
326
blockingReasons.add(errorMessage);
327
}
328
},
329
});
330
331
if (blockingReasons.size > 0) {
332
return { shouldContinue: true, reasons: [...blockingReasons] };
333
}
334
return { shouldContinue: false };
335
} catch (error) {
336
if (isHookAbortError(error)) {
337
throw error;
338
}
339
this._logService.error('[ToolCallingLoop] Error executing Stop hook', error);
340
return { shouldContinue: false };
341
}
342
}
343
344
/**
345
* Shows a message when the stop hook blocks the agent from stopping.
346
* Override in subclasses to customize the display.
347
* @param outputStream The output stream for displaying messages
348
* @param reasons The reasons the stop hook blocked stopping
349
*/
350
protected showStopHookBlockedMessage(outputStream: ChatResponseStream | undefined, reasons: readonly string[]): void {
351
if (outputStream) {
352
if (reasons.length === 1) {
353
outputStream.hookProgress('Stop', reasons[0]);
354
} else {
355
const formattedReasons = reasons.map((r, i) => `${i + 1}. ${r}`).join('\n');
356
outputStream.hookProgress('Stop', formattedReasons);
357
}
358
}
359
this._logService.trace(`[ToolCallingLoop] Stop hook blocked stopping: ${reasons.join('; ')}`);
360
}
361
362
private static readonly MAX_AUTOPILOT_RETRIES = 3;
363
private static readonly MAX_AUTOPILOT_ITERATIONS = 5;
364
private autopilotRetryCount = 0;
365
private autopilotIterationCount = 0;
366
367
private taskCompleted = false;
368
private autopilotStopHookActive = false;
369
private autopilotProgressDeferred: DeferredPromise<void> | undefined;
370
371
/**
372
* Autopilot stop hook — the model needs to call `task_complete` to signal it's done.
373
* If it stops without calling it, we nudge it to keep going. Returns a continuation
374
* message or `undefined` to let the loop stop.
375
*/
376
protected shouldAutopilotContinue(result: IToolCallSingleResult): string | undefined {
377
if (this.taskCompleted) {
378
this._logService.info('[ToolCallingLoop] Autopilot: task_complete was called, stopping');
379
return undefined;
380
}
381
382
// might have called task_complete alongside other tools in an earlier round
383
const calledTaskComplete = this.toolCallRounds.some(
384
round => round.toolCalls.some(tc => tc.name === ToolCallingLoop.TASK_COMPLETE_TOOL_NAME)
385
);
386
if (calledTaskComplete) {
387
this.taskCompleted = true;
388
this._logService.info('[ToolCallingLoop] Autopilot: task_complete found in history, stopping');
389
return undefined;
390
}
391
392
// If the model produced a substantive text response with no tool calls, treat it
393
// as a final summary and let the loop stop. Nudging in this case typically just
394
// wastes a turn — the model considers itself done. The user can always continue
395
// the conversation if it wasn't.
396
if (result.round.toolCalls.length === 0 && result.round.response.trim().length > 0) {
397
this._logService.info('[ToolCallingLoop] Autopilot: model produced a text-only response, treating as done');
398
return undefined;
399
}
400
401
// safety valve — only give up after exhausting all continuation attempts
402
if (this.autopilotIterationCount >= ToolCallingLoop.MAX_AUTOPILOT_ITERATIONS) {
403
this._logService.info(`[ToolCallingLoop] Autopilot: hit max iterations (${ToolCallingLoop.MAX_AUTOPILOT_ITERATIONS}), letting it stop`);
404
return undefined;
405
}
406
407
// If we already nudged once and the model still produced no tool calls, the model
408
// is effectively done — further nudges just waste tokens. Bail out and let the
409
// loop stop.
410
if (this.autopilotStopHookActive && result.round.toolCalls.length === 0) {
411
this._logService.info('[ToolCallingLoop] Autopilot: prior nudge produced no tool calls, stopping to avoid wasted requests');
412
return undefined;
413
}
414
415
this.autopilotIterationCount++;
416
return 'You have not yet marked the task as complete using the task_complete tool. ' +
417
'You must call task_complete when done — whether the task involved code changes, answering a question, or any other interaction.\n\n' +
418
'Do NOT repeat or restate your previous response. Pick up where you left off.\n\n' +
419
'If you were planning, stop planning and start implementing. ' +
420
'You are not done until you have fully completed the task.\n\n' +
421
'IMPORTANT: Do NOT call task_complete if:\n' +
422
'- You have open questions or ambiguities — make good decisions and keep working\n' +
423
'- You encountered an error — try to resolve it or find an alternative approach\n' +
424
'- There are remaining steps — complete them first\n\n' +
425
'When you ARE done, first provide a brief text summary of what was accomplished, then call task_complete. ' +
426
'Both the summary message and the tool call are required.\n\n' +
427
'Keep working autonomously until the task is truly finished, then call task_complete.';
428
}
429
430
/**
431
* Shows a progress spinner in the chat stream while autopilot continues.
432
* The spinner resolves to the past-tense message when {@link resolveAutopilotProgress} is called.
433
*/
434
private showAutopilotProgress(outputStream: ChatResponseStream | undefined, message: string, pastTenseMessage: string): void {
435
this.resolveAutopilotProgress();
436
const deferred = new DeferredPromise<void>();
437
this.autopilotProgressDeferred = deferred;
438
outputStream?.progress(message, async () => {
439
await deferred.p;
440
return pastTenseMessage;
441
});
442
}
443
444
/**
445
* Resolves any pending autopilot progress spinner, transitioning it to its past-tense message.
446
*/
447
private resolveAutopilotProgress(): void {
448
if (this.autopilotProgressDeferred) {
449
this.autopilotProgressDeferred.complete(undefined);
450
this.autopilotProgressDeferred = undefined;
451
}
452
}
453
454
/**
455
* Ensures the `task_complete` tool is present in the available tools when running in
456
* autopilot mode. If it's missing (e.g. filtered out by the tool picker), it's resolved
457
* from the tools service and appended so the model can always signal completion.
458
*/
459
protected ensureAutopilotTools(availableTools: LanguageModelToolInformation[]): LanguageModelToolInformation[] {
460
if (this.options.request.permissionLevel !== 'autopilot') {
461
return availableTools;
462
}
463
if (availableTools.some(t => t.name === ToolCallingLoop.TASK_COMPLETE_TOOL_NAME)) {
464
return availableTools;
465
}
466
const taskCompleteTool = this._instantiationService.invokeFunction(
467
accessor => accessor.get(IToolsService).getTool(ToolCallingLoop.TASK_COMPLETE_TOOL_NAME)
468
);
469
if (taskCompleteTool) {
470
this._logService.info('[ToolCallingLoop] Added task_complete tool for autopilot mode');
471
return [...availableTools, taskCompleteTool];
472
}
473
this._logService.warn('[ToolCallingLoop] task_complete tool not found — autopilot completion may not work');
474
return availableTools;
475
}
476
477
/**
478
* Whether the loop should auto-retry after a failed fetch in auto-approve/autopilot mode.
479
* Does not retry rate-limited, quota-exceeded, or cancellation errors.
480
*/
481
private shouldAutoRetry(response: ChatResponse): boolean {
482
const permLevel = this.options.request.permissionLevel;
483
if (permLevel !== 'autoApprove' && permLevel !== 'autopilot') {
484
return false;
485
}
486
if (this.autopilotRetryCount >= ToolCallingLoop.MAX_AUTOPILOT_RETRIES) {
487
return false;
488
}
489
switch (response.type) {
490
case ChatFetchResponseType.RateLimited:
491
case ChatFetchResponseType.QuotaExceeded:
492
case ChatFetchResponseType.Canceled:
493
case ChatFetchResponseType.OffTopic:
494
return false;
495
default:
496
return response.type !== ChatFetchResponseType.Success;
497
}
498
}
499
500
/**
501
* Called when a session starts to allow hooks to provide additional context.
502
* @param input The session start hook input containing source
503
* @param outputStream The output stream for displaying messages
504
* @param token Cancellation token
505
* @returns Result containing additional context from hooks
506
*/
507
protected async executeSessionStartHook(input: SessionStartHookInput, sessionId: string, outputStream: ChatResponseStream | undefined, token: CancellationToken): Promise<StartHookResult> {
508
try {
509
const results = await this._chatHookService.executeHook('SessionStart', this.options.request.hooks, input, sessionId, token);
510
511
const additionalContexts: string[] = [];
512
processHookResults({
513
hookType: 'SessionStart',
514
results,
515
outputStream,
516
logService: this._logService,
517
onSuccess: (output) => {
518
if (typeof output === 'object' && output !== null) {
519
const hookOutput = output as SessionStartHookOutput;
520
const additionalContext = hookOutput.hookSpecificOutput?.additionalContext;
521
if (additionalContext) {
522
additionalContexts.push(additionalContext);
523
this._logService.trace(`[ToolCallingLoop] SessionStart hook provided context: ${additionalContext.substring(0, 100)}...`);
524
}
525
}
526
},
527
// SessionStart blocking errors and stopReason are silently ignored
528
ignoreErrors: true,
529
});
530
531
return {
532
additionalContext: additionalContexts.length > 0 ? additionalContexts.join('\n') : undefined
533
};
534
} catch (error) {
535
if (isHookAbortError(error)) {
536
throw error;
537
}
538
this._logService.error('[ToolCallingLoop] Error executing SessionStart hook', error);
539
return {};
540
}
541
}
542
543
/**
544
* Called when a subagent starts to allow hooks to provide additional context.
545
* @param input The subagent start hook input containing agent_id and agent_type
546
* @param outputStream The output stream for displaying messages
547
* @param token Cancellation token
548
* @returns Result containing additional context from hooks
549
*/
550
protected async executeSubagentStartHook(input: SubagentStartHookInput, sessionId: string, outputStream: ChatResponseStream | undefined, token: CancellationToken): Promise<SubagentStartHookResult> {
551
try {
552
const results = await this._chatHookService.executeHook('SubagentStart', this.options.request.hooks, input, sessionId, token);
553
554
const additionalContexts: string[] = [];
555
processHookResults({
556
hookType: 'SubagentStart',
557
results,
558
outputStream,
559
logService: this._logService,
560
onSuccess: (output) => {
561
if (typeof output === 'object' && output !== null) {
562
const hookOutput = output as SubagentStartHookOutput;
563
const additionalContext = hookOutput.hookSpecificOutput?.additionalContext;
564
if (additionalContext) {
565
additionalContexts.push(additionalContext);
566
this._logService.trace(`[ToolCallingLoop] SubagentStart hook provided context: ${additionalContext.substring(0, 100)}...`);
567
}
568
}
569
},
570
// SubagentStart blocking errors and stopReason are silently ignored
571
ignoreErrors: true,
572
});
573
574
return {
575
additionalContext: additionalContexts.length > 0 ? additionalContexts.join('\n') : undefined
576
};
577
} catch (error) {
578
if (isHookAbortError(error)) {
579
throw error;
580
}
581
this._logService.error('[ToolCallingLoop] Error executing SubagentStart hook', error);
582
return {};
583
}
584
}
585
586
/**
587
* Called before a subagent stops to give hooks a chance to block the stop.
588
* @param input The subagent stop hook input containing agent_id, agent_type, and stop_hook_active flag
589
* @param outputStream The output stream for displaying messages
590
* @param token Cancellation token
591
* @returns Result indicating whether to continue and the reasons
592
*/
593
protected async executeSubagentStopHook(input: SubagentStopHookInput, sessionId: string, outputStream: ChatResponseStream | undefined, token: CancellationToken): Promise<SubagentStopHookResult> {
594
try {
595
const results = await this._chatHookService.executeHook('SubagentStop', this.options.request.hooks, input, sessionId, token);
596
597
const blockingReasons = new Set<string>();
598
processHookResults({
599
hookType: 'SubagentStop',
600
results,
601
outputStream,
602
logService: this._logService,
603
onSuccess: (output) => {
604
if (typeof output === 'object' && output !== null) {
605
const hookOutput = output as SubagentStopHookOutput;
606
const specific = hookOutput.hookSpecificOutput;
607
this._logService.trace(`[ToolCallingLoop] Checking SubagentStop hook output: decision=${specific?.decision}, reason=${specific?.reason}`);
608
if (specific?.decision === 'block' && specific.reason) {
609
this._logService.trace(`[ToolCallingLoop] SubagentStop hook blocked: ${specific.reason}`);
610
blockingReasons.add(specific.reason);
611
}
612
}
613
},
614
// Collect errors as blocking reasons (stderr from exit code != 0)
615
onError: (errorMessage) => {
616
if (errorMessage) {
617
this._logService.trace(`[ToolCallingLoop] SubagentStop hook error collected as blocking reason: ${errorMessage}`);
618
blockingReasons.add(errorMessage);
619
}
620
},
621
});
622
623
if (blockingReasons.size > 0) {
624
return { shouldContinue: true, reasons: [...blockingReasons] };
625
}
626
return { shouldContinue: false };
627
} catch (error) {
628
if (isHookAbortError(error)) {
629
throw error;
630
}
631
this._logService.error('[ToolCallingLoop] Error executing SubagentStop hook', error);
632
return { shouldContinue: false };
633
}
634
}
635
636
/**
637
* Shows a message when the subagent stop hook blocks the subagent from stopping.
638
* Override in subclasses to customize the display.
639
* @param outputStream The output stream for displaying messages
640
* @param reasons The reasons the subagent stop hook blocked stopping
641
*/
642
protected showSubagentStopHookBlockedMessage(outputStream: ChatResponseStream | undefined, reasons: readonly string[]): void {
643
if (outputStream) {
644
if (reasons.length === 1) {
645
outputStream.hookProgress('SubagentStop', reasons[0]);
646
} else {
647
const formattedReasons = reasons.map((r, i) => `${i + 1}. ${r}`).join('\n');
648
outputStream.hookProgress('SubagentStop', formattedReasons);
649
}
650
}
651
this._logService.trace(`[ToolCallingLoop] SubagentStop hook blocked stopping: ${reasons.join('; ')}`);
652
}
653
654
private throwIfCancelled(token: CancellationToken) {
655
if (token.isCancellationRequested) {
656
this.turn.setResponse(TurnStatus.Cancelled, undefined, undefined, CanceledResult);
657
throw new CancellationError();
658
}
659
}
660
661
/**
662
* Executes start hooks (SessionStart for regular sessions, SubagentStart for subagents).
663
* Should be called before run() to allow hooks to provide context before the first prompt.
664
*
665
* - For subagents: Always executes SubagentStart hook
666
* - For regular sessions: Only executes SessionStart hook on the first turn
667
* @throws HookAbortError if a hook requests the session/subagent to abort
668
*/
669
public async runStartHooks(outputStream: ChatResponseStream | undefined, token: CancellationToken): Promise<void> {
670
const sessionId = this.options.conversation.sessionId;
671
const hasHooks = this.options.request.hasHooksEnabled;
672
673
// Report which hooks are configured for this request
674
this._chatHookService.logConfiguredHooks(this.options.request.hooks);
675
676
// Execute SubagentStart hook for subagent requests, or SessionStart hook for first turn of regular sessions
677
if (this.options.request.subAgentInvocationId) {
678
const startHookResult = await this.executeSubagentStartHook({
679
agent_id: this.options.request.subAgentInvocationId,
680
agent_type: this.options.request.subAgentName ?? 'default',
681
}, sessionId, outputStream, token);
682
if (startHookResult.additionalContext) {
683
this.additionalHookContext = startHookResult.additionalContext;
684
this._logService.info(`[ToolCallingLoop] SubagentStart hook provided context for subagent ${this.options.request.subAgentInvocationId}`);
685
}
686
} else {
687
const isFirstTurn = this.options.conversation.turns.length === 1;
688
689
if (hasHooks) {
690
// Build history from prior turns (excluding the current one) for transcript replay
691
const priorTurns = this.options.conversation.turns.slice(0, -1);
692
const history: IHistoricalTurn[] = priorTurns.map(turn => ({
693
userMessage: turn.request.message,
694
timestamp: turn.startTime,
695
rounds: turn.rounds.map(round => ({
696
response: round.response,
697
toolCalls: round.toolCalls.map(tc => ({
698
name: tc.name,
699
arguments: tc.arguments,
700
id: tc.id,
701
})),
702
reasoningText: round.thinking
703
? (Array.isArray(round.thinking.text) ? round.thinking.text.join('') : round.thinking.text)
704
: undefined,
705
timestamp: round.timestamp,
706
})),
707
}));
708
709
// Start the transcript (will replay history if no file exists yet)
710
await this._sessionTranscriptService.startSession(sessionId, undefined, history.length > 0 ? history : undefined);
711
}
712
713
if (isFirstTurn) {
714
const startHookResult = await this.executeSessionStartHook({
715
source: 'new',
716
model: this.options.request.model?.id ?? 'unknown',
717
agent_type: this.agentName,
718
}, sessionId, outputStream, token);
719
if (startHookResult.additionalContext) {
720
this.additionalHookContext = startHookResult.additionalContext;
721
this._logService.info('[ToolCallingLoop] SessionStart hook provided context for session');
722
}
723
}
724
}
725
726
// Log the user message for the transcript (no-ops if session was not started)
727
this._sessionTranscriptService.logUserMessage(
728
sessionId,
729
this.turn.request.message,
730
);
731
}
732
733
public async run(outputStream: ChatResponseStream | undefined, token: CancellationToken): Promise<IToolCallLoopResult> {
734
const agentName = this.agentName ?? 'GitHub Copilot Chat';
735
736
// Extract custom mode name for debug logging (kept separate from agentName to avoid metric cardinality)
737
const modeInstructions = (this.options.request as { modeInstructions2?: { name?: string; isBuiltin?: boolean } }).modeInstructions2;
738
const customModeName = modeInstructions?.name && !modeInstructions.isBuiltin ? modeInstructions.name : undefined;
739
740
// If this is a subagent request, look up the parent trace context stored by the parent agent's execute_tool span
741
// Try subAgentInvocationId first (unique per subagent, supports parallel), then request-level key
742
const subAgentInvocationId = this.options.request.subAgentInvocationId;
743
const parentRequestId = this.options.request.parentRequestId;
744
const parentTraceContext = (subAgentInvocationId
745
? this._otelService.getStoredTraceContext(`subagent:invocation:${subAgentInvocationId}`)
746
: undefined)
747
?? (() => {
748
// For request-level fallback, read and re-store so parallel subagents can all read it
749
if (!parentRequestId) { return undefined; }
750
const ctx = this._otelService.getStoredTraceContext(`subagent:request:${parentRequestId}`);
751
if (ctx) { this._otelService.storeTraceContext(`subagent:request:${parentRequestId}`, ctx); }
752
return ctx;
753
})();
754
755
// Get the VS Code chat session ID from the CapturingToken (same mechanism as old debug panel)
756
const chatSessionId = getCurrentCapturingToken()?.chatSessionId;
757
const parentChatSessionId = getCurrentCapturingToken()?.parentChatSessionId;
758
const debugLogLabel = getCurrentCapturingToken()?.debugLogLabel;
759
760
return this._otelService.startActiveSpan(
761
`invoke_agent ${agentName}`,
762
{
763
kind: SpanKind.INTERNAL,
764
attributes: {
765
[GenAiAttr.OPERATION_NAME]: GenAiOperationName.INVOKE_AGENT,
766
[GenAiAttr.PROVIDER_NAME]: GenAiProviderName.GITHUB,
767
[GenAiAttr.AGENT_NAME]: agentName,
768
[GenAiAttr.CONVERSATION_ID]: this.options.conversation.sessionId,
769
[CopilotChatAttr.SESSION_ID]: this.options.conversation.sessionId,
770
...(chatSessionId ? { [CopilotChatAttr.CHAT_SESSION_ID]: chatSessionId } : {}),
771
...(parentChatSessionId ? { [CopilotChatAttr.PARENT_CHAT_SESSION_ID]: parentChatSessionId } : {}),
772
...(debugLogLabel ? { [CopilotChatAttr.DEBUG_LOG_LABEL]: debugLogLabel } : {}),
773
...(customModeName ? { [CopilotChatAttr.MODE_NAME]: customModeName } : {}),
774
...workspaceMetadataToOTelAttributes(resolveWorkspaceOTelMetadata(this._gitService)),
775
},
776
parentTraceContext,
777
},
778
async (span) => {
779
const otelStartTime = Date.now();
780
781
// Register this session as a child of its parent so that debug
782
// log entries are routed to a dedicated child JSONL file.
783
// parentChatSessionId is only set on subagent requests
784
// (see CapturingToken setup in defaultIntentRequestHandler).
785
if (chatSessionId) {
786
const fileLogger = this._instantiationService.invokeFunction(accessor =>
787
accessor.get(IChatDebugFileLoggerService));
788
789
// Register this session as a child of its parent so that debug
790
// log entries are routed to a dedicated child JSONL file.
791
// parentChatSessionId is only set on subagent requests
792
// (see CapturingToken setup in defaultIntentRequestHandler).
793
if (parentChatSessionId) {
794
const childLabel = debugLogLabel ?? `runSubagent-${agentName}`;
795
fileLogger.startChildSession(
796
chatSessionId, parentChatSessionId, childLabel, parentTraceContext?.spanId);
797
// Also register the invoke_agent span's ID so that hook spans
798
// (whose parentSpanId is this span) are routed to the child session.
799
const invokeSpanId = span.getSpanContext()?.spanId;
800
if (invokeSpanId) {
801
fileLogger.registerSpanSession(invokeSpanId, chatSessionId);
802
}
803
} else {
804
// For top-level agent invocations (not subagents), start a debug
805
// file logging session so entries are flushed to JSONL on disk.
806
// This is idempotent — calling startSession on an already-started
807
// session just promotes it if needed.
808
fileLogger.startSession(chatSessionId).catch(() => { /* best effort */ });
809
}
810
}
811
812
// Emit session start event and metric for top-level agent invocations (not subagents)
813
if (!parentTraceContext) {
814
GenAiMetrics.incrementSessionCount(this._otelService);
815
try {
816
const endpoint = await this._endpointProvider.getChatEndpoint(this.options.request);
817
emitSessionStartEvent(this._otelService, this.options.conversation.sessionId, endpoint.model, agentName);
818
} catch {
819
emitSessionStartEvent(this._otelService, this.options.conversation.sessionId, 'unknown', agentName);
820
}
821
}
822
823
// Set request model from the endpoint
824
try {
825
const endpoint = await this._endpointProvider.getChatEndpoint(this.options.request);
826
span.setAttribute(GenAiAttr.REQUEST_MODEL, endpoint.model);
827
} catch { /* endpoint not available yet, will be set on response */ }
828
829
// Always capture user input message for the debug panel
830
{
831
const userMessage = this.turn.request.message;
832
span.setAttribute(GenAiAttr.INPUT_MESSAGES, truncateForOTel(JSON.stringify([
833
{ role: 'user', parts: [{ type: 'text', content: userMessage }] }
834
])));
835
// Set USER_REQUEST so event translator can emit user.message
836
if (userMessage) {
837
span.setAttribute(CopilotChatAttr.USER_REQUEST, truncateForOTel(userMessage));
838
}
839
// Emit user_message span event for real-time debug panel streaming
840
if (userMessage) {
841
span.addEvent('user_message', { content: userMessage, ...(chatSessionId ? { [CopilotChatAttr.CHAT_SESSION_ID]: chatSessionId } : {}) });
842
}
843
}
844
845
// Accumulate token usage across all LLM turns per GenAI agent span spec
846
let totalInputTokens = 0;
847
let totalOutputTokens = 0;
848
let totalCacheReadTokens = 0;
849
let totalCacheCreationTokens = 0;
850
let lastResolvedModel: string | undefined;
851
let turnIndex = 0;
852
const tokenListener = this.onDidReceiveResponse(({ response }) => {
853
const turnInputTokens = response.type === ChatFetchResponseType.Success ? (response.usage?.prompt_tokens || 0) : 0;
854
const turnOutputTokens = response.type === ChatFetchResponseType.Success ? (response.usage?.completion_tokens || 0) : 0;
855
if (response.type === ChatFetchResponseType.Success && response.usage) {
856
totalInputTokens += turnInputTokens;
857
totalOutputTokens += turnOutputTokens;
858
totalCacheReadTokens += (response.usage.prompt_tokens_details?.cached_tokens || 0);
859
totalCacheCreationTokens += (response.usage.prompt_tokens_details?.cache_creation_input_tokens || 0);
860
}
861
if (response.type === ChatFetchResponseType.Success && response.resolvedModel) {
862
lastResolvedModel = response.resolvedModel;
863
}
864
emitAgentTurnEvent(this._otelService, turnIndex, turnInputTokens, turnOutputTokens, 0);
865
turnIndex++;
866
});
867
868
try {
869
const result = await this._runLoop(outputStream, token, span, chatSessionId);
870
span.setAttributes({
871
[CopilotChatAttr.TURN_COUNT]: result.toolCallRounds.length,
872
[GenAiAttr.USAGE_INPUT_TOKENS]: totalInputTokens,
873
[GenAiAttr.USAGE_OUTPUT_TOKENS]: totalOutputTokens,
874
...(totalCacheReadTokens ? { [GenAiAttr.USAGE_CACHE_READ_INPUT_TOKENS]: totalCacheReadTokens } : {}),
875
...(totalCacheCreationTokens ? { [GenAiAttr.USAGE_CACHE_CREATION_INPUT_TOKENS]: totalCacheCreationTokens } : {}),
876
...(lastResolvedModel ? { [GenAiAttr.RESPONSE_MODEL]: lastResolvedModel } : {}),
877
});
878
// Always capture agent output message and tool definitions for the debug panel
879
{
880
const lastRound = result.toolCallRounds.at(-1);
881
if (lastRound?.response) {
882
const responseText = Array.isArray(lastRound.response) ? lastRound.response.join('') : lastRound.response;
883
span.setAttribute(GenAiAttr.OUTPUT_MESSAGES, truncateForOTel(JSON.stringify([
884
{ role: 'assistant', parts: [{ type: 'text', content: responseText }] }
885
])));
886
}
887
// Log tool definitions once on the agent span (same set across all turns).
888
// Includes `parameters` (inputSchema) per OTel GenAI semantic convention so
889
// trace viewers can render full tool signatures (issue #300318).
890
if (result.availableTools.length > 0) {
891
span.setAttribute(GenAiAttr.TOOL_DEFINITIONS, truncateForOTel(JSON.stringify(
892
result.availableTools.map(t => ({
893
type: 'function',
894
name: t.name,
895
description: t.description,
896
parameters: t.inputSchema,
897
}))
898
)));
899
}
900
}
901
span.setStatus(SpanStatusCode.OK);
902
903
// Record agent-level metrics
904
const durationSec = (Date.now() - otelStartTime) / 1000;
905
GenAiMetrics.recordAgentDuration(this._otelService, agentName, durationSec);
906
GenAiMetrics.recordAgentTurnCount(this._otelService, agentName, result.toolCallRounds.length);
907
908
return result;
909
} catch (err) {
910
span.setStatus(SpanStatusCode.ERROR, err instanceof Error ? err.message : String(err));
911
span.setAttribute(StdAttr.ERROR_TYPE, err instanceof Error ? err.constructor.name : 'Error');
912
throw err;
913
} finally {
914
tokenListener.dispose();
915
}
916
},
917
);
918
}
919
920
private async _runLoop(outputStream: ChatResponseStream | undefined, token: CancellationToken, agentSpan?: ISpanHandle, chatSessionId?: string): Promise<IToolCallLoopResult> {
921
let i = 0;
922
let lastResult: IToolCallSingleResult | undefined;
923
let lastRequestMessagesStartingIndexForRun: number | undefined;
924
let stopHookActive = false;
925
const sessionId = this.options.conversation.sessionId;
926
927
// Store span context so runOne() can emit tools_available on first call
928
this.agentSpan = agentSpan;
929
this.chatSessionIdForTools = chatSessionId;
930
this.toolsAvailableEmitted = false;
931
932
while (true) {
933
if (lastResult && i++ >= this.options.toolCallLimit) {
934
// In Autopilot mode, silently increase the limit and continue
935
// without showing the confirmation dialog, up to a hard cap.
936
const permLevel = this.options.request.permissionLevel;
937
if (permLevel === 'autopilot' && this.options.toolCallLimit < 200) {
938
this.options.toolCallLimit = Math.min(Math.round(this.options.toolCallLimit * 3 / 2), 200);
939
this.showAutopilotProgress(outputStream, l10n.t('Autopilot: extending tool call limit\u2026'), l10n.t('Autopilot extended tool call limit'));
940
} else {
941
lastResult = this.hitToolCallLimit(outputStream, lastResult);
942
break;
943
}
944
}
945
946
// Check if VS Code has requested we gracefully yield before starting the next iteration.
947
// In autopilot mode, don't yield until the task is actually complete.
948
if (lastResult && this.options.yieldRequested?.()) {
949
if (this.options.request.permissionLevel !== 'autopilot' || this.taskCompleted) {
950
break;
951
}
952
}
953
954
try {
955
const turnId = String(i);
956
this._sessionTranscriptService.logAssistantTurnStart(sessionId, turnId);
957
agentSpan?.addEvent('turn_start', { turnId, ...(chatSessionId ? { [CopilotChatAttr.CHAT_SESSION_ID]: chatSessionId } : {}) });
958
this.resolveAutopilotProgress();
959
const result = await this.runOne(outputStream, i, token);
960
if (lastRequestMessagesStartingIndexForRun === undefined) {
961
lastRequestMessagesStartingIndexForRun = result.lastRequestMessages.length - 1;
962
}
963
lastResult = {
964
...result,
965
hadIgnoredFiles: lastResult?.hadIgnoredFiles || result.hadIgnoredFiles
966
};
967
968
this.toolCallRounds.push(result.round);
969
this._sessionTranscriptService.logAssistantTurnEnd(sessionId, turnId);
970
agentSpan?.addEvent('turn_end', { turnId, ...(chatSessionId ? { [CopilotChatAttr.CHAT_SESSION_ID]: chatSessionId } : {}) });
971
972
// If the model produced productive (non-task_complete) tool calls after being nudged,
973
// reset the stop hook flag and iteration count so it can be nudged again.
974
if (this.autopilotStopHookActive && result.round.toolCalls.length && !result.round.toolCalls.some(tc => tc.name === ToolCallingLoop.TASK_COMPLETE_TOOL_NAME)) {
975
this.autopilotStopHookActive = false;
976
this.autopilotIterationCount = 0;
977
}
978
979
if (!result.round.toolCalls.length || result.response.type !== ChatFetchResponseType.Success) {
980
// If cancelled, don't run stop hooks - just break immediately
981
if (token.isCancellationRequested) {
982
break;
983
}
984
985
// In auto-approve modes, auto-retry on transient errors (not rate-limited or quota-exceeded)
986
if (result.response.type !== ChatFetchResponseType.Success && this.shouldAutoRetry(result.response)) {
987
this.autopilotRetryCount++;
988
this._logService.info(`[ToolCallingLoop] Auto-retrying on error (attempt ${this.autopilotRetryCount}/${ToolCallingLoop.MAX_AUTOPILOT_RETRIES}): ${result.response.type}`);
989
if (this.options.request.permissionLevel === 'autopilot') {
990
this.showAutopilotProgress(outputStream, l10n.t('Autopilot: recovering from a request error\u2026'), l10n.t('Autopilot recovered from a request error'));
991
} else {
992
this.showAutopilotProgress(outputStream, l10n.t('Recovering from a request error\u2026'), l10n.t('Recovered from a request error'));
993
}
994
await timeout(1000, token);
995
continue;
996
}
997
998
// Before stopping, execute the stop hook
999
if (this.options.request.subAgentInvocationId) {
1000
const stopHookResult = await this.executeSubagentStopHook({
1001
agent_id: this.options.request.subAgentInvocationId,
1002
agent_type: this.options.request.subAgentName ?? 'default',
1003
stop_hook_active: stopHookActive,
1004
}, sessionId, outputStream, token);
1005
const joinedReasons = stopHookResult.reasons?.join('; ');
1006
this._logService.info(`[ToolCallingLoop] Subagent stop hook result: shouldContinue=${stopHookResult.shouldContinue}, reasons=${joinedReasons}`);
1007
if (stopHookResult.shouldContinue && stopHookResult.reasons?.length) {
1008
// The stop hook blocked stopping - show reasons and continue
1009
this.showSubagentStopHookBlockedMessage(outputStream, stopHookResult.reasons);
1010
// Store the joined reasons so it can be passed to the model in the next prompt
1011
this.stopHookReason = joinedReasons;
1012
// Also persist on the round so it survives across turns
1013
result.round.hookContext = formatHookContext(stopHookResult.reasons);
1014
this._logService.info(`[ToolCallingLoop] Subagent stop hook blocked, continuing with reasons: ${joinedReasons}`);
1015
stopHookActive = true;
1016
continue;
1017
}
1018
} else {
1019
const stopHookResult = await this.executeStopHook({ stop_hook_active: stopHookActive }, sessionId, outputStream, token);
1020
const joinedReasons = stopHookResult.reasons?.join('; ');
1021
this._logService.info(`[ToolCallingLoop] Stop hook result: shouldContinue=${stopHookResult.shouldContinue}, reasons=${joinedReasons}`);
1022
if (stopHookResult.shouldContinue && stopHookResult.reasons?.length) {
1023
// The stop hook blocked stopping - show reasons and continue
1024
this.showStopHookBlockedMessage(outputStream, stopHookResult.reasons);
1025
// Store the joined reasons so it can be passed to the model in the next prompt
1026
this.stopHookReason = joinedReasons;
1027
// Also persist on the round so it survives across turns
1028
result.round.hookContext = formatHookContext(stopHookResult.reasons);
1029
this._logService.info(`[ToolCallingLoop] Stop hook blocked, continuing with reasons: ${joinedReasons}`);
1030
stopHookActive = true;
1031
this.stopHookUserInitiated = true;
1032
continue;
1033
}
1034
}
1035
1036
// In Autopilot mode, check if the task is actually done before stopping.
1037
// This acts as an internal stop hook that keeps the agent churning until completion.
1038
if (this.options.request.permissionLevel === 'autopilot' && result.response.type === ChatFetchResponseType.Success) {
1039
const autopilotContinue = this.shouldAutopilotContinue(result);
1040
if (autopilotContinue) {
1041
this._logService.info(`[ToolCallingLoop] Autopilot internal stop hook: continuing because task may not be complete`);
1042
this.showAutopilotProgress(outputStream, l10n.t('Autopilot: verifying task is done\u2026'), l10n.t('Autopilot continued working'));
1043
this.stopHookReason = autopilotContinue;
1044
result.round.hookContext = formatHookContext([autopilotContinue]);
1045
this.autopilotStopHookActive = true;
1046
continue;
1047
}
1048
}
1049
1050
break;
1051
}
1052
} catch (e) {
1053
if (isCancellationError(e) && lastResult) {
1054
break;
1055
}
1056
1057
throw e;
1058
}
1059
}
1060
1061
this.resolveAutopilotProgress();
1062
1063
this.emitReadFileTrajectories().catch(err => {
1064
this._logService.error('Error emitting read file trajectories', err);
1065
});
1066
1067
const toolCallRoundsToDisplay = lastResult.lastRequestMessages.slice(lastRequestMessagesStartingIndexForRun ?? 0).filter((m): m is Raw.ToolChatMessage => m.role === Raw.ChatRole.Tool);
1068
for (const toolRound of toolCallRoundsToDisplay) {
1069
const result = this.toolCallResults[toolRound.toolCallId];
1070
if (result instanceof LanguageModelToolResult2) {
1071
for (const part of result.content) {
1072
if (part instanceof LanguageModelDataPart2 && part.mimeType === 'application/pull-request+json' && part.audience?.includes(LanguageModelPartAudience.User)) {
1073
const data: { uri: string; title: string; description: string; author: string; linkTag: string } = JSON.parse(part.data.toString());
1074
outputStream?.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));
1075
}
1076
}
1077
}
1078
}
1079
return { ...lastResult, toolCallRounds: this.toolCallRounds, toolCallResults: this.toolCallResults };
1080
}
1081
1082
private async emitReadFileTrajectories() {
1083
// We are tuning our `read_file` tool to read files more effectively and efficiently.
1084
// This is a likely-temporary function that emits trajectory telemetry read_files
1085
// at the end of each agentic loop so that we can do so, in addition to the
1086
// per-call telemetry in ReadFileTool
1087
1088
function tryGetRFArgs(call: IToolCall): ReadFileParams | undefined {
1089
if (call.name !== ToolName.ReadFile) {
1090
return undefined;
1091
}
1092
try {
1093
return JSON.parse(call.arguments);
1094
} catch {
1095
return undefined;
1096
}
1097
}
1098
1099
const consumed = new Set<string>();
1100
const tcrs = this.toolCallRounds;
1101
for (let i = 0; i < tcrs.length; i++) {
1102
const { toolCalls } = tcrs[i];
1103
for (const call of toolCalls) {
1104
if (consumed.has(call.id)) {
1105
continue;
1106
}
1107
const args = tryGetRFArgs(call);
1108
if (!args) {
1109
continue;
1110
}
1111
1112
const seqArgs = [args];
1113
consumed.add(call.id);
1114
1115
for (let k = i + 1; k < tcrs.length; k++) {
1116
for (const call2 of tcrs[k].toolCalls) {
1117
if (consumed.has(call2.id)) {
1118
continue;
1119
}
1120
1121
const args2 = tryGetRFArgs(call2);
1122
if (!args2 || args2.filePath !== args.filePath) {
1123
continue;
1124
}
1125
1126
consumed.add(call2.id);
1127
seqArgs.push(args2);
1128
}
1129
}
1130
1131
let chunkSizeTotal = 0;
1132
let chunkSizeNo = 0;
1133
for (const arg of seqArgs) {
1134
if ('startLine' in arg) {
1135
chunkSizeNo++;
1136
chunkSizeTotal += arg.endLine - arg.startLine + 1;
1137
} else if (arg.limit) {
1138
chunkSizeNo++;
1139
chunkSizeTotal += arg.limit;
1140
}
1141
}
1142
1143
/* __GDPR__
1144
"readFileTrajectory" : {
1145
"owner": "connor4312",
1146
"comment": "read_file tool invokation trajectory",
1147
"model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The model that invoked the tool" },
1148
"rounds": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The number of times the file was read sequentially" },
1149
"avgChunkSize": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The number of lines read at a time" }
1150
}
1151
*/
1152
this._telemetryService.sendMSFTTelemetryEvent('readFileTrajectory',
1153
{
1154
// model will be undefined in the simulator
1155
model: this.options.request.model?.id,
1156
},
1157
{
1158
rounds: seqArgs.length,
1159
avgChunkSize: chunkSizeNo > 0 ? Math.round(chunkSizeTotal / chunkSizeNo) : -1,
1160
}
1161
);
1162
}
1163
}
1164
}
1165
1166
private hitToolCallLimit(stream: ChatResponseStream | undefined, lastResult: IToolCallSingleResult) {
1167
if (stream && this.options.onHitToolCallLimit === ToolCallLimitBehavior.Confirm) {
1168
const messageString = new MarkdownString(l10n.t({
1169
message: '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}).',
1170
args: [`command:workbench.action.openSettings?${encodeURIComponent('["chat.agent.maxRequests"]')}`],
1171
comment: '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.'
1172
}));
1173
messageString.isTrusted = { enabledCommands: ['workbench.action.openSettings'] };
1174
1175
stream.confirmation(
1176
l10n.t('Continue to iterate?'),
1177
messageString,
1178
{ copilotRequestedRoundLimit: Math.round(this.options.toolCallLimit * 3 / 2) } satisfies IToolCallIterationIncrease,
1179
[
1180
l10n.t('Continue'),
1181
cancelText(),
1182
]
1183
);
1184
}
1185
1186
lastResult.chatResult = {
1187
...lastResult.chatResult,
1188
metadata: {
1189
...lastResult.chatResult?.metadata,
1190
maxToolCallsExceeded: true
1191
} satisfies Partial<IResultMetadata>,
1192
};
1193
1194
return lastResult;
1195
}
1196
1197
/** Runs a single iteration of the tool calling loop. */
1198
public async runOne(outputStream: ChatResponseStream | undefined, iterationNumber: number, token: CancellationToken): Promise<IToolCallSingleResult> {
1199
let availableTools = await this.getAvailableTools(outputStream, token);
1200
1201
// Emit tools_available on the agent span once, before the first CHAT span
1202
// starts in fetch(). This lets the debug logger write tools_*.json early.
1203
if (!this.toolsAvailableEmitted && this.agentSpan && availableTools.length > 0) {
1204
this.toolsAvailableEmitted = true;
1205
this.agentSpan.addEvent('tools_available', {
1206
toolDefinitions: truncateForOTel(JSON.stringify(availableTools.map(t => ({
1207
type: 'function',
1208
name: t.name,
1209
description: t.description,
1210
parameters: t.inputSchema,
1211
})))),
1212
...(this.chatSessionIdForTools ? { [CopilotChatAttr.CHAT_SESSION_ID]: this.chatSessionIdForTools } : {}),
1213
});
1214
}
1215
1216
const context = this.createPromptContext(availableTools, outputStream);
1217
const isContinuation = context.isContinuation || false;
1218
markChatExt(this.options.conversation.sessionId, ChatExtPerfMark.WillBuildPrompt);
1219
let buildPromptResult: IBuildPromptResult;
1220
try {
1221
buildPromptResult = await this.buildPrompt2(context, outputStream, token);
1222
} finally {
1223
markChatExt(this.options.conversation.sessionId, ChatExtPerfMark.DidBuildPrompt);
1224
}
1225
this.throwIfCancelled(token);
1226
this.turn.addReferences(buildPromptResult.references);
1227
// Possible the tool call resulted in new tools getting added.
1228
availableTools = await this.getAvailableTools(outputStream, token);
1229
1230
// Apply debug prompt/tool overrides from either inline YAML text or a YAML file.
1231
const promptOverride = this._configurationService.getConfig(ConfigKey.Advanced.DebugPromptOverrideString);
1232
const promptOverrideFile = this._configurationService.getConfig(ConfigKey.Advanced.DebugPromptOverrideFile);
1233
let effectiveBuildPromptResult: IBuildPromptResult = buildPromptResult;
1234
if (promptOverride || promptOverrideFile) {
1235
const overrideResult = await applyConfiguredPromptOverrides(
1236
promptOverride,
1237
promptOverrideFile,
1238
buildPromptResult.messages,
1239
availableTools,
1240
this._fileSystemService,
1241
this._logService,
1242
);
1243
effectiveBuildPromptResult = { ...buildPromptResult, messages: overrideResult.messages };
1244
availableTools = overrideResult.tools;
1245
}
1246
1247
// Ensure task_complete is available in autopilot mode so the model can signal completion
1248
availableTools = this.ensureAutopilotTools(availableTools);
1249
1250
const isToolInputFailure = effectiveBuildPromptResult.metadata.get(ToolFailureEncountered);
1251
const conversationSummary = effectiveBuildPromptResult.metadata.get(SummarizedConversationHistoryMetadata);
1252
if (conversationSummary) {
1253
this.turn.setMetadata(conversationSummary);
1254
}
1255
1256
// Find the latest summarized round.
1257
let summarizedAtRoundId: string | undefined;
1258
for (let i = this.toolCallRounds.length - 1; i >= 0; i--) {
1259
if (this.toolCallRounds[i].summary) {
1260
summarizedAtRoundId = this.toolCallRounds[i].id;
1261
break;
1262
}
1263
}
1264
if (!summarizedAtRoundId) {
1265
for (const turn of [...context.history].reverse()) {
1266
for (const round of [...turn.rounds].reverse()) {
1267
if (round.summary) {
1268
summarizedAtRoundId = round.id;
1269
break;
1270
}
1271
}
1272
if (summarizedAtRoundId) {
1273
break;
1274
}
1275
}
1276
}
1277
1278
const endpoint = await this._endpointProvider.getChatEndpoint(this.options.request);
1279
const tokenizer = endpoint.acquireTokenizer();
1280
const promptTokenLength = await tokenizer.countMessagesTokens(effectiveBuildPromptResult.messages);
1281
const toolTokenCount = availableTools.length > 0 ? await tokenizer.countToolTokens(availableTools) : 0;
1282
this.throwIfCancelled(token);
1283
this._onDidBuildPrompt.fire({ result: effectiveBuildPromptResult, tools: availableTools, promptTokenLength, toolTokenCount });
1284
this._logService.trace('Built prompt');
1285
1286
// Tool calls happen during prompt building. Check yield again here to see if we should abort prior to sending off the next request.
1287
if (iterationNumber > 0 && this.options.yieldRequested?.()) {
1288
throw new CancellationError();
1289
}
1290
1291
// todo@connor4312: can interaction outcome logic be implemented in a more generic way?
1292
const interactionOutcomeComputer = new InteractionOutcomeComputer(this.options.interactionContext);
1293
1294
const that = this;
1295
const responseProcessor = new class implements IResponseProcessor {
1296
1297
private readonly context = new ResponseProcessorContext(that.options.conversation.sessionId, that.turn, effectiveBuildPromptResult.messages, interactionOutcomeComputer);
1298
1299
async processResponse(_context: unknown, inputStream: AsyncIterable<IResponsePart>, responseStream: ChatResponseStream, token: CancellationToken): Promise<ChatResult | void> {
1300
let chatResult: ChatResult | void = undefined;
1301
if (that.options.responseProcessor) {
1302
chatResult = await that.options.responseProcessor.processResponse(this.context, inputStream, responseStream, token);
1303
} else {
1304
const responseProcessor = that._instantiationService.createInstance(PseudoStopStartResponseProcessor, [], undefined, { subagentInvocationId: that.options.request.subAgentInvocationId });
1305
await responseProcessor.processResponse(this.context, inputStream, responseStream, token);
1306
}
1307
return chatResult;
1308
}
1309
}();
1310
1311
this._logService.trace('Sending prompt to model');
1312
1313
const streamParticipants = outputStream ? [outputStream] : [];
1314
let fetchStreamSource: FetchStreamSource | undefined;
1315
let processResponsePromise: Promise<ChatResult | void> | undefined;
1316
let stopEarly = false;
1317
if (outputStream) {
1318
this.options.streamParticipants?.forEach(fn => {
1319
streamParticipants.push(fn(streamParticipants[streamParticipants.length - 1]));
1320
});
1321
const stream = streamParticipants[streamParticipants.length - 1];
1322
1323
fetchStreamSource = new FetchStreamSource();
1324
processResponsePromise = responseProcessor.processResponse(undefined, fetchStreamSource.stream, stream, token);
1325
1326
// Allows the response processor to do an early stop of the LLM request.
1327
processResponsePromise.finally(() => {
1328
// The response processor indicates that it has finished processing the response,
1329
// so let's stop the request if it's still in flight.
1330
stopEarly = true;
1331
});
1332
}
1333
1334
if (effectiveBuildPromptResult.messages.length === 0) {
1335
// /fixTestFailure relies on this check running after processResponse
1336
fetchStreamSource?.resolve();
1337
await processResponsePromise;
1338
await finalizeStreams(streamParticipants);
1339
throw new EmptyPromptError();
1340
}
1341
1342
const promptContextTools = availableTools.length ? availableTools.map(toolInfo => {
1343
return {
1344
name: toolInfo.name,
1345
description: toolInfo.description,
1346
parameters: toolInfo.inputSchema,
1347
} satisfies OpenAiFunctionDef;
1348
}) : undefined;
1349
1350
let statefulMarker: string | undefined;
1351
const toolCalls: IToolCall[] = [];
1352
let thinkingItem: ThinkingDataItem | undefined;
1353
const shouldDisableThinking = isContinuation && isAnthropicFamily(endpoint) && !ToolCallingLoop.messagesContainThinking(effectiveBuildPromptResult.messages);
1354
const enableThinking = !shouldDisableThinking;
1355
let phase: string | undefined;
1356
let compaction: OpenAIContextManagementResponse | undefined;
1357
markChatExt(this.options.conversation.sessionId, ChatExtPerfMark.WillFetch);
1358
const fetchResult = await this.fetch({
1359
messages: this.applyMessagePostProcessing(effectiveBuildPromptResult.messages, { stripOrphanedToolCalls: isGeminiFamily(endpoint) }),
1360
turnId: this.turn.id,
1361
summarizedAtRoundId,
1362
finishedCb: async (text, index, delta) => {
1363
fetchStreamSource?.update(text, delta);
1364
if (delta.copilotToolCalls) {
1365
toolCalls.push(...delta.copilotToolCalls.map((call): IToolCall => ({
1366
...call,
1367
id: this.createInternalToolCallId(call.id),
1368
arguments: call.arguments === '' ? '{}' : call.arguments
1369
})));
1370
}
1371
if (delta.statefulMarker) {
1372
statefulMarker = delta.statefulMarker;
1373
}
1374
if (delta.thinking) {
1375
thinkingItem = ThinkingDataItem.createOrUpdate(thinkingItem, delta.thinking);
1376
}
1377
if (delta.phase) {
1378
phase = delta.phase;
1379
}
1380
if (delta.contextManagement && isOpenAIContextManagementResponse(delta.contextManagement)) {
1381
compaction = delta.contextManagement;
1382
}
1383
return stopEarly ? text.length : undefined;
1384
},
1385
requestOptions: {
1386
tools: promptContextTools?.map(tool => ({
1387
function: {
1388
name: tool.name,
1389
description: tool.description,
1390
parameters: tool.parameters && Object.keys(tool.parameters).length ? tool.parameters : undefined
1391
},
1392
type: 'function',
1393
})),
1394
},
1395
userInitiatedRequest: (iterationNumber === 0 && !isContinuation && !this.options.request.subAgentInvocationId && !this.options.request.isSystemInitiated) || this.stopHookUserInitiated,
1396
modelCapabilities: {
1397
enableThinking,
1398
},
1399
}, token).finally(() => {
1400
this.stopHookUserInitiated = false;
1401
});
1402
markChatExt(this.options.conversation.sessionId, ChatExtPerfMark.DidFetch);
1403
1404
// Store the headerRequestId from the fetch response for subagent telemetry linking.
1405
// Use requestId (the client-generated UUID sent as X-Request-Id header), not serverRequestId
1406
// (the server's response header value), because requestId is what appears as headerRequestId
1407
// across all telemetry events.
1408
if (fetchResult.type === ChatFetchResponseType.Success) {
1409
this.lastHeaderRequestId = fetchResult.requestId;
1410
}
1411
1412
const promptTokenDetails = await computePromptTokenDetails({
1413
messages: effectiveBuildPromptResult.messages,
1414
tokenizer,
1415
tools: availableTools,
1416
});
1417
fetchStreamSource?.resolve();
1418
const chatResult = await processResponsePromise ?? undefined;
1419
1420
// Report token usage to the stream for rendering the context window widget
1421
const stream = streamParticipants[streamParticipants.length - 1];
1422
if (fetchResult.type === ChatFetchResponseType.Success && fetchResult.usage && stream && this.shouldReportUsageToContextWidget()) {
1423
stream.usage({
1424
completionTokens: fetchResult.usage.completion_tokens,
1425
promptTokens: fetchResult.usage.prompt_tokens,
1426
outputBuffer: endpoint.maxOutputTokens,
1427
promptTokenDetails,
1428
});
1429
}
1430
1431
// Validate authentication session upgrade and handle accordingly
1432
if (
1433
outputStream &&
1434
toolCalls.some(tc => tc.name === ToolName.Codebase) &&
1435
await this._authenticationChatUpgradeService.shouldRequestPermissiveSessionUpgrade()
1436
) {
1437
this._authenticationChatUpgradeService.showPermissiveSessionUpgradeInChat(outputStream, this.options.request);
1438
throw new ToolCallCancelledError(new CancellationError());
1439
}
1440
1441
await finalizeStreams(streamParticipants);
1442
this._onDidReceiveResponse.fire({ interactionOutcome: interactionOutcomeComputer, response: fetchResult, toolCalls });
1443
1444
this.turn.setMetadata(interactionOutcomeComputer.interactionOutcome);
1445
1446
const toolInputRetry = isToolInputFailure ? (this.toolCallRounds.at(-1)?.toolInputRetry || 0) + 1 : 0;
1447
if (fetchResult.type === ChatFetchResponseType.Success) {
1448
// Store token usage metadata for Anthropic models using Messages API
1449
if (fetchResult.usage && isAnthropicFamily(endpoint)) {
1450
this.turn.setMetadata(new AnthropicTokenUsageMetadata(
1451
fetchResult.usage.prompt_tokens,
1452
fetchResult.usage.completion_tokens
1453
));
1454
}
1455
1456
thinkingItem?.updateWithFetchResult(fetchResult);
1457
1458
// Log the assistant message to the transcript
1459
const transcriptToolRequests: ToolRequest[] = toolCalls.map(tc => ({
1460
toolCallId: tc.id,
1461
name: tc.name,
1462
arguments: tc.arguments,
1463
type: 'function' as const,
1464
}));
1465
this._sessionTranscriptService.logAssistantMessage(
1466
this.options.conversation.sessionId,
1467
fetchResult.value,
1468
transcriptToolRequests,
1469
thinkingItem ? (Array.isArray(thinkingItem.text) ? thinkingItem.text.join('') : thinkingItem.text) : undefined,
1470
);
1471
1472
return {
1473
response: fetchResult,
1474
round: ToolCallRound.create({
1475
response: fetchResult.value,
1476
toolCalls,
1477
toolInputRetry,
1478
statefulMarker,
1479
thinking: thinkingItem,
1480
phase,
1481
phaseModelId: phase ? endpoint.model : undefined,
1482
compaction,
1483
}),
1484
chatResult,
1485
hadIgnoredFiles: buildPromptResult.hasIgnoredFiles,
1486
lastRequestMessages: effectiveBuildPromptResult.messages,
1487
availableTools,
1488
};
1489
}
1490
1491
return {
1492
response: fetchResult,
1493
hadIgnoredFiles: buildPromptResult.hasIgnoredFiles,
1494
lastRequestMessages: effectiveBuildPromptResult.messages,
1495
availableTools,
1496
round: new ToolCallRound('', toolCalls, toolInputRetry),
1497
};
1498
}
1499
1500
/**
1501
* Sometimes 4o reuses tool call IDs, so make sure they are unique. Really we should restructure how tool calls and results are represented
1502
* to not expect them to be globally unique.
1503
*/
1504
private createInternalToolCallId(toolCallId: string): string {
1505
// Note- if this code is ever removed, these IDs will still exist in persisted session metadata!
1506
return toolCallId + `__vscode-${ToolCallingLoop.NextToolCallId++}`;
1507
}
1508
1509
private applyMessagePostProcessing(messages: Raw.ChatMessage[], options?: { stripOrphanedToolCalls?: boolean }): Raw.ChatMessage[] {
1510
return this.validateToolMessages(
1511
ToolCallingLoop.stripInternalToolCallIds(messages), options);
1512
}
1513
1514
public static stripInternalToolCallIds(messages: Raw.ChatMessage[]): Raw.ChatMessage[] {
1515
return messages.map(m => {
1516
if (m.role === Raw.ChatRole.Assistant) {
1517
return {
1518
...m,
1519
toolCalls: m.toolCalls?.map(tc => ({
1520
...tc,
1521
id: tc.id.split('__vscode-')[0]
1522
}))
1523
};
1524
} else if (m.role === Raw.ChatRole.Tool) {
1525
return {
1526
...m,
1527
toolCallId: m.toolCallId?.split('__vscode-')[0]
1528
};
1529
}
1530
1531
return m;
1532
});
1533
}
1534
1535
public static messagesContainThinking(messages: Raw.ChatMessage[]): boolean {
1536
let lastUserMessageIndex = -1;
1537
for (let i = messages.length - 1; i >= 0; i--) {
1538
if (messages[i].role === Raw.ChatRole.User) {
1539
lastUserMessageIndex = i;
1540
break;
1541
}
1542
}
1543
1544
// If no user message found, return false to disable thinking
1545
if (lastUserMessageIndex === -1) {
1546
return false;
1547
}
1548
1549
for (let i = lastUserMessageIndex + 1; i < messages.length; i++) {
1550
const m = messages[i];
1551
if (m.role !== Raw.ChatRole.Assistant) {
1552
continue;
1553
}
1554
return Array.isArray(m.content) && m.content.some(part =>
1555
part.type === Raw.ChatCompletionContentPartKind.Opaque && rawPartAsThinkingData(part) !== undefined
1556
);
1557
}
1558
return false;
1559
}
1560
1561
/**
1562
* Apparently we can render prompts which have a tool message which is out of place.
1563
* Don't know why this is happening, but try to detect this and fix it up.
1564
*
1565
* Validates tool messages in the conversation, ensuring:
1566
* 1. Tool result messages have a matching tool_call in the preceding assistant message
1567
* 2. (When stripOrphanedToolCalls is set) Every tool_call in an assistant message has
1568
* a matching tool result message. This prevents errors with models like Gemini which
1569
* strictly require 1:1 function_call ↔ function_response pairing.
1570
*
1571
* Returns the validated messages and an array of reasons for any corrections made.
1572
*/
1573
public static validateToolMessagesCore(messages: Raw.ChatMessage[], options?: { stripOrphanedToolCalls?: boolean }): { messages: Raw.ChatMessage[]; filterReasons: string[]; strippedToolCallCount: number } {
1574
const filterReasons: string[] = [];
1575
let strippedToolCallCount = 0;
1576
let previousAssistantMessage: Raw.AssistantChatMessage | undefined;
1577
const filtered = messages.filter(m => {
1578
if (m.role === Raw.ChatRole.Assistant) {
1579
previousAssistantMessage = m;
1580
} else if (m.role === Raw.ChatRole.Tool) {
1581
if (!previousAssistantMessage) {
1582
// No previous assistant message
1583
filterReasons.push('noPreviousAssistantMessage');
1584
return false;
1585
}
1586
1587
if (!previousAssistantMessage.toolCalls?.length) {
1588
// The assistant did not call any tools
1589
filterReasons.push('noToolCalls');
1590
return false;
1591
}
1592
1593
const toolCall = previousAssistantMessage.toolCalls.find(tc => tc.id === m.toolCallId);
1594
if (!toolCall) {
1595
// This tool call is excluded
1596
return false;
1597
}
1598
}
1599
1600
return true;
1601
});
1602
1603
// Second pass: strip tool_calls from assistant messages that lack matching tool result messages.
1604
// This prevents sending orphaned tool_calls that would cause errors with models like Gemini
1605
// which strictly require every function_call to have a corresponding function_response.
1606
// Gated behind stripOrphanedToolCalls to limit scope to models that need it.
1607
if (!options?.stripOrphanedToolCalls) {
1608
return { messages: filtered, filterReasons, strippedToolCallCount };
1609
}
1610
1611
for (let i = 0; i < filtered.length; i++) {
1612
const m = filtered[i];
1613
if (m.role !== Raw.ChatRole.Assistant || !m.toolCalls?.length) {
1614
continue;
1615
}
1616
1617
// Collect tool result IDs that follow this assistant message (up to the next assistant message)
1618
const toolResultIds = new Set<string>();
1619
for (let j = i + 1; j < filtered.length; j++) {
1620
const next = filtered[j];
1621
if (next.role === Raw.ChatRole.Assistant) {
1622
break;
1623
}
1624
if (next.role === Raw.ChatRole.Tool && next.toolCallId !== undefined) {
1625
toolResultIds.add(next.toolCallId);
1626
}
1627
}
1628
1629
const orphanedToolCalls = m.toolCalls.filter(tc => !toolResultIds.has(tc.id));
1630
if (orphanedToolCalls.length > 0) {
1631
strippedToolCallCount += orphanedToolCalls.length;
1632
const validToolCalls = m.toolCalls.filter(tc => toolResultIds.has(tc.id));
1633
// Mutate in place — the assistant message was already shallow-copied by stripInternalToolCallIds
1634
(m as Mutable<Raw.AssistantChatMessage>).toolCalls = validToolCalls.length > 0 ? validToolCalls : undefined;
1635
}
1636
}
1637
1638
return { messages: filtered, filterReasons, strippedToolCallCount };
1639
}
1640
1641
private validateToolMessages(messages: Raw.ChatMessage[], options?: { stripOrphanedToolCalls?: boolean }): Raw.ChatMessage[] {
1642
const { messages: filtered, filterReasons, strippedToolCallCount } = ToolCallingLoop.validateToolMessagesCore(messages, options);
1643
1644
if (filterReasons.length || strippedToolCallCount > 0) {
1645
const allReasons = strippedToolCallCount > 0 ? [...filterReasons, `orphanedToolCalls:${strippedToolCallCount}`] : filterReasons;
1646
const filterReasonsStr = allReasons.join(', ');
1647
this._logService.warn('Filtered invalid tool messages: ' + filterReasonsStr);
1648
/* __GDPR__
1649
"toolCalling.invalidToolMessages" : {
1650
"owner": "roblourens",
1651
"comment": "Provides info about invalid tool messages that were rendered in a prompt",
1652
"filterReasons": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Reasons for filtering the messages and stripping orphaned tool calls." },
1653
"filterCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Count of filtered messages." },
1654
"strippedToolCallCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "Count of orphaned tool_calls stripped from assistant messages." }
1655
}
1656
*/
1657
this._telemetryService.sendMSFTTelemetryEvent('toolCalling.invalidToolMessages', {
1658
filterReasons: filterReasonsStr,
1659
}, {
1660
filterCount: filterReasons.length,
1661
strippedToolCallCount,
1662
});
1663
}
1664
1665
return filtered;
1666
}
1667
1668
private async buildPrompt2(buildPromptContext: IBuildPromptContext, stream: ChatResponseStream | undefined, token: CancellationToken): Promise<IBuildPromptResult> {
1669
const progress: Progress<ChatResponseReferencePart | ChatResponseProgressPart> = {
1670
report(obj) {
1671
stream?.push(obj);
1672
}
1673
};
1674
1675
const buildPromptResult = await this.buildPrompt(buildPromptContext, progress, token);
1676
for (const metadata of buildPromptResult.metadata.getAll(ToolResultMetadata)) {
1677
this.logToolResult(buildPromptContext, metadata);
1678
this.toolCallResults[metadata.toolCallId] = metadata.result;
1679
}
1680
1681
if (buildPromptResult.metadata.getAll(ToolResultMetadata).some(r => r.isCancelled)) {
1682
throw new CancellationError();
1683
}
1684
1685
return buildPromptResult;
1686
}
1687
1688
1689
private logToolResult(buildPromptContext: IBuildPromptContext, metadata: ToolResultMetadata) {
1690
if (this.toolCallResults[metadata.toolCallId]) {
1691
return; // already logged this on a previous turn
1692
}
1693
1694
const lastTurn = this.toolCallRounds.at(-1);
1695
let originalCall = lastTurn?.toolCalls.find(tc => tc.id === metadata.toolCallId);
1696
if (!originalCall) {
1697
const byRef = buildPromptContext.tools?.toolReferences.find(r => r.id === metadata.toolCallId);
1698
if (byRef) {
1699
originalCall = { id: byRef.id, arguments: JSON.stringify(byRef.input), name: byRef.name };
1700
}
1701
}
1702
1703
if (originalCall) {
1704
this._requestLogger.logToolCall(originalCall.id || generateUuid(), originalCall.name, originalCall.arguments, metadata.result, lastTurn?.thinking);
1705
}
1706
}
1707
}
1708
1709
async function finalizeStreams(streams: readonly ChatResponseStream[]) {
1710
for (const stream of streams) {
1711
await tryFinalizeResponseStream(stream);
1712
}
1713
}
1714
1715
export class EmptyPromptError extends Error {
1716
constructor() {
1717
super('Empty prompt');
1718
}
1719
}
1720
1721
export interface IToolCallSingleResult {
1722
response: ChatResponse;
1723
round: IToolCallRound;
1724
chatResult?: ChatResult; // TODO should just be metadata
1725
hadIgnoredFiles: boolean;
1726
lastRequestMessages: Raw.ChatMessage[];
1727
availableTools: readonly LanguageModelToolInformation[];
1728
}
1729
1730
export interface IToolCallLoopResult extends IToolCallSingleResult {
1731
toolCallRounds: IToolCallRound[];
1732
toolCallResults: Record<string, LanguageModelToolResult2>;
1733
}
1734
1735