Path: blob/main/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { randomUUID } from 'crypto';6import type { CancellationToken, ChatRequest, ChatResponseStream, LanguageModelToolInformation, Progress } from 'vscode';7import { IAuthenticationChatUpgradeService } from '../../../platform/authentication/common/authenticationUpgrade';8import { IChatHookService } from '../../../platform/chat/common/chatHookService';9import { ChatLocation, ChatResponse } from '../../../platform/chat/common/commonTypes';10import { ISessionTranscriptService } from '../../../platform/chat/common/sessionTranscriptService';11import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';12import { ChatEndpointFamily, IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider';13import { ProxyAgenticEndpoint } from '../../../platform/endpoint/node/proxyAgenticEndpoint';14import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';15import { IGitService } from '../../../platform/git/common/gitService';16import { ILogService } from '../../../platform/log/common/logService';17import { IOTelService } from '../../../platform/otel/common/otelService';18import { IRequestLogger } from '../../../platform/requestLogger/common/requestLogger';19import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService';20import { ITelemetryService } from '../../../platform/telemetry/common/telemetry';21import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';22import { ChatResponseProgressPart, ChatResponseReferencePart, LanguageModelToolResult2 } from '../../../vscodeTypes';23import { IToolCallingLoopOptions, ToolCallingLoop, ToolCallingLoopFetchOptions } from '../../intents/node/toolCallingLoop';24import { ExecutionSubagentPrompt } from '../../prompts/node/agent/executionSubagentPrompt';25import { PromptRenderer } from '../../prompts/node/base/promptRenderer';26import { ToolResultMetadata } from '../../prompts/node/panel/toolCalling';27import { ToolName } from '../../tools/common/toolNames';28import { IToolsService } from '../../tools/common/toolsService';29import { IBuildPromptContext } from '../common/intents';30import { IBuildPromptResult } from './intents';3132export interface IExecutionSubagentToolCallingLoopOptions extends IToolCallingLoopOptions {33request: ChatRequest;34location: ChatLocation;35promptText: string;36/** Optional pre-generated subagent invocation ID. If not provided, a new UUID will be generated. */37subAgentInvocationId?: string;38/** The tool_call_id from the parent agent's LLM response that triggered this subagent invocation. */39parentToolCallId?: string;40/** The headerRequestId from the parent agent's fetch response that triggered this subagent invocation. */41parentHeaderRequestId?: string;42}4344/** A terminal command that is no longer being awaited by the subagent — either45* it timed out and was moved to the background, or the model invoked it in46* async/background mode from the start. */47export interface IBackgroundCommand {48readonly command: string;49readonly termId: string;50readonly reason: 'timeout' | 'async';51/** Only set when `reason === 'timeout'`. */52readonly timeoutMs?: number;53}5455export class ExecutionSubagentToolCallingLoop extends ToolCallingLoop<IExecutionSubagentToolCallingLoopOptions> {5657public static readonly ID = 'executionSubagentTool';5859/** Terminal calls from previous rounds that the subagent is no longer60* awaiting (timeout-moved-to-background or async-from-start), deduped by61* toolCallId. */62private readonly _backgroundCommands: IBackgroundCommand[] = [];63private readonly _seenBackgroundCallIds = new Set<string>();6465public get backgroundCommands(): readonly IBackgroundCommand[] {66return this._backgroundCommands;67}6869constructor(70options: IExecutionSubagentToolCallingLoopOptions,71@IInstantiationService private readonly instantiationService: IInstantiationService,72@ILogService logService: ILogService,73@IRequestLogger requestLogger: IRequestLogger,74@IEndpointProvider private readonly endpointProvider: IEndpointProvider,75@IToolsService private readonly toolsService: IToolsService,76@IAuthenticationChatUpgradeService authenticationChatUpgradeService: IAuthenticationChatUpgradeService,77@ITelemetryService telemetryService: ITelemetryService,78@IConfigurationService configurationService: IConfigurationService,79@IExperimentationService experimentationService: IExperimentationService,80@IChatHookService chatHookService: IChatHookService,81@ISessionTranscriptService sessionTranscriptService: ISessionTranscriptService,82@IFileSystemService fileSystemService: IFileSystemService,83@IOTelService otelService: IOTelService,84@IGitService gitService: IGitService,85) {86super(options, instantiationService, endpointProvider, logService, requestLogger, authenticationChatUpgradeService, telemetryService, configurationService, experimentationService, chatHookService, sessionTranscriptService, fileSystemService, otelService, gitService);87}8889protected override createPromptContext(availableTools: LanguageModelToolInformation[], outputStream: ChatResponseStream | undefined): IBuildPromptContext {90const context = super.createPromptContext(availableTools, outputStream);91if (context.tools) {92context.tools = {93...context.tools,94toolReferences: [],95subAgentInvocationId: this.options.subAgentInvocationId ?? randomUUID(),96subAgentName: 'execution'97};98}99context.query = this.options.promptText;100return context;101}102103private static readonly DEFAULT_AGENTIC_PROXY_MODEL = 'exec-subagent-router-a';104105/**106* Get the endpoint to use for the execution subagent107*/108private async getEndpoint() {109const modelName = this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.ExecutionSubagentModel, this._experimentationService) as ChatEndpointFamily | undefined;110const useAgenticProxy = this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.ExecutionSubagentUseAgenticProxy, this._experimentationService);111112if (useAgenticProxy) {113// Use agentic proxy with ExecutionSubagentModel or default to DEFAULT_AGENTIC_PROXY_MODEL114const agenticProxyModel = modelName || ExecutionSubagentToolCallingLoop.DEFAULT_AGENTIC_PROXY_MODEL;115return this.instantiationService.createInstance(ProxyAgenticEndpoint, agenticProxyModel);116}117118if (modelName) {119try {120// Try to get the specified model121const endpoint = await this.endpointProvider.getChatEndpoint(modelName);122if (endpoint.supportsToolCalls) {123return endpoint;124}125// Model does not support tool calls, fallback to main agent endpoint126return await this.endpointProvider.getChatEndpoint(this.options.request);127} catch (error) {128// Model not available, fallback to main agent endpoint129return await this.endpointProvider.getChatEndpoint(this.options.request);130}131} else {132// No model name specified, use main agent endpoint133return await this.endpointProvider.getChatEndpoint(this.options.request);134}135}136137protected async buildPrompt(buildpromptContext: IBuildPromptContext, progress: Progress<ChatResponseReferencePart | ChatResponseProgressPart>, token: CancellationToken): Promise<IBuildPromptResult> {138const endpoint = await this.getEndpoint();139const maxExecutionTurns = this._configurationService.getExperimentBasedConfig(ConfigKey.Advanced.ExecutionSubagentToolCallLimit, this._experimentationService);140141const render = (hasBackgroundCommand: boolean) => PromptRenderer.create(142this.instantiationService,143endpoint,144ExecutionSubagentPrompt,145{146promptContext: buildpromptContext,147maxExecutionTurns,148hasBackgroundCommand,149}150).render(progress, token);151152// If a previous render observed any background terminal commands, tell the153// prompt to nudge the model to stop issuing tool calls and produce its154// <final_answer>. Even with `getAvailableTools` returning [], the model155// may still attempt a (failed) tool call and trigger another iteration,156// so the nudge needs to persist across iterations.157const hadBackgroundBefore = this._backgroundCommands.length > 0;158let result = await render(hadBackgroundBefore);159160// After rendering, scan the rendered tool results for background commands.161// Every tool call rendered into the prompt (including those executed just162// now during this render) emits a ToolResultMetadata entry on163// `result.metadata`.164this.collectBackgroundCommands(buildpromptContext, result);165166// If a background command was first detected during this render, the nudge167// wasn't in the prompt we just built. Re-render with the nudge so the LLM168// in this same iteration sees the instruction to produce <final_answer>.169if (!hadBackgroundBefore && this._backgroundCommands.length > 0) {170const cache = buildpromptContext.toolCallResults;171// Write to the tool result cache so that the second render doesn't172// re-run all tool calls that happened during the first render173if (cache) {174for (const meta of result.metadata.getAll(ToolResultMetadata)) {175cache[meta.toolCallId] = meta.result;176}177}178result = await render(true);179}180181return result;182}183184private collectBackgroundCommands(buildpromptContext: IBuildPromptContext, result: IBuildPromptResult): void {185const lastRound = buildpromptContext.toolCallRounds?.at(-1);186if (!lastRound) {187return;188}189190// Index only this round's terminal calls. Calls from earlier rounds were191// already evaluated on prior iterations.192interface ITerminalCall {193readonly command: string;194/** True if the model called the tool with mode="async" or195* isBackground=true, regardless of how it actually ran. */196readonly invokedAsAsync: boolean;197}198const terminalCallsById = new Map<string, ITerminalCall>();199for (const tc of lastRound.toolCalls) {200if (tc.name !== ToolName.CoreRunInTerminal || this._seenBackgroundCallIds.has(tc.id)) {201continue;202}203let command = '';204let invokedAsAsync = false;205try {206const args = JSON.parse(tc.arguments) as { command?: unknown; mode?: unknown; isBackground?: unknown };207if (typeof args?.command === 'string') {208command = args.command;209}210invokedAsAsync = args?.mode === 'async' || args?.isBackground === true;211} catch {212// arguments may not be valid JSON on partial rounds; skip extraction213}214terminalCallsById.set(tc.id, { command, invokedAsAsync });215}216if (terminalCallsById.size === 0) {217return;218}219220for (const meta of result.metadata.getAll(ToolResultMetadata)) {221const call = terminalCallsById.get(meta.toolCallId);222if (!call) {223continue;224}225const termId = this.getTerminalId(meta.result);226if (!termId) {227// No termId means the call didn't produce a terminal (e.g., errored228// before execution). Nothing to track or note about.229continue;230}231const timeoutMs = this.getTimeoutMsIfTimedOut(meta.result);232if (timeoutMs !== undefined) {233this._seenBackgroundCallIds.add(meta.toolCallId);234this._backgroundCommands.push({235command: call.command,236termId,237reason: 'timeout',238timeoutMs,239});240} else if (call.invokedAsAsync) {241this._seenBackgroundCallIds.add(meta.toolCallId);242this._backgroundCommands.push({243command: call.command,244termId,245reason: 'async',246});247}248}249}250251/**252* Reads the `id` (terminal ID) field from a `run_in_terminal` tool result's253* `toolMetadata`, if present. `toolMetadata` is exposed on tool results via254* the chatParticipantPrivate proposed API and is not on the public255* LanguageModelToolResult2 type, so we narrow with an `in` check.256*/257private getTerminalId(toolResult: LanguageModelToolResult2): string | undefined {258if (!('toolMetadata' in toolResult)) {259return undefined;260}261const metadata = (toolResult as { toolMetadata?: unknown }).toolMetadata;262if (!metadata || typeof metadata !== 'object') {263return undefined;264}265const m = metadata as { id?: unknown };266return typeof m.id === 'string' ? m.id : undefined;267}268269/**270* Returns the configured timeout (ms) if the result indicates a sync271* `run_in_terminal` call timed out and was moved to the background; returns272* `undefined` otherwise. See vscode core: runInTerminalTool.ts which sets273* `timedOut: true` and `timeoutMs` on `toolMetadata` for that case.274*/275private getTimeoutMsIfTimedOut(toolResult: LanguageModelToolResult2): number | undefined {276if (!('toolMetadata' in toolResult)) {277return undefined;278}279const metadata = (toolResult as { toolMetadata?: unknown }).toolMetadata;280if (!metadata || typeof metadata !== 'object') {281return undefined;282}283const m = metadata as { timedOut?: unknown; timeoutMs?: unknown };284if (m.timedOut !== true) {285return undefined;286}287return typeof m.timeoutMs === 'number' ? m.timeoutMs : undefined;288}289290protected async getAvailableTools(): Promise<LanguageModelToolInformation[]> {291// If any previous terminal call has moved to the background (timeout or292// async), expose no tools so the model cannot make further calls and is293// forced to produce its <final_answer>.294if (this._backgroundCommands.length > 0) {295return [];296}297298const endpoint = await this.getEndpoint();299const allTools = this.toolsService.getEnabledTools(this.options.request, endpoint);300301const allowedExecutionTools = new Set([302ToolName.CoreRunInTerminal303]);304305return allTools.filter(tool => allowedExecutionTools.has(tool.name as ToolName));306}307308protected async fetch({ messages, finishedCb, requestOptions, modelCapabilities }: ToolCallingLoopFetchOptions, token: CancellationToken): Promise<ChatResponse> {309const endpoint = await this.getEndpoint();310return endpoint.makeChatRequest2({311debugName: ExecutionSubagentToolCallingLoop.ID,312messages,313finishedCb,314location: this.options.location,315modelCapabilities: { ...modelCapabilities, reasoningEffort: undefined },316requestOptions: {317...(requestOptions ?? {}),318temperature: 0319},320// This loop is inside a tool called from another request, so never user initiated321userInitiatedRequest: false,322telemetryProperties: {323requestId: this.options.subAgentInvocationId,324messageId: randomUUID(),325messageSource: 'chat.editAgent',326subType: 'subagent/execution',327conversationId: this.options.conversation.sessionId,328parentToolCallId: this.options.parentToolCallId,329parentHeaderRequestId: this.options.parentHeaderRequestId,330},331}, token);332}333}334335336