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

`read-shell` and `write-to-shell` tools and a configurable mode setting.
This commit is contained in:
Gaurav Ghosh
2026-03-20 13:39:10 -07:00
parent cbacdc67d0
commit 651ad63ed6
22 changed files with 906 additions and 83 deletions

View File

@@ -1009,6 +1009,7 @@ export async function loadCliConfig(
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
shellBackgroundCompletionBehavior: settings.tools?.shell
?.backgroundCompletionBehavior as string | undefined,
interactiveShellMode: settings.tools?.shell?.interactiveShellMode,
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
enableShellOutputEfficiency:
settings.tools?.shell?.enableShellOutputEfficiency ?? true,

View File

@@ -1512,6 +1512,26 @@ const SETTINGS_SCHEMA = {
{ 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: {
type: 'string',
label: 'Pager',

View File

@@ -92,7 +92,23 @@ export function shellReducer(
nextTasks.delete(action.pid);
}
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': {
const task = state.backgroundTasks.get(action.pid);

View File

@@ -0,0 +1,101 @@
/**
* @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,
};
}

View File

@@ -661,6 +661,10 @@ export const useExecutionLifecycle = (
(s: BackgroundTask) => s.status === 'running',
).length;
const showBackgroundShell = useCallback(() => {
dispatch({ type: 'SET_VISIBILITY', visible: true });
}, [dispatch]);
return {
handleShellCommand,
activeShellPtyId: state.activeShellPtyId,
@@ -668,6 +672,7 @@ export const useExecutionLifecycle = (
backgroundTaskCount,
isBackgroundTaskVisible: state.isBackgroundTaskVisible,
toggleBackgroundTasks,
showBackgroundShell,
backgroundCurrentExecution,
registerBackgroundTask,
dismissBackgroundTask,

View File

@@ -390,6 +390,7 @@ export const useGeminiStream = (
backgroundTaskCount,
isBackgroundTaskVisible,
toggleBackgroundTasks,
showBackgroundShell,
backgroundCurrentExecution,
registerBackgroundTask,
dismissBackgroundTask,
@@ -1917,6 +1918,7 @@ export const useGeminiStream = (
backgroundedTool.command,
backgroundedTool.initialOutput,
);
showBackgroundShell();
}
}
@@ -2056,6 +2058,7 @@ export const useGeminiStream = (
modelSwitchedFromQuotaError,
addItem,
registerBackgroundTask,
showBackgroundShell,
consumeUserHint,
isLowErrorVerbosity,
maybeAddSuppressedToolErrorNote,

View File

@@ -36,6 +36,8 @@ import { GlobTool } from '../tools/glob.js';
import { ActivateSkillTool } from '../tools/activate-skill.js';
import { EditTool } from '../tools/edit.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 { WebFetchTool } from '../tools/web-fetch.js';
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
@@ -656,6 +658,7 @@ export interface ConfigParameters {
useRipgrep?: boolean;
enableInteractiveShell?: boolean;
shellBackgroundCompletionBehavior?: string;
interactiveShellMode?: 'human' | 'ai' | 'off';
skipNextSpeakerCheck?: boolean;
shellExecutionConfig?: ShellExecutionConfig;
extensionManagement?: boolean;
@@ -868,6 +871,7 @@ export class Config implements McpContext, AgentLoopContext {
| 'inject'
| 'notify'
| 'silent';
private readonly interactiveShellMode: 'human' | 'ai' | 'off';
private readonly skipNextSpeakerCheck: boolean;
private readonly useBackgroundColor: boolean;
private readonly useAlternateBuffer: boolean;
@@ -1235,6 +1239,14 @@ export class Config implements McpContext, AgentLoopContext {
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.shellExecutionConfig = {
terminalWidth: params.shellExecutionConfig?.terminalWidth ?? 80,
@@ -3211,10 +3223,14 @@ export class Config implements McpContext, AgentLoopContext {
return (
this.interactive &&
this.ptyInfo !== 'child_process' &&
this.enableInteractiveShell
this.interactiveShellMode !== 'off'
);
}
getInteractiveShellMode(): 'human' | 'ai' | 'off' {
return this.interactiveShellMode;
}
isSkillsSupportEnabled(): boolean {
return this.skillsSupport;
}
@@ -3575,6 +3591,15 @@ export class Config implements McpContext, AgentLoopContext {
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()) {
maybeRegister(MemoryTool, () =>
registry.registerTool(new MemoryTool(this.messageBus, this.storage)),

View File

@@ -200,6 +200,7 @@ export class PromptProvider {
enableShellEfficiency:
context.config.getEnableShellOutputEfficiency(),
interactiveShellEnabled: context.config.isInteractiveShellEnabled(),
interactiveShellMode: context.config.getInteractiveShellMode(),
topicUpdateNarration:
context.config.isTopicUpdateNarrationEnabled(),
memoryManagerEnabled: context.config.isMemoryManagerEnabled(),

View File

@@ -18,6 +18,8 @@ import {
MEMORY_TOOL_NAME,
READ_FILE_TOOL_NAME,
SHELL_TOOL_NAME,
WRITE_TO_SHELL_TOOL_NAME,
READ_SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
WRITE_TODOS_TOOL_NAME,
GREP_PARAM_TOTAL_MAX_MATCHES,
@@ -81,6 +83,7 @@ export interface PrimaryWorkflowsOptions {
export interface OperationalGuidelinesOptions {
interactive: boolean;
interactiveShellEnabled: boolean;
interactiveShellMode?: 'human' | 'ai' | 'off';
topicUpdateNarration: boolean;
memoryManagerEnabled: boolean;
}
@@ -391,7 +394,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(
options.interactive,
options.interactiveShellEnabled,
)}${toolUsageRememberingFacts(options)}
)}${toolUsageRememberingFacts(options)}${toolUsageAiShell(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.
## Interaction Details
@@ -800,6 +803,17 @@ 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).`;
}
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(
options: OperationalGuidelinesOptions,
): string {

View File

@@ -105,6 +105,7 @@ export interface ShellExecutionConfig {
backgroundCompletionBehavior?: 'inject' | 'notify' | 'silent';
originalCommand?: string;
sessionId?: string;
autoPromoteTimeoutMs?: number;
}
/**
@@ -889,6 +890,21 @@ export class ShellExecutionService {
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, {
executionMethod: ptyInfo?.name ?? 'node-pty',
writeInput: (input) => {
@@ -1066,6 +1082,7 @@ export class ShellExecutionService {
});
const handleOutput = (data: Buffer) => {
resetAutoPromoteTimer();
processingChain = processingChain.then(
() =>
new Promise<void>((resolveChunk) => {
@@ -1135,6 +1152,7 @@ export class ShellExecutionService {
ptyProcess.onExit(
({ exitCode, signal }: { exitCode: number; signal?: number }) => {
if (autoPromoteTimer) clearTimeout(autoPromoteTimer);
exited = true;
abortSignal.removeEventListener('abort', abortHandler);
// Attempt to destroy the PTY to ensure FD is closed
@@ -1220,6 +1238,7 @@ export class ShellExecutionService {
);
const abortHandler = async () => {
if (autoPromoteTimer) clearTimeout(autoPromoteTimer);
if (ptyProcess.pid && !exited) {
await killProcessGroup({
pid: ptyPid,
@@ -1398,6 +1417,28 @@ export class ShellExecutionService {
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.
*

View File

@@ -56,6 +56,18 @@ export const READ_FILE_PARAM_END_LINE = 'end_line';
export const SHELL_TOOL_NAME = 'run_shell_command';
export const SHELL_PARAM_COMMAND = 'command';
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 --
export const WRITE_FILE_TOOL_NAME = 'write_file';

View File

@@ -27,6 +27,8 @@ export {
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
SHELL_TOOL_NAME,
WRITE_TO_SHELL_TOOL_NAME,
READ_SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
EDIT_TOOL_NAME,
WEB_SEARCH_TOOL_NAME,
@@ -73,6 +75,12 @@ export {
LS_PARAM_IGNORE,
SHELL_PARAM_COMMAND,
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_FETCH_PARAM_PROMPT,
READ_MANY_PARAM_INCLUDE,
@@ -249,18 +257,21 @@ export function getShellDefinition(
enableInteractiveShell: boolean,
enableEfficiency: boolean,
enableToolSandboxing: boolean = false,
interactiveShellMode?: string,
): ToolDefinition {
return {
base: getShellDeclaration(
enableInteractiveShell,
enableEfficiency,
enableToolSandboxing,
interactiveShellMode,
),
overrides: (modelId) =>
getToolSet(modelId).run_shell_command(
enableInteractiveShell,
enableEfficiency,
enableToolSandboxing,
interactiveShellMode,
),
};
}

View File

@@ -22,6 +22,7 @@ import {
PARAM_DIR_PATH,
SHELL_PARAM_IS_BACKGROUND,
EXIT_PLAN_PARAM_PLAN_FILENAME,
SHELL_PARAM_WAIT_SECONDS,
SKILL_PARAM_NAME,
PARAM_ADDITIONAL_PERMISSIONS,
UPDATE_TOPIC_TOOL_NAME,
@@ -36,7 +37,9 @@ import {
export function getShellToolDescription(
enableInteractiveShell: boolean,
enableEfficiency: boolean,
interactiveShellMode?: string,
): string {
const isAiMode = interactiveShellMode === 'ai';
const efficiencyGuidelines = enableEfficiency
? `
@@ -56,6 +59,11 @@ export function getShellToolDescription(
Background PIDs: Only included if background processes were started.
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') {
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.`
@@ -86,12 +94,33 @@ export function getShellDeclaration(
enableInteractiveShell: boolean,
enableEfficiency: boolean,
enableToolSandboxing: boolean = false,
interactiveShellMode?: string,
): 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 {
name: SHELL_TOOL_NAME,
description: getShellToolDescription(
enableInteractiveShell,
enableEfficiency,
interactiveShellMode,
),
parametersJsonSchema: {
type: 'object',
@@ -120,6 +149,7 @@ export function getShellDeclaration(
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.',
},
...backgroundParam,
...(enableToolSandboxing
? {
[PARAM_ADDITIONAL_PERMISSIONS]: {

View File

@@ -337,11 +337,13 @@ export const DEFAULT_LEGACY_SET: CoreToolSet = {
enableInteractiveShell,
enableEfficiency,
enableToolSandboxing,
interactiveShellMode,
) =>
getShellDeclaration(
enableInteractiveShell,
enableEfficiency,
enableToolSandboxing,
interactiveShellMode,
),
replace: {

View File

@@ -344,11 +344,13 @@ export const GEMINI_3_SET: CoreToolSet = {
enableInteractiveShell,
enableEfficiency,
enableToolSandboxing,
interactiveShellMode,
) =>
getShellDeclaration(
enableInteractiveShell,
enableEfficiency,
enableToolSandboxing,
interactiveShellMode,
),
replace: {

View File

@@ -38,6 +38,7 @@ export interface CoreToolSet {
enableInteractiveShell: boolean,
enableEfficiency: boolean,
enableToolSandboxing: boolean,
interactiveShellMode?: string,
) => FunctionDeclaration;
replace: FunctionDeclaration;
google_web_search: FunctionDeclaration;

View File

@@ -0,0 +1,148 @@
/**
* @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,
);
}
}

View File

@@ -149,6 +149,8 @@ describe('ShellTool', () => {
getShellBackgroundCompletionBehavior: vi.fn().mockReturnValue('silent'),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getSandboxEnabled: vi.fn().mockReturnValue(false),
getInteractiveShellMode: vi.fn().mockReturnValue('off'),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
sanitizationConfig: {},
get sandboxManager() {
return mockSandboxManager;
@@ -422,7 +424,7 @@ describe('ShellTool', () => {
expect(mockShellBackground).toHaveBeenCalledWith(
12345,
'default',
'test-session-id',
'sleep 10',
);
@@ -666,7 +668,7 @@ describe('ShellTool', () => {
expect(mockShellBackground).toHaveBeenCalledWith(
12345,
'default',
'test-session-id',
'sleep 10',
);

View File

@@ -33,6 +33,7 @@ import {
import { getErrorMessage } from '../utils/errors.js';
import { summarizeToolOutput } from '../utils/summarizer.js';
import { formatShellOutput } from './shellOutputFormatter.js';
import {
ShellExecutionService,
type ShellOutputEvent,
@@ -71,6 +72,7 @@ export interface ShellToolParams {
is_background?: boolean;
delay_ms?: number;
[PARAM_ADDITIONAL_PERMISSIONS]?: SandboxPermissions;
wait_for_output_seconds?: number;
}
export class ShellToolInvocation extends BaseToolInvocation<
@@ -78,6 +80,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
ToolResult
> {
private proactivePermissionsConfirmed?: SandboxPermissions;
private _autoPromoteTimer?: NodeJS.Timeout;
constructor(
private readonly context: AgentLoopContext,
@@ -223,7 +226,12 @@ export class ShellToolInvocation extends BaseToolInvocation<
}
override getExplanation(): string {
return this.getContextualDetails().trim();
let explanation = 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(
@@ -497,6 +505,21 @@ export class ShellToolInvocation extends BaseToolInvocation<
}, 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 });
timeoutController.signal.addEventListener('abort', onAbort, {
once: true,
@@ -511,6 +534,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
cwd,
(event: ShellOutputEvent) => {
resetTimeout(); // Reset timeout on any event
resetAutoPromoteTimer(); // Reset auto-promote on any event
if (!updateOutput) {
return;
}
@@ -582,6 +606,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
backgroundCompletionBehavior:
this.context.config.getShellBackgroundCompletionBehavior(),
originalCommand: strippedCommand,
autoPromoteTimeoutMs: shouldAutoPromote ? waitMs : undefined,
},
);
@@ -618,6 +643,11 @@ 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;
@@ -658,97 +688,75 @@ export class ShellToolInvocation extends BaseToolInvocation<
}
}
let data: BackgroundExecutionData | undefined;
let llmContent = '';
let timeoutMessage = '';
if (result.aborted) {
if (timeoutController.signal.aborted) {
timeoutMessage = `Command was automatically cancelled because it exceeded the timeout of ${(
timeoutMs / 60000
).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}`;
} else {
llmContent += ' There was no output before it was cancelled.';
}
} else if (this.params.is_background || result.backgrounded) {
llmContent = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
data = {
pid: result.pid,
command: this.params.command,
initialOutput: result.output,
};
} else {
// Create a formatted error string for display, replacing the wrapper command
// with the user-facing command.
const llmContentParts = [`Output: ${result.output || '(empty)'}`];
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}`;
const formatterOutput = formatShellOutput({
params: this.params,
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 {
returnDisplay = cancelMsg;
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 (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.
}
}
// 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,
initialOutput: result.output,
pid: result.pid!,
command: this.params.command,
};
} else if (result.exitCode !== null && result.exitCode !== 0) {
data = {
exitCode: result.exitCode,
isError: true,
} as BackgroundExecutionData;
}
// Heuristic Sandbox Denial Detection
if (
!!result.error ||
@@ -929,6 +937,8 @@ export class ShellToolInvocation extends BaseToolInvocation<
};
} finally {
if (timeoutTimer) clearTimeout(timeoutTimer);
const autoTimer = this._autoPromoteTimer;
if (autoTimer) clearTimeout(autoTimer);
signal.removeEventListener('abort', onAbort);
timeoutController.signal.removeEventListener('abort', onAbort);
try {
@@ -1007,6 +1017,7 @@ export class ShellTool extends BaseDeclarativeTool<
this.context.config.getEnableInteractiveShell(),
this.context.config.getEnableShellOutputEfficiency(),
this.context.config.getSandboxEnabled(),
this.context.config.getInteractiveShellMode(),
);
return resolveToolDeclaration(definition, modelId);
}

View File

@@ -0,0 +1,128 @@
/**
* @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 };
}

View File

@@ -10,6 +10,8 @@ import {
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
SHELL_TOOL_NAME,
WRITE_TO_SHELL_TOOL_NAME,
READ_SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
EDIT_TOOL_NAME,
WEB_SEARCH_TOOL_NAME,
@@ -52,6 +54,12 @@ import {
LS_PARAM_IGNORE,
SHELL_PARAM_COMMAND,
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_FETCH_PARAM_PROMPT,
READ_MANY_PARAM_INCLUDE,
@@ -90,6 +98,8 @@ export {
LS_TOOL_NAME,
READ_FILE_TOOL_NAME,
SHELL_TOOL_NAME,
WRITE_TO_SHELL_TOOL_NAME,
READ_SHELL_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
EDIT_TOOL_NAME,
WEB_SEARCH_TOOL_NAME,
@@ -136,6 +146,12 @@ export {
LS_PARAM_IGNORE,
SHELL_PARAM_COMMAND,
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_FETCH_PARAM_PROMPT,
READ_MANY_PARAM_INCLUDE,
@@ -179,6 +195,7 @@ export const TOOLS_REQUIRING_NARROWING = new Set([
WRITE_FILE_TOOL_NAME,
EDIT_TOOL_NAME,
SHELL_TOOL_NAME,
WRITE_TO_SHELL_TOOL_NAME,
]);
export const TRACKER_CREATE_TASK_TOOL_NAME = 'tracker_create_task';
@@ -251,6 +268,8 @@ export const ALL_BUILTIN_TOOL_NAMES = [
WEB_FETCH_TOOL_NAME,
EDIT_TOOL_NAME,
SHELL_TOOL_NAME,
WRITE_TO_SHELL_TOOL_NAME,
READ_SHELL_TOOL_NAME,
GREP_TOOL_NAME,
READ_MANY_FILES_TOOL_NAME,
READ_FILE_TOOL_NAME,

View File

@@ -0,0 +1,230 @@
/**
* @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,
);
}
}