Skip to content

Commit 31c73f1

Browse files
authored
integrate bufferOutputPolling into outputMonitor (microsoft#262585)
1 parent 0feef08 commit 31c73f1

File tree

6 files changed

+453
-80
lines changed

6 files changed

+453
-80
lines changed

src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/outputMonitor.ts

Lines changed: 0 additions & 74 deletions
This file was deleted.
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
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 { timeout } from '../../../../../../../base/common/async.js';
7+
import { CancellationToken } from '../../../../../../../base/common/cancellation.js';
8+
import { Emitter, Event } from '../../../../../../../base/common/event.js';
9+
import { MarkdownString } from '../../../../../../../base/common/htmlContent.js';
10+
import { Disposable } from '../../../../../../../base/common/lifecycle.js';
11+
import { localize } from '../../../../../../../nls.js';
12+
import { ExtensionIdentifier } from '../../../../../../../platform/extensions/common/extensions.js';
13+
import { ChatElicitationRequestPart } from '../../../../../chat/browser/chatElicitationRequestPart.js';
14+
import { ChatModel } from '../../../../../chat/common/chatModel.js';
15+
import { IChatService } from '../../../../../chat/common/chatService.js';
16+
import { ILanguageModelsService, ChatMessageRole } from '../../../../../chat/common/languageModels.js';
17+
import { IToolInvocationContext } from '../../../../../chat/common/languageModelToolsService.js';
18+
import { ITaskService } from '../../../../../tasks/common/taskService.js';
19+
import { PollingConsts } from '../../bufferOutputPolling.js';
20+
import { IPollingResult, OutputMonitorState, IExecution, IRacePollingOrPromptResult } from './types.js';
21+
import { getTextResponseFromStream } from './utils.js';
22+
23+
export interface IOutputMonitor extends Disposable {
24+
readonly isIdle: boolean;
25+
26+
readonly onDidFinishCommand: Event<void>;
27+
readonly onDidIdle: Event<void>;
28+
readonly onDidTimeout: Event<void>;
29+
30+
startMonitoring(
31+
command: string,
32+
invocationContext: any,
33+
token: CancellationToken
34+
): Promise<IPollingResult>;
35+
}
36+
37+
export class OutputMonitor extends Disposable implements IOutputMonitor {
38+
private _isIdle = false;
39+
get isIdle(): boolean { return this._isIdle; }
40+
41+
private _state: OutputMonitorState = OutputMonitorState.Initial;
42+
get state(): OutputMonitorState { return this._state; }
43+
44+
private readonly _onDidFinishCommand = this._register(new Emitter<void>());
45+
readonly onDidFinishCommand = this._onDidFinishCommand.event;
46+
private readonly _onDidIdle = this._register(new Emitter<void>());
47+
readonly onDidIdle = this._onDidIdle.event;
48+
private readonly _onDidTimeout = this._register(new Emitter<void>());
49+
readonly onDidTimeout = this._onDidTimeout.event;
50+
51+
constructor(
52+
private readonly _execution: IExecution,
53+
private readonly _pollFn: ((execution: IExecution, token: CancellationToken, taskService: ITaskService) => Promise<IPollingResult | undefined>) | undefined,
54+
@ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService,
55+
@ITaskService private readonly _taskService: ITaskService,
56+
@IChatService private readonly _chatService: IChatService
57+
) {
58+
super();
59+
}
60+
61+
async startMonitoring(
62+
command: string,
63+
invocationContext: any,
64+
token: CancellationToken
65+
): Promise<IPollingResult & { pollDurationMs: number }> {
66+
67+
const pollStartTime = Date.now();
68+
69+
let result = await this._pollForOutputAndIdle(this._execution, false, token, this._pollFn);
70+
71+
if (this._state === OutputMonitorState.Timeout) {
72+
result = await this._racePollingOrPrompt(
73+
() => this._pollForOutputAndIdle(this._execution, true, token, this._pollFn),
74+
() => this._promptForMorePolling(command, token, invocationContext),
75+
result,
76+
);
77+
}
78+
79+
return { ...result, pollDurationMs: Date.now() - pollStartTime };
80+
}
81+
82+
/**
83+
* Waits for either polling to complete (terminal idle or timeout) or for the user to respond to a prompt.
84+
* If polling completes first, the prompt is removed. If the prompt completes first and is accepted, polling continues.
85+
*/
86+
private async _racePollingOrPrompt(
87+
pollFn: () => Promise<IRacePollingOrPromptResult>,
88+
promptFn: () => Promise<{ promise: Promise<boolean>; part?: Pick<ChatElicitationRequestPart, 'hide' | 'dispose'> }>,
89+
originalResult: IPollingResult,
90+
): Promise<IRacePollingOrPromptResult> {
91+
type Winner =
92+
| { kind: 'poll'; result: IRacePollingOrPromptResult }
93+
| { kind: 'prompt'; continuePolling: boolean };
94+
95+
const { promise: promptP, part } = await promptFn();
96+
97+
const pollPromise = pollFn().then<Winner>(result => ({ kind: 'poll', result }));
98+
const promptPromise = promptP.then<Winner>(continuePolling => ({ kind: 'prompt', continuePolling }));
99+
100+
let winner: Winner;
101+
try {
102+
winner = await Promise.race([pollPromise, promptPromise]);
103+
} finally {
104+
part?.hide();
105+
part?.dispose?.();
106+
}
107+
108+
if (winner.kind === 'poll') {
109+
return winner.result;
110+
}
111+
112+
if (winner.kind === 'prompt' && !winner.continuePolling) {
113+
this._state = OutputMonitorState.Cancelled;
114+
return { ...originalResult, state: this._state };
115+
}
116+
return await pollFn();
117+
}
118+
119+
120+
private async _promptForMorePolling(command: string, token: CancellationToken, context: IToolInvocationContext): Promise<{ promise: Promise<boolean>; part?: ChatElicitationRequestPart }> {
121+
if (token.isCancellationRequested || this._state === OutputMonitorState.Cancelled) {
122+
return { promise: Promise.resolve(false) };
123+
}
124+
this._state = OutputMonitorState.Prompting;
125+
const chatModel = this._chatService.getSession(context.sessionId);
126+
if (chatModel instanceof ChatModel) {
127+
const request = chatModel.getRequests().at(-1);
128+
if (request) {
129+
let part: ChatElicitationRequestPart | undefined = undefined;
130+
const promise = new Promise<boolean>(resolve => {
131+
const thePart = part = this._register(new ChatElicitationRequestPart(
132+
new MarkdownString(localize('poll.terminal.waiting', "Continue waiting for \`{0}\`?", command)),
133+
new MarkdownString(localize('poll.terminal.polling', "This will continue to poll for output to determine when the terminal becomes idle for up to 2 minutes.")),
134+
'',
135+
localize('poll.terminal.accept', 'Yes'),
136+
localize('poll.terminal.reject', 'No'),
137+
async () => {
138+
thePart.state = 'accepted';
139+
thePart.hide();
140+
thePart.dispose();
141+
resolve(true);
142+
},
143+
async () => {
144+
thePart.state = 'rejected';
145+
thePart.hide();
146+
this._state = OutputMonitorState.Cancelled;
147+
resolve(false);
148+
}
149+
));
150+
chatModel.acceptResponseProgress(request, thePart);
151+
});
152+
153+
return { promise, part };
154+
}
155+
}
156+
return { promise: Promise.resolve(false) };
157+
}
158+
159+
private async _pollForOutputAndIdle(
160+
execution: IExecution,
161+
extendedPolling: boolean,
162+
token: CancellationToken,
163+
pollFn?: (execution: IExecution, token: CancellationToken, taskService: ITaskService) => Promise<IPollingResult | undefined> | undefined,
164+
recursionDepth: number = 0
165+
): Promise<IPollingResult> {
166+
this._state = OutputMonitorState.Polling;
167+
const maxWaitMs = extendedPolling ? PollingConsts.ExtendedPollingMaxDuration : PollingConsts.FirstPollingMaxDuration;
168+
const maxInterval = PollingConsts.MaxPollingIntervalDuration;
169+
let currentInterval = PollingConsts.MinPollingDuration;
170+
171+
let lastBufferLength = 0;
172+
let noNewDataCount = 0;
173+
let buffer = '';
174+
175+
let pollDuration = 0;
176+
while (true) {
177+
if (token.isCancellationRequested) {
178+
this._state = OutputMonitorState.Cancelled;
179+
return { output: buffer, state: this._state };
180+
}
181+
182+
if (pollDuration >= maxWaitMs) {
183+
this._state = OutputMonitorState.Timeout;
184+
break;
185+
}
186+
187+
const waitTime = Math.min(currentInterval, maxWaitMs - pollDuration);
188+
await timeout(waitTime, token);
189+
pollDuration += waitTime;
190+
191+
currentInterval = Math.min(currentInterval * 2, maxInterval);
192+
193+
buffer = execution.getOutput();
194+
const currentBufferLength = buffer.length;
195+
196+
if (currentBufferLength === lastBufferLength) {
197+
noNewDataCount++;
198+
} else {
199+
noNewDataCount = 0;
200+
lastBufferLength = currentBufferLength;
201+
}
202+
203+
const isInactive = execution.isActive && ((await execution.isActive()) === false);
204+
const isActive = execution.isActive && ((await execution.isActive()) === true);
205+
const noNewData = noNewDataCount >= PollingConsts.MinNoDataEvents;
206+
207+
if (noNewData || isInactive) {
208+
this._state = OutputMonitorState.Idle;
209+
break;
210+
}
211+
if (noNewData && isActive) {
212+
noNewDataCount = 0;
213+
lastBufferLength = currentBufferLength;
214+
continue;
215+
}
216+
}
217+
218+
const customPollingResult = await pollFn?.(execution, token, this._taskService);
219+
if (customPollingResult) {
220+
return customPollingResult;
221+
}
222+
const modelOutputEvalResponse = await this._assessOutputForErrors(buffer, token);
223+
return { state: this._state, modelOutputEvalResponse, output: buffer, autoReplyCount: recursionDepth };
224+
}
225+
226+
227+
private async _assessOutputForErrors(buffer: string, token: CancellationToken): Promise<string> {
228+
const models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', family: 'gpt-4o-mini' });
229+
if (!models.length) {
230+
return 'No models available';
231+
}
232+
233+
const response = await this._languageModelsService.sendChatRequest(models[0], new ExtensionIdentifier('github.copilot-chat'), [{ role: ChatMessageRole.User, content: [{ type: 'text', value: `Evaluate this terminal output to determine if there were errors or if the command ran successfully: ${buffer}.` }] }], {}, token);
234+
235+
try {
236+
const responseFromStream = getTextResponseFromStream(response);
237+
await Promise.all([response.result, responseFromStream]);
238+
return await responseFromStream;
239+
} catch (err) {
240+
return 'Error occurred ' + err;
241+
}
242+
}
243+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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 type { Task } from '../../../../../tasks/common/taskService.js';
7+
import type { ITerminalInstance } from '../../../../../terminal/browser/terminal.js';
8+
import type { ILinkLocation } from '../../taskHelpers.js';
9+
10+
export interface IConfirmationPrompt {
11+
prompt: string;
12+
options: string[];
13+
}
14+
15+
export interface IExecution {
16+
getOutput: () => string;
17+
isActive?: () => Promise<boolean>;
18+
task?: Task | Pick<Task, 'configurationProperties'>;
19+
instance: Pick<ITerminalInstance, 'sendText' | 'instanceId'>;
20+
}
21+
22+
export interface IPollingResult {
23+
output: string;
24+
resources?: ILinkLocation[];
25+
modelOutputEvalResponse?: string;
26+
state: OutputMonitorState;
27+
autoReplyCount?: number;
28+
}
29+
30+
export enum OutputMonitorState {
31+
Initial = 'Initial',
32+
Idle = 'Idle',
33+
Polling = 'Polling',
34+
Prompting = 'Prompting',
35+
Timeout = 'Timeout',
36+
Active = 'Active',
37+
Cancelled = 'Cancelled',
38+
}
39+
40+
export interface IRacePollingOrPromptResult {
41+
output: string;
42+
pollDurationMs?: number;
43+
modelOutputEvalResponse?: string;
44+
state: OutputMonitorState;
45+
}
46+
47+
export const enum PollingConsts {
48+
MinNoDataEvents = 2, // Minimum number of no data checks before considering the terminal idle
49+
MinPollingDuration = 500,
50+
FirstPollingMaxDuration = 20000, // 20 seconds
51+
ExtendedPollingMaxDuration = 120000, // 2 minutes
52+
MaxPollingIntervalDuration = 2000, // 2 seconds
53+
MaxRecursionCount = 5
54+
}

0 commit comments

Comments
 (0)