Revert "feat: Introduce an AI-driven interactive shell mode with new"

This reverts commit 651ad63ed6.
This commit is contained in:
Gaurav Ghosh
2026-04-08 07:31:17 -07:00
parent 651ad63ed6
commit e7f8d9cf1a
22 changed files with 84 additions and 907 deletions
-1
View File
@@ -1009,7 +1009,6 @@ export async function loadCliConfig(
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell, enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
shellBackgroundCompletionBehavior: settings.tools?.shell shellBackgroundCompletionBehavior: settings.tools?.shell
?.backgroundCompletionBehavior as string | undefined, ?.backgroundCompletionBehavior as string | undefined,
interactiveShellMode: settings.tools?.shell?.interactiveShellMode,
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout, shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
enableShellOutputEfficiency: enableShellOutputEfficiency:
settings.tools?.shell?.enableShellOutputEfficiency ?? true, settings.tools?.shell?.enableShellOutputEfficiency ?? true,
-20
View File
@@ -1512,26 +1512,6 @@ const SETTINGS_SCHEMA = {
{ label: 'Notify', value: 'notify' }, { label: 'Notify', value: 'notify' },
], ],
}, },
interactiveShellMode: {
type: 'enum',
label: 'Interactive Shell Mode',
category: 'Tools',
requiresRestart: true,
default: undefined as 'human' | 'ai' | 'off' | undefined,
description: oneLine`
Controls who can interact with backgrounded shell processes.
"human": user can Tab-focus and type into shells (default).
"ai": model gets write_to_shell/read_shell tools for TUI interaction.
"off": no interactive shell.
When set, overrides enableInteractiveShell.
`,
showInDialog: true,
options: [
{ value: 'human', label: 'Human (Tab to focus)' },
{ value: 'ai', label: 'AI (model-driven tools)' },
{ value: 'off', label: 'Off' },
],
},
pager: { pager: {
type: 'string', type: 'string',
label: 'Pager', label: 'Pager',
+1 -17
View File
@@ -92,23 +92,7 @@ export function shellReducer(
nextTasks.delete(action.pid); nextTasks.delete(action.pid);
} }
nextTasks.set(action.pid, updatedTask); nextTasks.set(action.pid, updatedTask);
return { ...state, backgroundTasks: nextTasks };
// Auto-hide panel when all tasks have exited
let nextVisible = state.isBackgroundTaskVisible;
if (action.update.status === 'exited') {
const hasRunning = Array.from(nextTasks.values()).some(
(s) => s.status === 'running',
);
if (!hasRunning) {
nextVisible = false;
}
}
return {
...state,
backgroundTasks: nextTasks,
isBackgroundTaskVisible: nextVisible,
};
} }
case 'APPEND_TASK_OUTPUT': { case 'APPEND_TASK_OUTPUT': {
const task = state.backgroundTasks.get(action.pid); const task = state.backgroundTasks.get(action.pid);
@@ -1,101 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useMemo, useRef } from 'react';
import { type BackgroundTask } from './shellReducer.js';
export interface BackgroundShellManagerProps {
backgroundTasks: Map<number, BackgroundTask>;
backgroundTaskCount: number;
isBackgroundTaskVisible: boolean;
activePtyId: number | null | undefined;
embeddedShellFocused: boolean;
setEmbeddedShellFocused: (focused: boolean) => void;
terminalHeight: number;
}
export function useBackgroundShellManager({
backgroundTasks,
backgroundTaskCount,
isBackgroundTaskVisible,
activePtyId,
embeddedShellFocused,
setEmbeddedShellFocused,
terminalHeight,
}: BackgroundShellManagerProps) {
const [isBackgroundShellListOpen, setIsBackgroundShellListOpen] =
useState(false);
const [activeBackgroundShellPid, setActiveBackgroundShellPid] = useState<
number | null
>(null);
const prevShellCountRef = useRef(backgroundTaskCount);
useEffect(() => {
if (backgroundTasks.size === 0) {
if (activeBackgroundShellPid !== null) {
setActiveBackgroundShellPid(null);
}
if (isBackgroundShellListOpen) {
setIsBackgroundShellListOpen(false);
}
} else if (
activeBackgroundShellPid === null ||
!backgroundTasks.has(activeBackgroundShellPid)
) {
// If active shell is closed or none selected, select the first one
setActiveBackgroundShellPid(backgroundTasks.keys().next().value ?? null);
} else if (backgroundTaskCount > prevShellCountRef.current) {
// A new shell was added — auto-switch to the newest one (last in the map)
const pids = Array.from(backgroundTasks.keys());
const newestPid = pids[pids.length - 1];
if (newestPid !== undefined && newestPid !== activeBackgroundShellPid) {
setActiveBackgroundShellPid(newestPid);
}
}
prevShellCountRef.current = backgroundTaskCount;
}, [
backgroundTasks,
activeBackgroundShellPid,
backgroundTaskCount,
isBackgroundShellListOpen,
]);
useEffect(() => {
if (embeddedShellFocused) {
const hasActiveForegroundShell = !!activePtyId;
const hasVisibleBackgroundShell =
isBackgroundTaskVisible && backgroundTasks.size > 0;
if (!hasActiveForegroundShell && !hasVisibleBackgroundShell) {
setEmbeddedShellFocused(false);
}
}
}, [
isBackgroundTaskVisible,
backgroundTasks,
embeddedShellFocused,
backgroundTaskCount,
activePtyId,
setEmbeddedShellFocused,
]);
const backgroundShellHeight = useMemo(
() =>
isBackgroundTaskVisible && backgroundTasks.size > 0
? Math.max(Math.floor(terminalHeight * 0.3), 5)
: 0,
[isBackgroundTaskVisible, backgroundTasks.size, terminalHeight],
);
return {
isBackgroundShellListOpen,
setIsBackgroundShellListOpen,
activeBackgroundShellPid,
setActiveBackgroundShellPid,
backgroundShellHeight,
};
}
@@ -661,10 +661,6 @@ export const useExecutionLifecycle = (
(s: BackgroundTask) => s.status === 'running', (s: BackgroundTask) => s.status === 'running',
).length; ).length;
const showBackgroundShell = useCallback(() => {
dispatch({ type: 'SET_VISIBILITY', visible: true });
}, [dispatch]);
return { return {
handleShellCommand, handleShellCommand,
activeShellPtyId: state.activeShellPtyId, activeShellPtyId: state.activeShellPtyId,
@@ -672,7 +668,6 @@ export const useExecutionLifecycle = (
backgroundTaskCount, backgroundTaskCount,
isBackgroundTaskVisible: state.isBackgroundTaskVisible, isBackgroundTaskVisible: state.isBackgroundTaskVisible,
toggleBackgroundTasks, toggleBackgroundTasks,
showBackgroundShell,
backgroundCurrentExecution, backgroundCurrentExecution,
registerBackgroundTask, registerBackgroundTask,
dismissBackgroundTask, dismissBackgroundTask,
@@ -390,7 +390,6 @@ export const useGeminiStream = (
backgroundTaskCount, backgroundTaskCount,
isBackgroundTaskVisible, isBackgroundTaskVisible,
toggleBackgroundTasks, toggleBackgroundTasks,
showBackgroundShell,
backgroundCurrentExecution, backgroundCurrentExecution,
registerBackgroundTask, registerBackgroundTask,
dismissBackgroundTask, dismissBackgroundTask,
@@ -1918,7 +1917,6 @@ export const useGeminiStream = (
backgroundedTool.command, backgroundedTool.command,
backgroundedTool.initialOutput, backgroundedTool.initialOutput,
); );
showBackgroundShell();
} }
} }
@@ -2058,7 +2056,6 @@ export const useGeminiStream = (
modelSwitchedFromQuotaError, modelSwitchedFromQuotaError,
addItem, addItem,
registerBackgroundTask, registerBackgroundTask,
showBackgroundShell,
consumeUserHint, consumeUserHint,
isLowErrorVerbosity, isLowErrorVerbosity,
maybeAddSuppressedToolErrorNote, maybeAddSuppressedToolErrorNote,
+1 -26
View File
@@ -36,8 +36,6 @@ import { GlobTool } from '../tools/glob.js';
import { ActivateSkillTool } from '../tools/activate-skill.js'; import { ActivateSkillTool } from '../tools/activate-skill.js';
import { EditTool } from '../tools/edit.js'; import { EditTool } from '../tools/edit.js';
import { ShellTool } from '../tools/shell.js'; import { ShellTool } from '../tools/shell.js';
import { WriteToShellTool } from '../tools/write-to-shell.js';
import { ReadShellTool } from '../tools/read-shell.js';
import { WriteFileTool } from '../tools/write-file.js'; import { WriteFileTool } from '../tools/write-file.js';
import { WebFetchTool } from '../tools/web-fetch.js'; import { WebFetchTool } from '../tools/web-fetch.js';
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js'; import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
@@ -658,7 +656,6 @@ export interface ConfigParameters {
useRipgrep?: boolean; useRipgrep?: boolean;
enableInteractiveShell?: boolean; enableInteractiveShell?: boolean;
shellBackgroundCompletionBehavior?: string; shellBackgroundCompletionBehavior?: string;
interactiveShellMode?: 'human' | 'ai' | 'off';
skipNextSpeakerCheck?: boolean; skipNextSpeakerCheck?: boolean;
shellExecutionConfig?: ShellExecutionConfig; shellExecutionConfig?: ShellExecutionConfig;
extensionManagement?: boolean; extensionManagement?: boolean;
@@ -871,7 +868,6 @@ export class Config implements McpContext, AgentLoopContext {
| 'inject' | 'inject'
| 'notify' | 'notify'
| 'silent'; | 'silent';
private readonly interactiveShellMode: 'human' | 'ai' | 'off';
private readonly skipNextSpeakerCheck: boolean; private readonly skipNextSpeakerCheck: boolean;
private readonly useBackgroundColor: boolean; private readonly useBackgroundColor: boolean;
private readonly useAlternateBuffer: boolean; private readonly useAlternateBuffer: boolean;
@@ -1239,14 +1235,6 @@ export class Config implements McpContext, AgentLoopContext {
this.shellBackgroundCompletionBehavior = 'silent'; this.shellBackgroundCompletionBehavior = 'silent';
} }
// interactiveShellMode takes precedence over enableInteractiveShell.
// If not set, derive from enableInteractiveShell for backward compat.
if (params.interactiveShellMode) {
this.interactiveShellMode = params.interactiveShellMode;
} else {
this.interactiveShellMode = this.enableInteractiveShell ? 'human' : 'off';
}
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true; this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true;
this.shellExecutionConfig = { this.shellExecutionConfig = {
terminalWidth: params.shellExecutionConfig?.terminalWidth ?? 80, terminalWidth: params.shellExecutionConfig?.terminalWidth ?? 80,
@@ -3223,14 +3211,10 @@ export class Config implements McpContext, AgentLoopContext {
return ( return (
this.interactive && this.interactive &&
this.ptyInfo !== 'child_process' && this.ptyInfo !== 'child_process' &&
this.interactiveShellMode !== 'off' this.enableInteractiveShell
); );
} }
getInteractiveShellMode(): 'human' | 'ai' | 'off' {
return this.interactiveShellMode;
}
isSkillsSupportEnabled(): boolean { isSkillsSupportEnabled(): boolean {
return this.skillsSupport; return this.skillsSupport;
} }
@@ -3591,15 +3575,6 @@ export class Config implements McpContext, AgentLoopContext {
new ReadBackgroundOutputTool(this, this.messageBus), new ReadBackgroundOutputTool(this, this.messageBus),
), ),
); );
// Register AI-driven interactive shell tools when mode is 'ai'
if (this.getInteractiveShellMode() === 'ai') {
maybeRegister(WriteToShellTool, () =>
registry.registerTool(new WriteToShellTool(this.messageBus)),
);
maybeRegister(ReadShellTool, () =>
registry.registerTool(new ReadShellTool(this.messageBus)),
);
}
if (!this.isMemoryManagerEnabled()) { if (!this.isMemoryManagerEnabled()) {
maybeRegister(MemoryTool, () => maybeRegister(MemoryTool, () =>
registry.registerTool(new MemoryTool(this.messageBus, this.storage)), registry.registerTool(new MemoryTool(this.messageBus, this.storage)),
@@ -200,7 +200,6 @@ export class PromptProvider {
enableShellEfficiency: enableShellEfficiency:
context.config.getEnableShellOutputEfficiency(), context.config.getEnableShellOutputEfficiency(),
interactiveShellEnabled: context.config.isInteractiveShellEnabled(), interactiveShellEnabled: context.config.isInteractiveShellEnabled(),
interactiveShellMode: context.config.getInteractiveShellMode(),
topicUpdateNarration: topicUpdateNarration:
context.config.isTopicUpdateNarrationEnabled(), context.config.isTopicUpdateNarrationEnabled(),
memoryManagerEnabled: context.config.isMemoryManagerEnabled(), memoryManagerEnabled: context.config.isMemoryManagerEnabled(),
+1 -15
View File
@@ -18,8 +18,6 @@ import {
MEMORY_TOOL_NAME, MEMORY_TOOL_NAME,
READ_FILE_TOOL_NAME, READ_FILE_TOOL_NAME,
SHELL_TOOL_NAME, SHELL_TOOL_NAME,
WRITE_TO_SHELL_TOOL_NAME,
READ_SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME, WRITE_FILE_TOOL_NAME,
WRITE_TODOS_TOOL_NAME, WRITE_TODOS_TOOL_NAME,
GREP_PARAM_TOTAL_MAX_MATCHES, GREP_PARAM_TOTAL_MAX_MATCHES,
@@ -83,7 +81,6 @@ export interface PrimaryWorkflowsOptions {
export interface OperationalGuidelinesOptions { export interface OperationalGuidelinesOptions {
interactive: boolean; interactive: boolean;
interactiveShellEnabled: boolean; interactiveShellEnabled: boolean;
interactiveShellMode?: 'human' | 'ai' | 'off';
topicUpdateNarration: boolean; topicUpdateNarration: boolean;
memoryManagerEnabled: boolean; memoryManagerEnabled: boolean;
} }
@@ -394,7 +391,7 @@ export function renderOperationalGuidelines(
- **Command Execution:** Use the ${formatToolName(SHELL_TOOL_NAME)} tool for running shell commands, remembering the safety rule to explain modifying commands first.${toolUsageInteractive( - **Command Execution:** Use the ${formatToolName(SHELL_TOOL_NAME)} tool for running shell commands, remembering the safety rule to explain modifying commands first.${toolUsageInteractive(
options.interactive, options.interactive,
options.interactiveShellEnabled, options.interactiveShellEnabled,
)}${toolUsageRememberingFacts(options)}${toolUsageAiShell(options)} )}${toolUsageRememberingFacts(options)}
- **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible. - **Confirmation Protocol:** If a tool call is declined or cancelled, respect the decision immediately. Do not re-attempt the action or "negotiate" for the same tool call unless the user explicitly directs you to. Offer an alternative technical path if possible.
## Interaction Details ## Interaction Details
@@ -803,17 +800,6 @@ function toolUsageInteractive(
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).`; - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).`;
} }
function toolUsageAiShell(options: OperationalGuidelinesOptions): string {
if (options.interactiveShellMode !== 'ai') return '';
return `
- **AI-Driven Interactive Shell:** Commands using \`wait_for_output_seconds\` auto-promote to background when they stall. Once promoted, use ${formatToolName(READ_SHELL_TOOL_NAME)} to see the terminal screen, then ${formatToolName(WRITE_TO_SHELL_TOOL_NAME)} to send text input and/or special keys (arrows, Enter, Ctrl-C, etc.).
- Set \`wait_for_output_seconds\` **low (2-5)** for commands that prompt for input (npx, installers, REPLs). Set **high (60+)** for long builds. Omit for instant commands.
- **Always read the screen before writing input.** The screen state tells you what the process is waiting for.
- When waiting for a command to finish (e.g. npm install), use ${formatToolName(READ_SHELL_TOOL_NAME)} with \`wait_seconds\` to delay before reading. Do NOT poll in a tight loop.
- **Clean up when done:** when your task is complete, kill background processes with ${formatToolName(WRITE_TO_SHELL_TOOL_NAME)} sending Ctrl-C, or note the PID for the user to clean up.
- You are the sole operator of promoted shells the user cannot type into them.`;
}
function toolUsageRememberingFacts( function toolUsageRememberingFacts(
options: OperationalGuidelinesOptions, options: OperationalGuidelinesOptions,
): string { ): string {
@@ -105,7 +105,6 @@ export interface ShellExecutionConfig {
backgroundCompletionBehavior?: 'inject' | 'notify' | 'silent'; backgroundCompletionBehavior?: 'inject' | 'notify' | 'silent';
originalCommand?: string; originalCommand?: string;
sessionId?: string; sessionId?: string;
autoPromoteTimeoutMs?: number;
} }
/** /**
@@ -890,21 +889,6 @@ export class ShellExecutionService {
sessionId: shellExecutionConfig.sessionId, sessionId: shellExecutionConfig.sessionId,
}); });
let autoPromoteTimer: NodeJS.Timeout | undefined;
const resetAutoPromoteTimer = () => {
if (shellExecutionConfig.autoPromoteTimeoutMs !== undefined) {
if (autoPromoteTimer) clearTimeout(autoPromoteTimer);
autoPromoteTimer = setTimeout(() => {
ShellExecutionService.background(
ptyPid,
shellExecutionConfig.sessionId,
);
}, shellExecutionConfig.autoPromoteTimeoutMs);
}
};
resetAutoPromoteTimer();
const result = ExecutionLifecycleService.attachExecution(ptyPid, { const result = ExecutionLifecycleService.attachExecution(ptyPid, {
executionMethod: ptyInfo?.name ?? 'node-pty', executionMethod: ptyInfo?.name ?? 'node-pty',
writeInput: (input) => { writeInput: (input) => {
@@ -1082,7 +1066,6 @@ export class ShellExecutionService {
}); });
const handleOutput = (data: Buffer) => { const handleOutput = (data: Buffer) => {
resetAutoPromoteTimer();
processingChain = processingChain.then( processingChain = processingChain.then(
() => () =>
new Promise<void>((resolveChunk) => { new Promise<void>((resolveChunk) => {
@@ -1152,7 +1135,6 @@ export class ShellExecutionService {
ptyProcess.onExit( ptyProcess.onExit(
({ exitCode, signal }: { exitCode: number; signal?: number }) => { ({ exitCode, signal }: { exitCode: number; signal?: number }) => {
if (autoPromoteTimer) clearTimeout(autoPromoteTimer);
exited = true; exited = true;
abortSignal.removeEventListener('abort', abortHandler); abortSignal.removeEventListener('abort', abortHandler);
// Attempt to destroy the PTY to ensure FD is closed // Attempt to destroy the PTY to ensure FD is closed
@@ -1238,7 +1220,6 @@ export class ShellExecutionService {
); );
const abortHandler = async () => { const abortHandler = async () => {
if (autoPromoteTimer) clearTimeout(autoPromoteTimer);
if (ptyProcess.pid && !exited) { if (ptyProcess.pid && !exited) {
await killProcessGroup({ await killProcessGroup({
pid: ptyPid, pid: ptyPid,
@@ -1417,28 +1398,6 @@ export class ShellExecutionService {
return ExecutionLifecycleService.subscribe(pid, listener); return ExecutionLifecycleService.subscribe(pid, listener);
} }
/**
* Reads the current rendered screen state of a running process.
* Returns the full terminal buffer text for PTY processes,
* or the accumulated output for child processes.
*
* @param pid The process ID of the target process.
* @returns The screen text, or null if the process is not found.
*/
static readScreen(pid: number): string | null {
const activePty = this.activePtys.get(pid);
if (activePty) {
return getFullBufferText(activePty.headlessTerminal);
}
const activeChild = this.activeChildProcesses.get(pid);
if (activeChild) {
return activeChild.state.output;
}
return null;
}
/** /**
* Resizes the pseudo-terminal (PTY) of a running process. * Resizes the pseudo-terminal (PTY) of a running process.
* *
@@ -56,18 +56,6 @@ export const READ_FILE_PARAM_END_LINE = 'end_line';
export const SHELL_TOOL_NAME = 'run_shell_command'; export const SHELL_TOOL_NAME = 'run_shell_command';
export const SHELL_PARAM_COMMAND = 'command'; export const SHELL_PARAM_COMMAND = 'command';
export const SHELL_PARAM_IS_BACKGROUND = 'is_background'; export const SHELL_PARAM_IS_BACKGROUND = 'is_background';
export const SHELL_PARAM_WAIT_SECONDS = 'wait_for_output_seconds';
// -- write_to_shell --
export const WRITE_TO_SHELL_TOOL_NAME = 'write_to_shell';
export const WRITE_TO_SHELL_PARAM_PID = 'pid';
export const WRITE_TO_SHELL_PARAM_INPUT = 'input';
export const WRITE_TO_SHELL_PARAM_SPECIAL_KEYS = 'special_keys';
// -- read_shell --
export const READ_SHELL_TOOL_NAME = 'read_shell';
export const READ_SHELL_PARAM_PID = 'pid';
export const READ_SHELL_PARAM_WAIT_SECONDS = 'wait_seconds';
// -- write_file -- // -- write_file --
export const WRITE_FILE_TOOL_NAME = 'write_file'; export const WRITE_FILE_TOOL_NAME = 'write_file';
@@ -27,8 +27,6 @@ export {
LS_TOOL_NAME, LS_TOOL_NAME,
READ_FILE_TOOL_NAME, READ_FILE_TOOL_NAME,
SHELL_TOOL_NAME, SHELL_TOOL_NAME,
WRITE_TO_SHELL_TOOL_NAME,
READ_SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME, WRITE_FILE_TOOL_NAME,
EDIT_TOOL_NAME, EDIT_TOOL_NAME,
WEB_SEARCH_TOOL_NAME, WEB_SEARCH_TOOL_NAME,
@@ -75,12 +73,6 @@ export {
LS_PARAM_IGNORE, LS_PARAM_IGNORE,
SHELL_PARAM_COMMAND, SHELL_PARAM_COMMAND,
SHELL_PARAM_IS_BACKGROUND, SHELL_PARAM_IS_BACKGROUND,
SHELL_PARAM_WAIT_SECONDS,
WRITE_TO_SHELL_PARAM_PID,
WRITE_TO_SHELL_PARAM_INPUT,
WRITE_TO_SHELL_PARAM_SPECIAL_KEYS,
READ_SHELL_PARAM_PID,
READ_SHELL_PARAM_WAIT_SECONDS,
WEB_SEARCH_PARAM_QUERY, WEB_SEARCH_PARAM_QUERY,
WEB_FETCH_PARAM_PROMPT, WEB_FETCH_PARAM_PROMPT,
READ_MANY_PARAM_INCLUDE, READ_MANY_PARAM_INCLUDE,
@@ -257,21 +249,18 @@ export function getShellDefinition(
enableInteractiveShell: boolean, enableInteractiveShell: boolean,
enableEfficiency: boolean, enableEfficiency: boolean,
enableToolSandboxing: boolean = false, enableToolSandboxing: boolean = false,
interactiveShellMode?: string,
): ToolDefinition { ): ToolDefinition {
return { return {
base: getShellDeclaration( base: getShellDeclaration(
enableInteractiveShell, enableInteractiveShell,
enableEfficiency, enableEfficiency,
enableToolSandboxing, enableToolSandboxing,
interactiveShellMode,
), ),
overrides: (modelId) => overrides: (modelId) =>
getToolSet(modelId).run_shell_command( getToolSet(modelId).run_shell_command(
enableInteractiveShell, enableInteractiveShell,
enableEfficiency, enableEfficiency,
enableToolSandboxing, enableToolSandboxing,
interactiveShellMode,
), ),
}; };
} }
@@ -22,7 +22,6 @@ import {
PARAM_DIR_PATH, PARAM_DIR_PATH,
SHELL_PARAM_IS_BACKGROUND, SHELL_PARAM_IS_BACKGROUND,
EXIT_PLAN_PARAM_PLAN_FILENAME, EXIT_PLAN_PARAM_PLAN_FILENAME,
SHELL_PARAM_WAIT_SECONDS,
SKILL_PARAM_NAME, SKILL_PARAM_NAME,
PARAM_ADDITIONAL_PERMISSIONS, PARAM_ADDITIONAL_PERMISSIONS,
UPDATE_TOPIC_TOOL_NAME, UPDATE_TOPIC_TOOL_NAME,
@@ -37,9 +36,7 @@ import {
export function getShellToolDescription( export function getShellToolDescription(
enableInteractiveShell: boolean, enableInteractiveShell: boolean,
enableEfficiency: boolean, enableEfficiency: boolean,
interactiveShellMode?: string,
): string { ): string {
const isAiMode = interactiveShellMode === 'ai';
const efficiencyGuidelines = enableEfficiency const efficiencyGuidelines = enableEfficiency
? ` ? `
@@ -59,11 +56,6 @@ export function getShellToolDescription(
Background PIDs: Only included if background processes were started. Background PIDs: Only included if background processes were started.
Process Group PGID: Only included if available.`; Process Group PGID: Only included if available.`;
if (isAiMode) {
const autoPromoteInstructions = `Commands that do not complete within \`${SHELL_PARAM_WAIT_SECONDS}\` seconds are automatically promoted to background. Once promoted, use \`write_to_shell\` and \`read_shell\` to interact with the process. Do NOT use \`&\` to background commands.`;
return `This tool executes a given shell command as \`bash -c <command>\`. ${autoPromoteInstructions} Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.${efficiencyGuidelines}${returnedInfo}`;
}
if (os.platform() === 'win32') { if (os.platform() === 'win32') {
const backgroundInstructions = enableInteractiveShell const backgroundInstructions = enableInteractiveShell
? `To run a command in the background, set the \`${SHELL_PARAM_IS_BACKGROUND}\` parameter to true. Do NOT use PowerShell background constructs.` ? `To run a command in the background, set the \`${SHELL_PARAM_IS_BACKGROUND}\` parameter to true. Do NOT use PowerShell background constructs.`
@@ -94,33 +86,12 @@ export function getShellDeclaration(
enableInteractiveShell: boolean, enableInteractiveShell: boolean,
enableEfficiency: boolean, enableEfficiency: boolean,
enableToolSandboxing: boolean = false, enableToolSandboxing: boolean = false,
interactiveShellMode?: string,
): FunctionDeclaration { ): FunctionDeclaration {
const isAiMode = interactiveShellMode === 'ai';
// In AI mode, use wait_for_output_seconds instead of is_background
const backgroundParam = isAiMode
? {
[SHELL_PARAM_WAIT_SECONDS]: {
type: 'number' as const,
description:
'Max seconds to wait for command to complete before auto-promoting to background (default: 5). Set low (2-5) for commands likely to prompt for input (npx, installers, REPLs). Set high (60-300) for long builds or installs. Once promoted, use write_to_shell/read_shell to interact.',
},
}
: {
[SHELL_PARAM_IS_BACKGROUND]: {
type: 'boolean' as const,
description:
'Set to true if this command should be run in the background (e.g. for long-running servers or watchers). The command will be started, allowed to run for a brief moment to check for immediate errors, and then moved to the background.',
},
};
return { return {
name: SHELL_TOOL_NAME, name: SHELL_TOOL_NAME,
description: getShellToolDescription( description: getShellToolDescription(
enableInteractiveShell, enableInteractiveShell,
enableEfficiency, enableEfficiency,
interactiveShellMode,
), ),
parametersJsonSchema: { parametersJsonSchema: {
type: 'object', type: 'object',
@@ -149,7 +120,6 @@ export function getShellDeclaration(
description: description:
'Optional. Delay in milliseconds to wait after starting the process in the background. Useful to allow the process to start and generate initial output before returning.', 'Optional. Delay in milliseconds to wait after starting the process in the background. Useful to allow the process to start and generate initial output before returning.',
}, },
...backgroundParam,
...(enableToolSandboxing ...(enableToolSandboxing
? { ? {
[PARAM_ADDITIONAL_PERMISSIONS]: { [PARAM_ADDITIONAL_PERMISSIONS]: {
@@ -337,13 +337,11 @@ export const DEFAULT_LEGACY_SET: CoreToolSet = {
enableInteractiveShell, enableInteractiveShell,
enableEfficiency, enableEfficiency,
enableToolSandboxing, enableToolSandboxing,
interactiveShellMode,
) => ) =>
getShellDeclaration( getShellDeclaration(
enableInteractiveShell, enableInteractiveShell,
enableEfficiency, enableEfficiency,
enableToolSandboxing, enableToolSandboxing,
interactiveShellMode,
), ),
replace: { replace: {
@@ -344,13 +344,11 @@ export const GEMINI_3_SET: CoreToolSet = {
enableInteractiveShell, enableInteractiveShell,
enableEfficiency, enableEfficiency,
enableToolSandboxing, enableToolSandboxing,
interactiveShellMode,
) => ) =>
getShellDeclaration( getShellDeclaration(
enableInteractiveShell, enableInteractiveShell,
enableEfficiency, enableEfficiency,
enableToolSandboxing, enableToolSandboxing,
interactiveShellMode,
), ),
replace: { replace: {
@@ -38,7 +38,6 @@ export interface CoreToolSet {
enableInteractiveShell: boolean, enableInteractiveShell: boolean,
enableEfficiency: boolean, enableEfficiency: boolean,
enableToolSandboxing: boolean, enableToolSandboxing: boolean,
interactiveShellMode?: string,
) => FunctionDeclaration; ) => FunctionDeclaration;
replace: FunctionDeclaration; replace: FunctionDeclaration;
google_web_search: FunctionDeclaration; google_web_search: FunctionDeclaration;
-148
View File
@@ -1,148 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
type ToolInvocation,
type ToolResult,
} from './tools.js';
import { ShellExecutionService } from '../services/shellExecutionService.js';
import {
READ_SHELL_TOOL_NAME,
READ_SHELL_PARAM_PID,
READ_SHELL_PARAM_WAIT_SECONDS,
} from './tool-names.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
export interface ReadShellParams {
pid: number;
wait_seconds?: number;
}
export class ReadShellToolInvocation extends BaseToolInvocation<
ReadShellParams,
ToolResult
> {
constructor(
params: ReadShellParams,
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
) {
super(params, messageBus, _toolName, _toolDisplayName);
}
getDescription(): string {
const waitPart =
this.params.wait_seconds !== undefined
? ` (after ${this.params.wait_seconds}s)`
: '';
return `read shell screen PID ${this.params.pid}${waitPart}`;
}
async execute(signal: AbortSignal): Promise<ToolResult> {
const { pid, wait_seconds } = this.params;
// Wait before reading if requested
if (wait_seconds !== undefined && wait_seconds > 0) {
const waitMs = Math.min(wait_seconds, 30) * 1000; // Cap at 30s
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, waitMs);
const onAbort = () => {
clearTimeout(timer);
resolve();
};
signal.addEventListener('abort', onAbort, { once: true });
});
}
// Validate the PID is active
if (!ShellExecutionService.isPtyActive(pid)) {
return {
llmContent: `Error: No active process found with PID ${pid}. The process may have exited.`,
returnDisplay: `No active process with PID ${pid}.`,
};
}
const screen = ShellExecutionService.readScreen(pid);
if (screen === null) {
return {
llmContent: `Error: Could not read screen for PID ${pid}. The process may have exited.`,
returnDisplay: `Could not read screen for PID ${pid}.`,
};
}
return {
llmContent: screen,
returnDisplay: `Screen read from PID ${pid} (${screen.split('\n').length} lines).`,
};
}
}
export class ReadShellTool extends BaseDeclarativeTool<
ReadShellParams,
ToolResult
> {
static readonly Name = READ_SHELL_TOOL_NAME;
constructor(messageBus: MessageBus) {
super(
ReadShellTool.Name,
'ReadShell',
'Reads the current screen state of a running background shell process. Returns the rendered terminal screen as text, preserving the visual layout. Use after write_to_shell to see updated output, or to check progress of a running command.',
Kind.Read,
{
type: 'object',
properties: {
[READ_SHELL_PARAM_PID]: {
type: 'number',
description:
'The PID of the background process to read from. Obtained from a previous run_shell_command call that was auto-promoted to background or started with is_background=true.',
},
[READ_SHELL_PARAM_WAIT_SECONDS]: {
type: 'number',
description:
'Seconds to wait before reading the screen. Use this to let the process run for a while before checking output (e.g. wait for a build to finish). Max 30 seconds.',
},
},
required: [READ_SHELL_PARAM_PID],
},
messageBus,
false, // output is not markdown
);
}
protected override validateToolParamValues(
params: ReadShellParams,
): string | null {
if (!params.pid || params.pid <= 0) {
return 'PID must be a positive number.';
}
if (
params.wait_seconds !== undefined &&
(params.wait_seconds < 0 || params.wait_seconds > 30)
) {
return 'wait_seconds must be between 0 and 30.';
}
return null;
}
protected createInvocation(
params: ReadShellParams,
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
): ToolInvocation<ReadShellParams, ToolResult> {
return new ReadShellToolInvocation(
params,
messageBus,
_toolName,
_toolDisplayName,
);
}
}
+2 -4
View File
@@ -149,8 +149,6 @@ describe('ShellTool', () => {
getShellBackgroundCompletionBehavior: vi.fn().mockReturnValue('silent'), getShellBackgroundCompletionBehavior: vi.fn().mockReturnValue('silent'),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false), getSandboxEnabled: vi.fn().mockReturnValue(false),
getInteractiveShellMode: vi.fn().mockReturnValue('off'),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
sanitizationConfig: {}, sanitizationConfig: {},
get sandboxManager() { get sandboxManager() {
return mockSandboxManager; return mockSandboxManager;
@@ -424,7 +422,7 @@ describe('ShellTool', () => {
expect(mockShellBackground).toHaveBeenCalledWith( expect(mockShellBackground).toHaveBeenCalledWith(
12345, 12345,
'test-session-id', 'default',
'sleep 10', 'sleep 10',
); );
@@ -668,7 +666,7 @@ describe('ShellTool', () => {
expect(mockShellBackground).toHaveBeenCalledWith( expect(mockShellBackground).toHaveBeenCalledWith(
12345, 12345,
'test-session-id', 'default',
'sleep 10', 'sleep 10',
); );
+79 -90
View File
@@ -33,7 +33,6 @@ import {
import { getErrorMessage } from '../utils/errors.js'; import { getErrorMessage } from '../utils/errors.js';
import { summarizeToolOutput } from '../utils/summarizer.js'; import { summarizeToolOutput } from '../utils/summarizer.js';
import { formatShellOutput } from './shellOutputFormatter.js';
import { import {
ShellExecutionService, ShellExecutionService,
type ShellOutputEvent, type ShellOutputEvent,
@@ -72,7 +71,6 @@ export interface ShellToolParams {
is_background?: boolean; is_background?: boolean;
delay_ms?: number; delay_ms?: number;
[PARAM_ADDITIONAL_PERMISSIONS]?: SandboxPermissions; [PARAM_ADDITIONAL_PERMISSIONS]?: SandboxPermissions;
wait_for_output_seconds?: number;
} }
export class ShellToolInvocation extends BaseToolInvocation< export class ShellToolInvocation extends BaseToolInvocation<
@@ -80,7 +78,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
ToolResult ToolResult
> { > {
private proactivePermissionsConfirmed?: SandboxPermissions; private proactivePermissionsConfirmed?: SandboxPermissions;
private _autoPromoteTimer?: NodeJS.Timeout;
constructor( constructor(
private readonly context: AgentLoopContext, private readonly context: AgentLoopContext,
@@ -226,12 +223,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
} }
override getExplanation(): string { override getExplanation(): string {
let explanation = this.getContextualDetails().trim(); return this.getContextualDetails().trim();
const isAiMode = this.context.config.getInteractiveShellMode() === 'ai';
if (this.params.wait_for_output_seconds !== undefined || isAiMode) {
explanation += ` [auto-background after ${this.params.wait_for_output_seconds ?? 5}s]`;
}
return explanation;
} }
override getPolicyUpdateOptions( override getPolicyUpdateOptions(
@@ -505,21 +497,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
}, timeoutMs); }, timeoutMs);
}; };
let currentPid: number | undefined;
const isAiMode = this.context.config.getInteractiveShellMode() === 'ai';
const shouldAutoPromote =
this.params.wait_for_output_seconds !== undefined || isAiMode;
const waitMs = (this.params.wait_for_output_seconds ?? 5) * 1000;
const resetAutoPromoteTimer = () => {
if (shouldAutoPromote && currentPid) {
if (this._autoPromoteTimer) clearTimeout(this._autoPromoteTimer);
this._autoPromoteTimer = setTimeout(() => {
ShellExecutionService.background(currentPid!);
}, waitMs);
}
};
signal.addEventListener('abort', onAbort, { once: true }); signal.addEventListener('abort', onAbort, { once: true });
timeoutController.signal.addEventListener('abort', onAbort, { timeoutController.signal.addEventListener('abort', onAbort, {
once: true, once: true,
@@ -534,7 +511,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
cwd, cwd,
(event: ShellOutputEvent) => { (event: ShellOutputEvent) => {
resetTimeout(); // Reset timeout on any event resetTimeout(); // Reset timeout on any event
resetAutoPromoteTimer(); // Reset auto-promote on any event
if (!updateOutput) { if (!updateOutput) {
return; return;
} }
@@ -606,7 +582,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
backgroundCompletionBehavior: backgroundCompletionBehavior:
this.context.config.getShellBackgroundCompletionBehavior(), this.context.config.getShellBackgroundCompletionBehavior(),
originalCommand: strippedCommand, originalCommand: strippedCommand,
autoPromoteTimeoutMs: shouldAutoPromote ? waitMs : undefined,
}, },
); );
@@ -643,11 +618,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
}; };
} }
} }
// In AI mode with wait_for_output_seconds, set up auto-promotion timer.
// When the timer fires, promote to background instead of cancelling.
currentPid = pid;
resetAutoPromoteTimer();
} }
const result = await resultPromise; const result = await resultPromise;
@@ -688,73 +658,95 @@ export class ShellToolInvocation extends BaseToolInvocation<
} }
} }
let data: BackgroundExecutionData | undefined;
let llmContent = '';
let timeoutMessage = ''; let timeoutMessage = '';
if (result.aborted) { if (result.aborted) {
if (timeoutController.signal.aborted) { if (timeoutController.signal.aborted) {
timeoutMessage = `Command was automatically cancelled because it exceeded the timeout of ${( timeoutMessage = `Command was automatically cancelled because it exceeded the timeout of ${(
timeoutMs / 60000 timeoutMs / 60000
).toFixed(1)} minutes without output.`; ).toFixed(1)} minutes without output.`;
llmContent = timeoutMessage;
} else {
llmContent =
'Command was cancelled by user before it could complete.';
} }
} if (result.output.trim()) {
llmContent += ` Below is the output before it was cancelled:\n${result.output}`;
const formatterOutput = formatShellOutput({ } else {
params: this.params, llmContent += ' There was no output before it was cancelled.';
result,
debugMode: this.context.config.getDebugMode(),
backgroundPIDs,
isAiMode,
timeoutMessage,
});
let data: BackgroundExecutionData | undefined;
data = formatterOutput.data as BackgroundExecutionData | undefined;
let returnDisplay: string | AnsiOutput = formatterOutput.returnDisplay;
let llmContent = formatterOutput.llmContent;
if (!this.context.config.getDebugMode()) {
if (
!this.params.is_background &&
!result.backgrounded &&
!result.aborted
) {
if (result.output.trim() || result.ansiOutput) {
returnDisplay =
result.ansiOutput && result.ansiOutput.length > 0
? result.ansiOutput
: result.output;
} else {
if (result.signal) {
returnDisplay = `Command terminated by signal: ${result.signal}`;
} else if (result.error) {
returnDisplay = `Command failed: ${getErrorMessage(result.error)}`;
} else if (result.exitCode !== null && result.exitCode !== 0) {
returnDisplay = `Command exited with code: ${result.exitCode}`;
}
}
} }
} } else if (this.params.is_background || result.backgrounded) {
llmContent = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
// Replace wrapper command with actual command in error messages
if (result.error && !result.aborted) {
llmContent = llmContent.replaceAll(
commandToExecute,
this.params.command,
);
}
// Update data with specific things needed by ShellTool
if (this.params.is_background || result.backgrounded) {
data = { data = {
...data, pid: result.pid,
initialOutput: result.output,
pid: result.pid!,
command: this.params.command, command: this.params.command,
initialOutput: result.output,
}; };
} else if (result.exitCode !== null && result.exitCode !== 0) { } else {
data = { // Create a formatted error string for display, replacing the wrapper command
exitCode: result.exitCode, // with the user-facing command.
isError: true, const llmContentParts = [`Output: ${result.output || '(empty)'}`];
} as BackgroundExecutionData;
if (result.error) {
const finalError = result.error.message.replaceAll(
commandToExecute,
this.params.command,
);
llmContentParts.push(`Error: ${finalError}`);
}
if (result.exitCode !== null && result.exitCode !== 0) {
llmContentParts.push(`Exit Code: ${result.exitCode}`);
data = {
exitCode: result.exitCode,
isError: true,
};
}
if (result.signal) {
llmContentParts.push(`Signal: ${result.signal}`);
}
if (backgroundPIDs.length) {
llmContentParts.push(`Background PIDs: ${backgroundPIDs.join(', ')}`);
}
if (result.pid) {
llmContentParts.push(`Process Group PGID: ${result.pid}`);
}
llmContent = llmContentParts.join('\n');
}
let returnDisplay: string | AnsiOutput = '';
if (this.context.config.getDebugMode()) {
returnDisplay = llmContent;
} else {
if (this.params.is_background || result.backgrounded) {
returnDisplay = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
} else if (result.aborted) {
const cancelMsg = timeoutMessage || 'Command cancelled by user.';
if (result.output.trim()) {
returnDisplay = `${cancelMsg}\n\nOutput before cancellation:\n${result.output}`;
} else {
returnDisplay = cancelMsg;
}
} else if (result.output.trim() || result.ansiOutput) {
returnDisplay =
result.ansiOutput && result.ansiOutput.length > 0
? result.ansiOutput
: result.output;
} else {
if (result.signal) {
returnDisplay = `Command terminated by signal: ${result.signal}`;
} else if (result.error) {
returnDisplay = `Command failed: ${getErrorMessage(result.error)}`;
} else if (result.exitCode !== null && result.exitCode !== 0) {
returnDisplay = `Command exited with code: ${result.exitCode}`;
}
// If output is empty and command succeeded (code 0, no error/signal/abort),
// returnDisplay will remain empty, which is fine.
}
} }
// Heuristic Sandbox Denial Detection // Heuristic Sandbox Denial Detection
@@ -937,8 +929,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
}; };
} finally { } finally {
if (timeoutTimer) clearTimeout(timeoutTimer); if (timeoutTimer) clearTimeout(timeoutTimer);
const autoTimer = this._autoPromoteTimer;
if (autoTimer) clearTimeout(autoTimer);
signal.removeEventListener('abort', onAbort); signal.removeEventListener('abort', onAbort);
timeoutController.signal.removeEventListener('abort', onAbort); timeoutController.signal.removeEventListener('abort', onAbort);
try { try {
@@ -1017,7 +1007,6 @@ export class ShellTool extends BaseDeclarativeTool<
this.context.config.getEnableInteractiveShell(), this.context.config.getEnableInteractiveShell(),
this.context.config.getEnableShellOutputEfficiency(), this.context.config.getEnableShellOutputEfficiency(),
this.context.config.getSandboxEnabled(), this.context.config.getSandboxEnabled(),
this.context.config.getInteractiveShellMode(),
); );
return resolveToolDeclaration(definition, modelId); return resolveToolDeclaration(definition, modelId);
} }
@@ -1,128 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type ShellExecutionResult } from '../services/shellExecutionService.js';
import { type ShellToolParams } from './shell.js';
export interface FormatShellOutputOptions {
params: ShellToolParams;
result: ShellExecutionResult;
debugMode: boolean;
timeoutMessage?: string;
backgroundPIDs: number[];
summarizedOutput?: string;
isAiMode: boolean;
}
export interface FormattedShellOutput {
llmContent: string;
returnDisplay: string;
data: Record<string, unknown>;
}
export function formatShellOutput(
options: FormatShellOutputOptions,
): FormattedShellOutput {
const {
params,
result,
debugMode,
timeoutMessage,
backgroundPIDs,
summarizedOutput,
} = options;
let llmContent = '';
let data: Record<string, unknown> = {};
if (result.aborted) {
llmContent = timeoutMessage || 'Command cancelled by user.';
if (result.output.trim()) {
llmContent += ` Below is the output before it was cancelled:\n${result.output}`;
} else {
llmContent += ' There was no output before it was cancelled.';
}
} else if (params.is_background || result.backgrounded) {
const isAutoPromoted = result.backgrounded && !params.is_background;
if (isAutoPromoted) {
llmContent = `Command auto-promoted to background (PID: ${result.pid}). The process is still running. To check its screen state, call the read_shell tool with pid ${result.pid}. To send input or keystrokes, call the write_to_shell tool with pid ${result.pid}. If the process does not exit on its own when done, kill it with write_to_shell using special_keys=["Ctrl-C"].`;
} else {
llmContent = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
}
data = {
pid: result.pid,
command: params.command,
directory: params.dir_path,
backgrounded: true,
};
} else {
const llmContentParts: string[] = [];
let content = summarizedOutput ?? result.output.trim();
if (!content) {
content = '(empty)';
}
llmContentParts.push(`Output: ${content}`);
if (result.error) {
llmContentParts.push(`Error: ${result.error.message}`);
}
if (result.exitCode !== null && result.exitCode !== 0) {
llmContentParts.push(`Exit Code: ${result.exitCode}`);
}
if (result.signal !== null) {
llmContentParts.push(`Signal: ${result.signal}`);
}
if (backgroundPIDs.length) {
llmContentParts.push(`Background PIDs: ${backgroundPIDs.join(', ')}`);
}
if (result.pid) {
llmContentParts.push(`Process Group PGID: ${result.pid}`);
}
llmContent = llmContentParts.join('\n');
}
let returnDisplay = '';
if (debugMode) {
returnDisplay = llmContent;
} else {
if (params.is_background || result.backgrounded) {
const isAutoPromotedDisplay =
result.backgrounded && !params.is_background;
if (isAutoPromotedDisplay) {
returnDisplay = `Command auto-promoted to background (PID: ${result.pid}).`;
} else {
returnDisplay = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
}
} else if (result.aborted) {
const cancelMsg = timeoutMessage || 'Command cancelled by user.';
if (result.output.trim()) {
returnDisplay = `${cancelMsg}\n\nOutput before cancellation:\n${result.output}`;
} else {
returnDisplay = cancelMsg;
}
} else if (result.error) {
returnDisplay = `Command failed: ${result.error.message}`;
} else if (result.exitCode !== 0 && result.exitCode !== null) {
returnDisplay = `Command exited with code ${result.exitCode}`;
if (result.output.trim()) {
returnDisplay += `\n\n${result.output}`;
}
} else if (summarizedOutput) {
returnDisplay = `Command succeeded. Output summarized:\n${summarizedOutput}`;
} else {
returnDisplay = `Command succeeded.`;
if (result.output.trim()) {
returnDisplay += `\n\n${result.output}`;
}
}
}
return { llmContent, returnDisplay, data };
}
-19
View File
@@ -10,8 +10,6 @@ import {
LS_TOOL_NAME, LS_TOOL_NAME,
READ_FILE_TOOL_NAME, READ_FILE_TOOL_NAME,
SHELL_TOOL_NAME, SHELL_TOOL_NAME,
WRITE_TO_SHELL_TOOL_NAME,
READ_SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME, WRITE_FILE_TOOL_NAME,
EDIT_TOOL_NAME, EDIT_TOOL_NAME,
WEB_SEARCH_TOOL_NAME, WEB_SEARCH_TOOL_NAME,
@@ -54,12 +52,6 @@ import {
LS_PARAM_IGNORE, LS_PARAM_IGNORE,
SHELL_PARAM_COMMAND, SHELL_PARAM_COMMAND,
SHELL_PARAM_IS_BACKGROUND, SHELL_PARAM_IS_BACKGROUND,
SHELL_PARAM_WAIT_SECONDS,
WRITE_TO_SHELL_PARAM_PID,
WRITE_TO_SHELL_PARAM_INPUT,
WRITE_TO_SHELL_PARAM_SPECIAL_KEYS,
READ_SHELL_PARAM_PID,
READ_SHELL_PARAM_WAIT_SECONDS,
WEB_SEARCH_PARAM_QUERY, WEB_SEARCH_PARAM_QUERY,
WEB_FETCH_PARAM_PROMPT, WEB_FETCH_PARAM_PROMPT,
READ_MANY_PARAM_INCLUDE, READ_MANY_PARAM_INCLUDE,
@@ -98,8 +90,6 @@ export {
LS_TOOL_NAME, LS_TOOL_NAME,
READ_FILE_TOOL_NAME, READ_FILE_TOOL_NAME,
SHELL_TOOL_NAME, SHELL_TOOL_NAME,
WRITE_TO_SHELL_TOOL_NAME,
READ_SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME, WRITE_FILE_TOOL_NAME,
EDIT_TOOL_NAME, EDIT_TOOL_NAME,
WEB_SEARCH_TOOL_NAME, WEB_SEARCH_TOOL_NAME,
@@ -146,12 +136,6 @@ export {
LS_PARAM_IGNORE, LS_PARAM_IGNORE,
SHELL_PARAM_COMMAND, SHELL_PARAM_COMMAND,
SHELL_PARAM_IS_BACKGROUND, SHELL_PARAM_IS_BACKGROUND,
SHELL_PARAM_WAIT_SECONDS,
WRITE_TO_SHELL_PARAM_PID,
WRITE_TO_SHELL_PARAM_INPUT,
WRITE_TO_SHELL_PARAM_SPECIAL_KEYS,
READ_SHELL_PARAM_PID,
READ_SHELL_PARAM_WAIT_SECONDS,
WEB_SEARCH_PARAM_QUERY, WEB_SEARCH_PARAM_QUERY,
WEB_FETCH_PARAM_PROMPT, WEB_FETCH_PARAM_PROMPT,
READ_MANY_PARAM_INCLUDE, READ_MANY_PARAM_INCLUDE,
@@ -195,7 +179,6 @@ export const TOOLS_REQUIRING_NARROWING = new Set([
WRITE_FILE_TOOL_NAME, WRITE_FILE_TOOL_NAME,
EDIT_TOOL_NAME, EDIT_TOOL_NAME,
SHELL_TOOL_NAME, SHELL_TOOL_NAME,
WRITE_TO_SHELL_TOOL_NAME,
]); ]);
export const TRACKER_CREATE_TASK_TOOL_NAME = 'tracker_create_task'; export const TRACKER_CREATE_TASK_TOOL_NAME = 'tracker_create_task';
@@ -268,8 +251,6 @@ export const ALL_BUILTIN_TOOL_NAMES = [
WEB_FETCH_TOOL_NAME, WEB_FETCH_TOOL_NAME,
EDIT_TOOL_NAME, EDIT_TOOL_NAME,
SHELL_TOOL_NAME, SHELL_TOOL_NAME,
WRITE_TO_SHELL_TOOL_NAME,
READ_SHELL_TOOL_NAME,
GREP_TOOL_NAME, GREP_TOOL_NAME,
READ_MANY_FILES_TOOL_NAME, READ_MANY_FILES_TOOL_NAME,
READ_FILE_TOOL_NAME, READ_FILE_TOOL_NAME,
-230
View File
@@ -1,230 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
type ToolConfirmationOutcome,
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
type ToolInvocation,
type ToolResult,
type ToolCallConfirmationDetails,
type ToolExecuteConfirmationDetails,
} from './tools.js';
import { ShellExecutionService } from '../services/shellExecutionService.js';
import {
WRITE_TO_SHELL_TOOL_NAME,
WRITE_TO_SHELL_PARAM_PID,
WRITE_TO_SHELL_PARAM_INPUT,
WRITE_TO_SHELL_PARAM_SPECIAL_KEYS,
} from './tool-names.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
/**
* Mapping of named special keys to their ANSI escape sequences.
*/
const SPECIAL_KEY_MAP: Record<string, string> = {
Enter: '\r',
Tab: '\t',
Up: '\x1b[A',
Down: '\x1b[B',
Left: '\x1b[D',
Right: '\x1b[C',
Escape: '\x1b',
Backspace: '\x7f',
'Ctrl-C': '\x03',
'Ctrl-D': '\x04',
'Ctrl-Z': '\x1a',
Space: ' ',
Delete: '\x1b[3~',
Home: '\x1b[H',
End: '\x1b[F',
};
const VALID_SPECIAL_KEYS = Object.keys(SPECIAL_KEY_MAP);
/** Delay in ms to wait after writing input for the process to react. */
const POST_INPUT_DELAY_MS = 150;
export interface WriteToShellParams {
pid: number;
input?: string;
special_keys?: string[];
}
export class WriteToShellToolInvocation extends BaseToolInvocation<
WriteToShellParams,
ToolResult
> {
constructor(
params: WriteToShellParams,
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
) {
super(params, messageBus, _toolName, _toolDisplayName);
}
getDescription(): string {
const parts: string[] = [`write to shell PID ${this.params.pid}`];
if (this.params.input) {
const display =
this.params.input.length > 50
? `${this.params.input.substring(0, 50)}...`
: this.params.input;
parts.push(`input: "${display}"`);
}
if (this.params.special_keys?.length) {
parts.push(`keys: [${this.params.special_keys.join(', ')}]`);
}
return parts.join(' ');
}
protected override async getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
const confirmationDetails: ToolExecuteConfirmationDetails = {
type: 'exec',
title: 'Confirm Shell Input',
command: this.getDescription(),
rootCommand: 'write_to_shell',
rootCommands: ['write_to_shell'],
onConfirm: async (_outcome: ToolConfirmationOutcome) => {
// Policy updates handled centrally
},
};
return confirmationDetails;
}
async execute(_signal: AbortSignal): Promise<ToolResult> {
const { pid, input, special_keys } = this.params;
// Validate the PID is active
if (!ShellExecutionService.isPtyActive(pid)) {
return {
llmContent: `Error: No active process found with PID ${pid}. The process may have exited.`,
returnDisplay: `No active process with PID ${pid}.`,
};
}
// Validate special keys
if (special_keys?.length) {
const invalidKeys = special_keys.filter(
(k) => !VALID_SPECIAL_KEYS.includes(k),
);
if (invalidKeys.length > 0) {
return {
llmContent: `Error: Invalid special keys: ${invalidKeys.join(', ')}. Valid keys are: ${VALID_SPECIAL_KEYS.join(', ')}`,
returnDisplay: `Invalid special keys: ${invalidKeys.join(', ')}`,
};
}
}
// Send text input
if (input) {
ShellExecutionService.writeToPty(pid, input);
}
// Send special keys
if (special_keys?.length) {
for (const key of special_keys) {
const sequence = SPECIAL_KEY_MAP[key];
if (sequence) {
ShellExecutionService.writeToPty(pid, sequence);
}
}
}
// Wait briefly for the process to react
await new Promise((resolve) => setTimeout(resolve, POST_INPUT_DELAY_MS));
// Read the screen after writing
const screen = ShellExecutionService.readScreen(pid);
if (screen === null) {
return {
llmContent: `Input sent, but the process (PID ${pid}) has exited.`,
returnDisplay: `Process exited after input.`,
};
}
return {
llmContent: `Input sent to PID ${pid}. Current screen:\n${screen}`,
returnDisplay: `Input sent to PID ${pid}.`,
};
}
}
export class WriteToShellTool extends BaseDeclarativeTool<
WriteToShellParams,
ToolResult
> {
static readonly Name = WRITE_TO_SHELL_TOOL_NAME;
constructor(messageBus: MessageBus) {
super(
WriteToShellTool.Name,
'WriteToShell',
'Sends input to a running background shell process. Use this to interact with TUI applications, REPLs, and interactive commands. After writing, the current screen state is returned. Works with processes that were auto-promoted to background via wait_for_output_seconds or started with is_background=true.',
Kind.Execute,
{
type: 'object',
properties: {
[WRITE_TO_SHELL_PARAM_PID]: {
type: 'number',
description:
'The PID of the background process to write to. Obtained from a previous run_shell_command call that was auto-promoted to background or started with is_background=true.',
},
[WRITE_TO_SHELL_PARAM_INPUT]: {
type: 'string',
description:
'(OPTIONAL) Text to send to the process. This is literal text typed into the terminal.',
},
[WRITE_TO_SHELL_PARAM_SPECIAL_KEYS]: {
type: 'array',
items: {
type: 'string',
enum: VALID_SPECIAL_KEYS,
},
description:
'(OPTIONAL) Named special keys to send after the input text. Each key is sent in sequence. Examples: ["Enter"], ["Tab"], ["Up", "Enter"], ["Ctrl-C"].',
},
},
required: [WRITE_TO_SHELL_PARAM_PID],
},
messageBus,
false, // output is not markdown
);
}
protected override validateToolParamValues(
params: WriteToShellParams,
): string | null {
if (!params.pid || params.pid <= 0) {
return 'PID must be a positive number.';
}
if (
!params.input &&
(!params.special_keys || !params.special_keys.length)
) {
return 'At least one of input or special_keys must be provided.';
}
return null;
}
protected createInvocation(
params: WriteToShellParams,
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
): ToolInvocation<WriteToShellParams, ToolResult> {
return new WriteToShellToolInvocation(
params,
messageBus,
_toolName,
_toolDisplayName,
);
}
}