Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.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 { randomUUID } from 'crypto';
7
import type { CancellationToken, ChatRequest, ChatResponseStream, LanguageModelToolInformation, Progress } from 'vscode';
8
import { IAuthenticationChatUpgradeService } from '../../../platform/authentication/common/authenticationUpgrade';
9
import { IChatHookService } from '../../../platform/chat/common/chatHookService';
10
import { ChatLocation, ChatResponse } from '../../../platform/chat/common/commonTypes';
11
import { ISessionTranscriptService } from '../../../platform/chat/common/sessionTranscriptService';
12
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
13
import { ChatEndpointFamily, IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider';
14
import { ProxyAgenticEndpoint } from '../../../platform/endpoint/node/proxyAgenticEndpoint';
15
import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';
16
import { IGitService } from '../../../platform/git/common/gitService';
17
import { ILogService } from '../../../platform/log/common/logService';
18
import { IOTelService } from '../../../platform/otel/common/otelService';
19
import { IRequestLogger } from '../../../platform/requestLogger/common/requestLogger';
20
import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService';
21
import { ITelemetryService } from '../../../platform/telemetry/common/telemetry';
22
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
23
import { ChatResponseProgressPart, ChatResponseReferencePart, LanguageModelToolResult2 } from '../../../vscodeTypes';
24
import { IToolCallingLoopOptions, ToolCallingLoop, ToolCallingLoopFetchOptions } from '../../intents/node/toolCallingLoop';
25
import { ExecutionSubagentPrompt } from '../../prompts/node/agent/executionSubagentPrompt';
26
import { PromptRenderer } from '../../prompts/node/base/promptRenderer';
27
import { ToolResultMetadata } from '../../prompts/node/panel/toolCalling';
28
import { ToolName } from '../../tools/common/toolNames';
29
import { IToolsService } from '../../tools/common/toolsService';
30
import { IBuildPromptContext } from '../common/intents';
31
import { IBuildPromptResult } from './intents';
32
33
export interface IExecutionSubagentToolCallingLoopOptions extends IToolCallingLoopOptions {
34
request: ChatRequest;
35
location: ChatLocation;
36
promptText: string;
37
/** Optional pre-generated subagent invocation ID. If not provided, a new UUID will be generated. */
38
subAgentInvocationId?: string;
39
/** The tool_call_id from the parent agent's LLM response that triggered this subagent invocation. */
40
parentToolCallId?: string;
41
/** The headerRequestId from the parent agent's fetch response that triggered this subagent invocation. */
42
parentHeaderRequestId?: string;
43
}
44
45
/** A terminal command that is no longer being awaited by the subagent — either
46
* it timed out and was moved to the background, or the model invoked it in
47
* async/background mode from the start. */
48
export interface IBackgroundCommand {
49
readonly command: string;
50
readonly termId: string;
51
readonly reason: 'timeout' | 'async';
52
/** Only set when `reason === 'timeout'`. */
53
readonly timeoutMs?: number;
54
}
55
56
export class ExecutionSubagentToolCallingLoop extends ToolCallingLoop<IExecutionSubagentToolCallingLoopOptions> {
57
58
public static readonly ID = 'executionSubagentTool';
59
60
/** Terminal calls from previous rounds that the subagent is no longer
61
* awaiting (timeout-moved-to-background or async-from-start), deduped by
62
* toolCallId. */
63
private readonly _backgroundCommands: IBackgroundCommand[] = [];
64
private readonly _seenBackgroundCallIds = new Set<string>();
65
66
public get backgroundCommands(): readonly IBackgroundCommand[] {
67
return this._backgroundCommands;
68
}
69
70
constructor(
71
options: IExecutionSubagentToolCallingLoopOptions,
72
@IInstantiationService private readonly instantiationService: IInstantiationService,
73
@ILogService logService: ILogService,
74
@IRequestLogger requestLogger: IRequestLogger,
75
@IEndpointProvider private readonly endpointProvider: IEndpointProvider,
76
@IToolsService private readonly toolsService: IToolsService,
77
@IAuthenticationChatUpgradeService authenticationChatUpgradeService: IAuthenticationChatUpgradeService,
78
@ITelemetryService telemetryService: ITelemetryService,
79
@IConfigurationService configurationService: IConfigurationService,
80
@IExperimentationService experimentationService: IExperimentationService,
81
@IChatHookService chatHookService: IChatHookService,
82
@ISessionTranscriptService sessionTranscriptService: ISessionTranscriptService,
83
@IFileSystemService fileSystemService: IFileSystemService,
84
@IOTelService otelService: IOTelService,
85
@IGitService gitService: IGitService,
86
) {
87
super(options, instantiationService, endpointProvider, logService, requestLogger, authenticationChatUpgradeService, telemetryService, configurationService, experimentationService, chatHookService, sessionTranscriptService, fileSystemService, otelService, gitService);
88
}
89
90
protected override createPromptContext(availableTools: LanguageModelToolInformation[], outputStream: ChatResponseStream | undefined): IBuildPromptContext {
91
const context = super.createPromptContext(availableTools, outputStream);
92
if (context.tools) {
93
context.tools = {
94
...context.tools,
95
toolReferences: [],
96
subAgentInvocationId: this.options.subAgentInvocationId ?? randomUUID(),
97
subAgentName: 'execution'
98
};
99
}
100
context.query = this.options.promptText;
101
return context;
102
}
103
104
private static readonly DEFAULT_AGENTIC_PROXY_MODEL = 'exec-subagent-router-a';
105
106
/**
107
* Get the endpoint to use for the execution subagent
108
*/
109
private async getEndpoint() {
110
const modelName = this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.ExecutionSubagentModel, this._experimentationService) as ChatEndpointFamily | undefined;
111
const useAgenticProxy = this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.ExecutionSubagentUseAgenticProxy, this._experimentationService);
112
113
if (useAgenticProxy) {
114
// Use agentic proxy with ExecutionSubagentModel or default to DEFAULT_AGENTIC_PROXY_MODEL
115
const agenticProxyModel = modelName || ExecutionSubagentToolCallingLoop.DEFAULT_AGENTIC_PROXY_MODEL;
116
return this.instantiationService.createInstance(ProxyAgenticEndpoint, agenticProxyModel);
117
}
118
119
if (modelName) {
120
try {
121
// Try to get the specified model
122
const endpoint = await this.endpointProvider.getChatEndpoint(modelName);
123
if (endpoint.supportsToolCalls) {
124
return endpoint;
125
}
126
// Model does not support tool calls, fallback to main agent endpoint
127
return await this.endpointProvider.getChatEndpoint(this.options.request);
128
} catch (error) {
129
// Model not available, fallback to main agent endpoint
130
return await this.endpointProvider.getChatEndpoint(this.options.request);
131
}
132
} else {
133
// No model name specified, use main agent endpoint
134
return await this.endpointProvider.getChatEndpoint(this.options.request);
135
}
136
}
137
138
protected async buildPrompt(buildpromptContext: IBuildPromptContext, progress: Progress<ChatResponseReferencePart | ChatResponseProgressPart>, token: CancellationToken): Promise<IBuildPromptResult> {
139
const endpoint = await this.getEndpoint();
140
const maxExecutionTurns = this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.ExecutionSubagentToolCallLimit, this._experimentationService);
141
142
const render = (hasBackgroundCommand: boolean) => PromptRenderer.create(
143
this.instantiationService,
144
endpoint,
145
ExecutionSubagentPrompt,
146
{
147
promptContext: buildpromptContext,
148
maxExecutionTurns,
149
hasBackgroundCommand,
150
}
151
).render(progress, token);
152
153
// If a previous render observed any background terminal commands, tell the
154
// prompt to nudge the model to stop issuing tool calls and produce its
155
// <final_answer>. Even with `getAvailableTools` returning [], the model
156
// may still attempt a (failed) tool call and trigger another iteration,
157
// so the nudge needs to persist across iterations.
158
const hadBackgroundBefore = this._backgroundCommands.length > 0;
159
let result = await render(hadBackgroundBefore);
160
161
// After rendering, scan the rendered tool results for background commands.
162
// Every tool call rendered into the prompt (including those executed just
163
// now during this render) emits a ToolResultMetadata entry on
164
// `result.metadata`.
165
this.collectBackgroundCommands(buildpromptContext, result);
166
167
// If a background command was first detected during this render, the nudge
168
// wasn't in the prompt we just built. Re-render with the nudge so the LLM
169
// in this same iteration sees the instruction to produce <final_answer>.
170
if (!hadBackgroundBefore && this._backgroundCommands.length > 0) {
171
const cache = buildpromptContext.toolCallResults;
172
// Write to the tool result cache so that the second render doesn't
173
// re-run all tool calls that happened during the first render
174
if (cache) {
175
for (const meta of result.metadata.getAll(ToolResultMetadata)) {
176
cache[meta.toolCallId] = meta.result;
177
}
178
}
179
result = await render(true);
180
}
181
182
return result;
183
}
184
185
private collectBackgroundCommands(buildpromptContext: IBuildPromptContext, result: IBuildPromptResult): void {
186
const lastRound = buildpromptContext.toolCallRounds?.at(-1);
187
if (!lastRound) {
188
return;
189
}
190
191
// Index only this round's terminal calls. Calls from earlier rounds were
192
// already evaluated on prior iterations.
193
interface ITerminalCall {
194
readonly command: string;
195
/** True if the model called the tool with mode="async" or
196
* isBackground=true, regardless of how it actually ran. */
197
readonly invokedAsAsync: boolean;
198
}
199
const terminalCallsById = new Map<string, ITerminalCall>();
200
for (const tc of lastRound.toolCalls) {
201
if (tc.name !== ToolName.CoreRunInTerminal || this._seenBackgroundCallIds.has(tc.id)) {
202
continue;
203
}
204
let command = '';
205
let invokedAsAsync = false;
206
try {
207
const args = JSON.parse(tc.arguments) as { command?: unknown; mode?: unknown; isBackground?: unknown };
208
if (typeof args?.command === 'string') {
209
command = args.command;
210
}
211
invokedAsAsync = args?.mode === 'async' || args?.isBackground === true;
212
} catch {
213
// arguments may not be valid JSON on partial rounds; skip extraction
214
}
215
terminalCallsById.set(tc.id, { command, invokedAsAsync });
216
}
217
if (terminalCallsById.size === 0) {
218
return;
219
}
220
221
for (const meta of result.metadata.getAll(ToolResultMetadata)) {
222
const call = terminalCallsById.get(meta.toolCallId);
223
if (!call) {
224
continue;
225
}
226
const termId = this.getTerminalId(meta.result);
227
if (!termId) {
228
// No termId means the call didn't produce a terminal (e.g., errored
229
// before execution). Nothing to track or note about.
230
continue;
231
}
232
const timeoutMs = this.getTimeoutMsIfTimedOut(meta.result);
233
if (timeoutMs !== undefined) {
234
this._seenBackgroundCallIds.add(meta.toolCallId);
235
this._backgroundCommands.push({
236
command: call.command,
237
termId,
238
reason: 'timeout',
239
timeoutMs,
240
});
241
} else if (call.invokedAsAsync) {
242
this._seenBackgroundCallIds.add(meta.toolCallId);
243
this._backgroundCommands.push({
244
command: call.command,
245
termId,
246
reason: 'async',
247
});
248
}
249
}
250
}
251
252
/**
253
* Reads the `id` (terminal ID) field from a `run_in_terminal` tool result's
254
* `toolMetadata`, if present. `toolMetadata` is exposed on tool results via
255
* the chatParticipantPrivate proposed API and is not on the public
256
* LanguageModelToolResult2 type, so we narrow with an `in` check.
257
*/
258
private getTerminalId(toolResult: LanguageModelToolResult2): string | undefined {
259
if (!('toolMetadata' in toolResult)) {
260
return undefined;
261
}
262
const metadata = (toolResult as { toolMetadata?: unknown }).toolMetadata;
263
if (!metadata || typeof metadata !== 'object') {
264
return undefined;
265
}
266
const m = metadata as { id?: unknown };
267
return typeof m.id === 'string' ? m.id : undefined;
268
}
269
270
/**
271
* Returns the configured timeout (ms) if the result indicates a sync
272
* `run_in_terminal` call timed out and was moved to the background; returns
273
* `undefined` otherwise. See vscode core: runInTerminalTool.ts which sets
274
* `timedOut: true` and `timeoutMs` on `toolMetadata` for that case.
275
*/
276
private getTimeoutMsIfTimedOut(toolResult: LanguageModelToolResult2): number | undefined {
277
if (!('toolMetadata' in toolResult)) {
278
return undefined;
279
}
280
const metadata = (toolResult as { toolMetadata?: unknown }).toolMetadata;
281
if (!metadata || typeof metadata !== 'object') {
282
return undefined;
283
}
284
const m = metadata as { timedOut?: unknown; timeoutMs?: unknown };
285
if (m.timedOut !== true) {
286
return undefined;
287
}
288
return typeof m.timeoutMs === 'number' ? m.timeoutMs : undefined;
289
}
290
291
protected async getAvailableTools(): Promise<LanguageModelToolInformation[]> {
292
// If any previous terminal call has moved to the background (timeout or
293
// async), expose no tools so the model cannot make further calls and is
294
// forced to produce its <final_answer>.
295
if (this._backgroundCommands.length > 0) {
296
return [];
297
}
298
299
const endpoint = await this.getEndpoint();
300
const allTools = this.toolsService.getEnabledTools(this.options.request, endpoint);
301
302
const allowedExecutionTools = new Set([
303
ToolName.CoreRunInTerminal
304
]);
305
306
return allTools.filter(tool => allowedExecutionTools.has(tool.name as ToolName));
307
}
308
309
protected async fetch({ messages, finishedCb, requestOptions, modelCapabilities }: ToolCallingLoopFetchOptions, token: CancellationToken): Promise<ChatResponse> {
310
const endpoint = await this.getEndpoint();
311
return endpoint.makeChatRequest2({
312
debugName: ExecutionSubagentToolCallingLoop.ID,
313
messages,
314
finishedCb,
315
location: this.options.location,
316
modelCapabilities: { ...modelCapabilities, reasoningEffort: undefined },
317
requestOptions: {
318
...(requestOptions ?? {}),
319
temperature: 0
320
},
321
// This loop is inside a tool called from another request, so never user initiated
322
userInitiatedRequest: false,
323
telemetryProperties: {
324
requestId: this.options.subAgentInvocationId,
325
messageId: randomUUID(),
326
messageSource: 'chat.editAgent',
327
subType: 'subagent/execution',
328
conversationId: this.options.conversation.sessionId,
329
parentToolCallId: this.options.parentToolCallId,
330
parentHeaderRequestId: this.options.parentHeaderRequestId,
331
},
332
}, token);
333
}
334
}
335
336