Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/

import type { IMarker as IXtermMarker } from '@xterm/xterm';
import { DeferredPromise, timeout, type CancelablePromise } from '../../../../../../base/common/async.js';
import { DeferredPromise, RunOnceScheduler, timeout, type CancelablePromise } from '../../../../../../base/common/async.js';
import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js';
import { Codicon } from '../../../../../../base/common/codicons.js';
import { CancellationError } from '../../../../../../base/common/errors.js';
Expand Down Expand Up @@ -1483,6 +1483,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
let exitCode: number | undefined;
let altBufferResult: IToolResult | undefined;
let didTimeout = false;
let didIdleSilence = false;
let didInputNeeded = false;
let didSensitiveAutoCancelled = false;
// Covers both terminals that start as background (persistentSession) and
Expand Down Expand Up @@ -1622,7 +1623,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
resultText += `${outputAnalyzerMessage}\n`;
}
resultText += pollingResult.output;
resultText += `\n${this._buildInputNeededSteeringText(chatSessionResource, termId, /*mentionTimeout*/ false)}`;
resultText += `\n${this._buildInputNeededSteeringText(chatSessionResource, termId, 'none')}`;
} else if (pollingResult) {
resultText += `\n The command is still running, with output:\n`;
if (outputAnalyzerMessage) {
Expand Down Expand Up @@ -1672,7 +1673,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
));
}
});
const raceCandidates: Promise<{ type: 'completed'; result: ITerminalExecuteStrategyResult } | { type: 'background' } | { type: 'timeout' } | { type: 'inputNeeded' }>[] = [
const raceCandidates: Promise<{ type: 'completed'; result: ITerminalExecuteStrategyResult } | { type: 'background' } | { type: 'timeout' } | { type: 'inputNeeded' } | { type: 'idleSilence' }>[] = [
executionPromise.then(result => ({ type: 'completed' as const, result })),
continueInBackgroundPromise.then(() => ({ type: 'background' as const })),
new Promise<{ type: 'inputNeeded' }>(resolve => {
Expand All @@ -1686,8 +1687,24 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
if (timeoutRacePromise) {
raceCandidates.push(timeoutRacePromise);
}
const raceResult = await Promise.race(raceCandidates);
raceCleanup.dispose();
// Idle-silence promotion: if no terminal output arrives for N ms,
// hand control back to the model with the terminal ID + output
// collected so far. The process keeps running — model can poll,
// send input, or kill it. Default 60s; 0 disables.
const idleSilenceMs = this._configurationService.getValue<number>(TerminalChatAgentToolsSettingId.IdleSilenceTimeoutMs) ?? 60000;
if (idleSilenceMs > 0) {
const idleSilenceDeferred = new DeferredPromise<{ type: 'idleSilence' }>();
const idleSilenceScheduler = raceCleanup.add(new RunOnceScheduler(() => idleSilenceDeferred.complete({ type: 'idleSilence' as const }), idleSilenceMs));
raceCleanup.add(toolTerminal.instance.onData(() => idleSilenceScheduler.schedule()));
idleSilenceScheduler.schedule();
raceCandidates.push(idleSilenceDeferred.p);
}
let raceResult: { type: 'completed'; result: ITerminalExecuteStrategyResult } | { type: 'background' } | { type: 'timeout' } | { type: 'inputNeeded' } | { type: 'idleSilence' };
try {
raceResult = await Promise.race(raceCandidates);
} finally {
raceCleanup.dispose();
}

if (raceResult.type === 'inputNeeded') {
// Output monitor detected the terminal is waiting for input.
Expand Down Expand Up @@ -1722,6 +1739,18 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
const timeoutOutput = execution.getOutput();
outputLineCount = timeoutOutput ? count(timeoutOutput.trim(), '\n') + 1 : 0;
terminalResult = timeoutOutput ?? '';
} else if (raceResult.type === 'idleSilence') {
// No output for N ms - promote to background and hand back to model. Process keeps running.
this._logService.debug(`RunInTerminalTool: Idle silence reached (${idleSilenceMs}ms), promoting to background`);
error = 'idleSilence';
didIdleSilence = true;
isBackgroundExecution = true;
toolTerminal.isBackground = true;
this._sessionTerminalAssociations.delete(chatSessionResource);
await this._associateProcessIdWithSession(toolTerminal.instance, chatSessionResource, termId, toolTerminal.shellIntegrationQuality, true);
const idleSilenceOutput = execution.getOutput();
outputLineCount = idleSilenceOutput ? count(idleSilenceOutput.trim(), '\n') + 1 : 0;
terminalResult = idleSilenceOutput ?? '';
} else {
const executeResult = raceResult.result;
// Reset user input state after command execution completes
Expand Down Expand Up @@ -1975,12 +2004,17 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
if (didSensitiveAutoCancelled) {
resultText.push(`Note: The command in terminal ID ${termId} was prompting for a password, passphrase, or other secret. The user is unavailable (auto-approve / autopilot mode is on, so no human can focus the terminal to type a secret) and the command has been cancelled. Stop, do NOT retry the command, do NOT call ${TerminalToolId.SendToTerminal}, and do NOT call vscode_askQuestions for the secret. Tell the user to run the command interactively when they are available.\n\n`);
} else if (didInputNeeded) {
resultText.push(`Note: The command is running in terminal ID ${termId} and may be waiting for input.\n${this._buildInputNeededSteeringText(chatSessionResource, termId, /*mentionTimeout*/ false)}\n\n`);
resultText.push(`Note: The command is running in terminal ID ${termId} and may be waiting for input.\n${this._buildInputNeededSteeringText(chatSessionResource, termId, 'none')}\n\n`);
} else if (didTimeout && timeoutValue !== undefined && timeoutValue > 0) {
const notificationHint = shouldSendNotifications
? ' You will be automatically notified on your next turn when it completes.'
: '';
resultText.push(`Note: Command timed out after ${timeoutValue}ms. The command may still be running in terminal ID ${termId}.${notificationHint}\n${this._buildInputNeededSteeringText(chatSessionResource, termId, /*mentionTimeout*/ true)}\n\n`);
resultText.push(`Note: Command timed out after ${timeoutValue}ms. The command may still be running in terminal ID ${termId}.${notificationHint}\n${this._buildInputNeededSteeringText(chatSessionResource, termId, 'timeout')}\n\n`);
} else if (didIdleSilence) {
const notificationHint = shouldSendNotifications
? ' You will be automatically notified on your next turn when it completes.'
: '';
resultText.push(`Note: The command produced no new output for an extended period and was moved to background terminal ID ${termId}; the process is still running and has not been killed.${notificationHint}\n${this._buildInputNeededSteeringText(chatSessionResource, termId, 'idleSilence')}\n\n`);
}
const outputAnalyzerMessage = await this._getOutputAnalyzerMessage(exitCode, terminalResult, command, didSandboxWrapCommand);
if (outputAnalyzerMessage) {
Expand Down Expand Up @@ -2034,11 +2068,12 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
* tokens) must never be routed through `vscode_askQuestions`
* because answers to that tool are sent through the model — the
* user is told to type those values directly into the terminal.
* `kill_terminal` is only advertised on the timeout branch — suggesting it
* in the general case leads the model to terminate valid interactive
* sessions (e.g. `npm init`) instead of driving them.
* `kill_terminal` is only advertised when the command may be hung
* (`'timeout'` or `'idleSilence'`) — suggesting it in the general case
* leads the model to terminate valid interactive sessions (e.g.
* `npm init`) instead of driving them.
*/
private _buildInputNeededSteeringText(chatSessionResource: URI, termId: string, mentionTimeout: boolean): string {
private _buildInputNeededSteeringText(chatSessionResource: URI, termId: string, hungHint: 'none' | 'timeout' | 'idleSilence'): string {
const isAutoApproved = isSessionAutoApproveLevel(chatSessionResource, this._configurationService, this._chatWidgetService, this._chatService);
const lines: string[] = [];
lines.push(`This note is not a signal to end the turn — pick one of the actions below and continue.`);
Expand All @@ -2052,8 +2087,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
lines.push(` 1. If the command may still be producing output or the shell prompt has not returned, call ${TerminalToolId.GetTerminalOutput} with id="${termId}" to continue polling. This is the default and safest action when unsure.`);
lines.push(` 2. Only if the output clearly ends with a real non-secret input prompt (Continue? (y/n), Enter selection, etc. — a normal shell prompt like \`$\` or \`#\` does NOT count), call the vscode_askQuestions tool to ask the user, then send each answer using ${TerminalToolId.SendToTerminal} with id="${termId}" (which returns the next few lines of output). Repeat one prompt at a time. NEVER route secret prompts (passwords, passphrases, tokens, API keys, etc.) through vscode_askQuestions — answers to that tool are sent through the model. For secret prompts, tell the user to type the value directly into the terminal and stop.`);
}
if (mentionTimeout) {
if (hungHint === 'timeout') {
lines.push(` 3. A timeout does not mean the command failed — call ${TerminalToolId.GetTerminalOutput} with id="${termId}" to continue polling. Only call ${TerminalToolId.KillTerminal} if the command is genuinely hung and you need to retry with a different approach.`);
} else if (hungHint === 'idleSilence') {
lines.push(` 3. Producing no output for an extended period does not mean the command failed — call ${TerminalToolId.GetTerminalOutput} with id="${termId}" to continue polling. Only call ${TerminalToolId.KillTerminal} if the command is genuinely hung and you need to retry with a different approach.`);
}
return lines.join('\n');
}
Expand Down Expand Up @@ -2553,7 +2590,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
}
lastInputNeededOutput = currentOutput;
lastInputNeededNotificationTime = now;
const inputAction = this._buildInputNeededSteeringText(chatSessionResource, termId, /*mentionTimeout*/ false);
const inputAction = this._buildInputNeededSteeringText(chatSessionResource, termId, 'none');
const message = `[Terminal ${termId} notification: command may be waiting for input — assess the output below.]\n${inputAction}\nTerminal output:\n${currentOutput}`;

this._logService.debug(`RunInTerminalTool: Input needed in background terminal ${termId}, notifying chat session`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const enum TerminalChatAgentToolsSettingId {
AgentSandboxAdvancedRuntime = 'chat.agent.sandbox.advanced.runtime',
PreventShellHistory = 'chat.tools.terminal.preventShellHistory',
EnforceTimeoutFromModel = 'chat.tools.terminal.enforceTimeoutFromModel',
IdleSilenceTimeoutMs = 'chat.tools.terminal.idleSilenceTimeoutMs',
DetachBackgroundProcesses = 'chat.tools.terminal.detachBackgroundProcesses',
BackgroundNotifications = 'chat.tools.terminal.backgroundNotifications',
IdlePollInterval = 'chat.tools.terminal.idlePollInterval',
Expand Down Expand Up @@ -720,6 +721,17 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary<IConfigurati
},
markdownDescription: localize('enforceTimeoutFromModel.description', "Whether to enforce the timeout value provided by the model in the run in terminal tool. When enabled, if the model provides a timeout parameter, the tool will stop tracking the command after that duration and return the output collected so far."),
},
[TerminalChatAgentToolsSettingId.IdleSilenceTimeoutMs]: {
restricted: true,
type: 'number',
default: 60000,
minimum: 0,
tags: ['experimental'],
experiment: {
mode: 'auto'
},
markdownDescription: localize('idleSilenceTimeoutMs.description', "Number of milliseconds the run in terminal tool will wait for new output from a synchronous command before moving it to a background terminal and returning what was collected so far. The process is not killed — the tool returns the terminal ID so the model can poll, send input, or kill it. Set to {0} to disable.", '`0`'),
},
[TerminalChatAgentToolsSettingId.DetachBackgroundProcesses]: {
included: false,
restricted: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2427,6 +2427,35 @@ suite('RunInTerminalTool', () => {
});
});

suite('input-needed steering text', () => {
function buildSteeringText(hungHint: 'none' | 'timeout' | 'idleSilence'): string {
const sessionResource = LocalChatSessionUri.forSession('input-needed-steering-session');
// eslint-disable-next-line @typescript-eslint/naming-convention
return (runInTerminalTool as unknown as { _buildInputNeededSteeringText(s: URI, t: string, h: 'none' | 'timeout' | 'idleSilence'): string })
._buildInputNeededSteeringText(sessionResource, 'test-term-id', hungHint);
}

test('none mode does not mention timeout, idle silence, or kill_terminal', () => {
const text = buildSteeringText('none');
ok(!text.toLowerCase().includes('timeout'), 'Expected no mention of timeout in the input-needed (none) hint');
ok(!text.toLowerCase().includes('no output'), 'Expected no mention of idle silence in the input-needed (none) hint');
ok(!text.includes(TerminalToolId.KillTerminal), 'Expected kill_terminal not to be advertised in the input-needed (none) hint');
});

test('timeout mode advertises kill_terminal and mentions timeout', () => {
const text = buildSteeringText('timeout');
ok(text.toLowerCase().includes('timeout'), 'Expected timeout hint to mention "timeout"');
ok(text.includes(TerminalToolId.KillTerminal), 'Expected timeout hint to advertise kill_terminal');
});

test('idleSilence mode advertises kill_terminal without saying "timeout"', () => {
const text = buildSteeringText('idleSilence');
ok(!text.toLowerCase().includes('timeout'), 'Idle-silence hint must not refer to a timeout');
ok(text.toLowerCase().includes('no output'), 'Expected idle-silence hint to describe the no-output condition');
ok(text.includes(TerminalToolId.KillTerminal), 'Expected idle-silence hint to advertise kill_terminal');
});
});

suite('unique rules deduplication', () => {
test('should properly deduplicate rules with same sourceText in auto-approve info', async () => {
setAutoApprove({
Expand Down
Loading