mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-17 13:30:53 -07:00
Implement background process monitoring and inspection tools (#23799)
This commit is contained in:
@@ -616,6 +616,10 @@ exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snaps
|
||||
"description": "Exact bash command to execute as \`bash -c <command>\`",
|
||||
"type": "string",
|
||||
},
|
||||
"delay_ms": {
|
||||
"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.",
|
||||
"type": "integer",
|
||||
},
|
||||
"description": {
|
||||
"description": "Brief description of the command for the user. Be specific and concise. Ideally a single sentence. Can be up to 3 sentences for clarity. No line breaks.",
|
||||
"type": "string",
|
||||
@@ -1418,6 +1422,10 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview >
|
||||
"description": "Exact bash command to execute as \`bash -c <command>\`",
|
||||
"type": "string",
|
||||
},
|
||||
"delay_ms": {
|
||||
"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.",
|
||||
"type": "integer",
|
||||
},
|
||||
"description": {
|
||||
"description": "Brief description of the command for the user. Be specific and concise. Ideally a single sentence. Can be up to 3 sentences for clarity. No line breaks.",
|
||||
"type": "string",
|
||||
|
||||
@@ -115,6 +115,11 @@ export function getShellDeclaration(
|
||||
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.',
|
||||
},
|
||||
delay_ms: {
|
||||
type: 'integer',
|
||||
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.',
|
||||
},
|
||||
...(enableToolSandboxing
|
||||
? {
|
||||
[PARAM_ADDITIONAL_PERMISSIONS]: {
|
||||
|
||||
@@ -416,7 +416,11 @@ describe('ShellTool', () => {
|
||||
// Advance time to trigger the background timeout
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(mockShellBackground).toHaveBeenCalledWith(12345);
|
||||
expect(mockShellBackground).toHaveBeenCalledWith(
|
||||
12345,
|
||||
'default',
|
||||
'sleep 10',
|
||||
);
|
||||
|
||||
await promise;
|
||||
});
|
||||
@@ -656,7 +660,11 @@ describe('ShellTool', () => {
|
||||
// Advance time to trigger the background timeout
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(mockShellBackground).toHaveBeenCalledWith(12345);
|
||||
expect(mockShellBackground).toHaveBeenCalledWith(
|
||||
12345,
|
||||
'default',
|
||||
'sleep 10',
|
||||
);
|
||||
|
||||
await promise;
|
||||
});
|
||||
|
||||
@@ -65,6 +65,7 @@ export interface ShellToolParams {
|
||||
description?: string;
|
||||
dir_path?: string;
|
||||
is_background?: boolean;
|
||||
delay_ms?: number;
|
||||
[PARAM_ADDITIONAL_PERMISSIONS]?: SandboxPermissions;
|
||||
}
|
||||
|
||||
@@ -521,6 +522,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
this.context.config.getEnableInteractiveShell(),
|
||||
{
|
||||
...shellExecutionConfig,
|
||||
sessionId: this.context.config?.getSessionId?.() ?? 'default',
|
||||
pager: 'cat',
|
||||
sanitizationConfig:
|
||||
shellExecutionConfig?.sanitizationConfig ??
|
||||
@@ -547,6 +549,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
},
|
||||
backgroundCompletionBehavior:
|
||||
this.context.config.getShellBackgroundCompletionBehavior(),
|
||||
originalCommand: strippedCommand,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -556,10 +559,32 @@ export class ShellToolInvocation extends BaseToolInvocation<
|
||||
}
|
||||
|
||||
// If the model requested to run in the background, do so after a short delay.
|
||||
let completed = false;
|
||||
if (this.params.is_background) {
|
||||
resultPromise
|
||||
.then(() => {
|
||||
completed = true;
|
||||
})
|
||||
.catch(() => {
|
||||
completed = true; // Also mark completed if it failed
|
||||
});
|
||||
|
||||
const sessionId = this.context.config?.getSessionId?.() ?? 'default';
|
||||
const delay = this.params.delay_ms ?? BACKGROUND_DELAY_MS;
|
||||
setTimeout(() => {
|
||||
ShellExecutionService.background(pid);
|
||||
}, BACKGROUND_DELAY_MS);
|
||||
ShellExecutionService.background(pid, sessionId, strippedCommand);
|
||||
}, delay);
|
||||
|
||||
// Wait for the delay amount to see if command returns quickly
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
|
||||
if (!completed) {
|
||||
// Return early with initial output if still running
|
||||
return {
|
||||
llmContent: `Command is running in background. PID: ${pid}. Initial output:\n${cumulativeOutput}`,
|
||||
returnDisplay: `Background process started with PID ${pid}.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { ShellExecutionService } from '../services/shellExecutionService.js';
|
||||
import {
|
||||
ListBackgroundProcessesTool,
|
||||
ReadBackgroundOutputTool,
|
||||
} from './shellBackgroundTools.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
import { NoopSandboxManager } from '../services/sandboxManager.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
|
||||
// Integration test simulating model interaction cycle
|
||||
describe('Background Tools Integration', () => {
|
||||
const bus = createMockMessageBus();
|
||||
let listTool: ListBackgroundProcessesTool;
|
||||
let readTool: ReadBackgroundOutputTool;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const mockContext = {
|
||||
config: { getSessionId: () => 'default' },
|
||||
} as unknown as AgentLoopContext;
|
||||
listTool = new ListBackgroundProcessesTool(mockContext, bus);
|
||||
readTool = new ReadBackgroundOutputTool(mockContext, bus);
|
||||
|
||||
// Clear history to avoid state leakage from previous runs
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(ShellExecutionService as any).backgroundProcessHistory.clear();
|
||||
});
|
||||
|
||||
it('should support interaction cycle: start background -> list -> read logs', async () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
// 1. Start a backgroundable process
|
||||
// We use node to print continuous logs until killed
|
||||
const commandString = `${process.execPath} -e "setInterval(() => console.log('Log line'), 50)"`;
|
||||
|
||||
const realHandle = await ShellExecutionService.execute(
|
||||
commandString,
|
||||
'/',
|
||||
() => {},
|
||||
controller.signal,
|
||||
true,
|
||||
{
|
||||
originalCommand: 'node continuous_log',
|
||||
sessionId: 'default',
|
||||
sanitizationConfig: {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
},
|
||||
sandboxManager: new NoopSandboxManager(),
|
||||
},
|
||||
);
|
||||
|
||||
const pid = realHandle.pid;
|
||||
if (pid === undefined) {
|
||||
throw new Error('pid is undefined');
|
||||
}
|
||||
expect(pid).toBeGreaterThan(0);
|
||||
|
||||
// 2. Simulate model triggering background operations
|
||||
ShellExecutionService.background(pid, 'default', 'node continuous_log');
|
||||
|
||||
// 3. Model decides to inspect list
|
||||
const listInvocation = listTool.build({});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(listInvocation as any).context = {
|
||||
config: { getSessionId: () => 'default' },
|
||||
};
|
||||
const listResult = await listInvocation.execute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(listResult.llmContent).toContain(
|
||||
`[PID ${pid}] RUNNING: \`node continuous_log\``,
|
||||
);
|
||||
|
||||
// 4. Give it time to write output to interval
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// 5. Model decides to read logs
|
||||
const readInvocation = readTool.build({ pid, lines: 2 });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(readInvocation as any).context = {
|
||||
config: { getSessionId: () => 'default' },
|
||||
};
|
||||
const readResult = await readInvocation.execute(
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(readResult.llmContent).toContain('Showing last');
|
||||
expect(readResult.llmContent).toContain('Log line');
|
||||
|
||||
// Cleanup
|
||||
await ShellExecutionService.kill(pid);
|
||||
controller.abort();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { ShellExecutionService } from '../services/shellExecutionService.js';
|
||||
import {
|
||||
ListBackgroundProcessesTool,
|
||||
ReadBackgroundOutputTool,
|
||||
} from './shellBackgroundTools.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
import fs from 'node:fs';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
|
||||
describe('Background Tools', () => {
|
||||
let listTool: ListBackgroundProcessesTool;
|
||||
let readTool: ReadBackgroundOutputTool;
|
||||
const bus = createMockMessageBus();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
const mockContext = {
|
||||
config: { getSessionId: () => 'default' },
|
||||
} as unknown as AgentLoopContext;
|
||||
listTool = new ListBackgroundProcessesTool(mockContext, bus);
|
||||
readTool = new ReadBackgroundOutputTool(mockContext, bus);
|
||||
|
||||
// Clear history to avoid state leakage from previous runs
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(ShellExecutionService as any).backgroundProcessHistory.clear();
|
||||
});
|
||||
|
||||
it('list_background_processes should return empty message when no processes', async () => {
|
||||
const invocation = listTool.build({});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(invocation as any).context = { config: { getSessionId: () => 'default' } };
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.llmContent).toBe('No background processes found.');
|
||||
});
|
||||
|
||||
it('list_background_processes should list processes after they are backgrounded', async () => {
|
||||
const pid = 99999 + Math.floor(Math.random() * 1000);
|
||||
|
||||
// Simulate adding to history
|
||||
// Since background method relies on activePtys/activeChildProcesses,
|
||||
// we should probably mock those or just call the history add logic if we can't easily trigger background.
|
||||
// Wait, ShellExecutionService.background() reads from activePtys/activeChildProcesses!
|
||||
// So we MUST populate them or mock them!
|
||||
// Let's use vi.spyOn or populate the map if accessible?
|
||||
// activePtys is private static.
|
||||
// Mock active process map to provide sessionId
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(ShellExecutionService as any).activeChildProcesses.set(pid, {
|
||||
process: {},
|
||||
state: { output: '' },
|
||||
command: 'unknown command',
|
||||
sessionId: 'default',
|
||||
});
|
||||
|
||||
ShellExecutionService.background(pid, 'default', 'unknown command');
|
||||
|
||||
const invocation = listTool.build({});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(invocation as any).context = { config: { getSessionId: () => 'default' } };
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain(
|
||||
`[PID ${pid}] RUNNING: \`unknown command\``,
|
||||
);
|
||||
});
|
||||
|
||||
it('list_background_processes should show exited status with code or signal', async () => {
|
||||
const pid = 98989;
|
||||
const history = new Map();
|
||||
history.set(pid, {
|
||||
command: 'exited command',
|
||||
status: 'exited',
|
||||
exitCode: 1,
|
||||
startTime: Date.now(),
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(ShellExecutionService as any).backgroundProcessHistory.set(
|
||||
'default',
|
||||
history,
|
||||
);
|
||||
|
||||
const invocation = listTool.build({});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(invocation as any).context = { config: { getSessionId: () => 'default' } };
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain(
|
||||
`- [PID ${pid}] EXITED: \`exited command\` (Exit Code: 1)`,
|
||||
);
|
||||
});
|
||||
|
||||
it('read_background_output should return error if log file does not exist', async () => {
|
||||
const pid = 12345 + Math.floor(Math.random() * 1000);
|
||||
const history = new Map();
|
||||
history.set(pid, {
|
||||
command: 'unknown command',
|
||||
status: 'running',
|
||||
startTime: Date.now(),
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(ShellExecutionService as any).backgroundProcessHistory.set(
|
||||
'default',
|
||||
history,
|
||||
);
|
||||
|
||||
const invocation = readTool.build({ pid });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(invocation as any).context = { config: { getSessionId: () => 'default' } };
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.llmContent).toContain('No output log found');
|
||||
});
|
||||
|
||||
it('read_background_output should read content from log file', async () => {
|
||||
const pid = 88888 + Math.floor(Math.random() * 1000);
|
||||
const logPath = ShellExecutionService.getLogFilePath(pid);
|
||||
const logDir = ShellExecutionService.getLogDir();
|
||||
|
||||
// Ensure dir exists
|
||||
// Add to history to pass access check
|
||||
const history = new Map();
|
||||
history.set(pid, {
|
||||
command: 'unknown command',
|
||||
status: 'running',
|
||||
startTime: Date.now(),
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(ShellExecutionService as any).backgroundProcessHistory.set(
|
||||
'default',
|
||||
history,
|
||||
);
|
||||
|
||||
// Ensure dir exists
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
|
||||
// Write mock log
|
||||
fs.writeFileSync(logPath, 'line 1\nline 2\nline 3\n');
|
||||
|
||||
const invocation = readTool.build({ pid, lines: 2 });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(invocation as any).context = { config: { getSessionId: () => 'default' } };
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('Showing last 2 of 3 lines');
|
||||
expect(result.llmContent).toContain('line 2\nline 3');
|
||||
|
||||
// Cleanup
|
||||
fs.unlinkSync(logPath);
|
||||
});
|
||||
|
||||
it('read_background_output should return Access Denied for processes in other sessions', async () => {
|
||||
const pid = 77777;
|
||||
const history = new Map();
|
||||
history.set(pid, {
|
||||
command: 'other command',
|
||||
status: 'running',
|
||||
startTime: Date.now(),
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(ShellExecutionService as any).backgroundProcessHistory.set(
|
||||
'other-session',
|
||||
history,
|
||||
);
|
||||
|
||||
const invocation = readTool.build({ pid });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(invocation as any).context = { config: { getSessionId: () => 'default' } }; // Asking for PID from another session
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.llmContent).toContain('Access denied');
|
||||
});
|
||||
|
||||
it('read_background_output should handle empty log files', async () => {
|
||||
const pid = 66666;
|
||||
const logPath = ShellExecutionService.getLogFilePath(pid);
|
||||
const logDir = ShellExecutionService.getLogDir();
|
||||
|
||||
const history = new Map();
|
||||
history.set(pid, {
|
||||
command: 'empty output command',
|
||||
status: 'running',
|
||||
startTime: Date.now(),
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(ShellExecutionService as any).backgroundProcessHistory.set(
|
||||
'default',
|
||||
history,
|
||||
);
|
||||
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
fs.writeFileSync(logPath, '');
|
||||
|
||||
const invocation = readTool.build({ pid });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(invocation as any).context = { config: { getSessionId: () => 'default' } };
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('Log is empty');
|
||||
|
||||
fs.unlinkSync(logPath);
|
||||
});
|
||||
|
||||
it('read_background_output should handle direct tool errors gracefully', async () => {
|
||||
const pid = 55555;
|
||||
const logPath = ShellExecutionService.getLogFilePath(pid);
|
||||
const logDir = ShellExecutionService.getLogDir();
|
||||
|
||||
const history = new Map();
|
||||
history.set(pid, {
|
||||
command: 'fail command',
|
||||
status: 'running',
|
||||
startTime: Date.now(),
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(ShellExecutionService as any).backgroundProcessHistory.set(
|
||||
'default',
|
||||
history,
|
||||
);
|
||||
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
fs.writeFileSync(logPath, 'dummy content');
|
||||
|
||||
// Mock open to throw to hit catch block
|
||||
vi.spyOn(fs.promises, 'open').mockRejectedValue(
|
||||
new Error('Simulated read error'),
|
||||
);
|
||||
|
||||
const invocation = readTool.build({ pid });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(invocation as any).context = { config: { getSessionId: () => 'default' } };
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(result.llmContent).toContain('Error reading background log');
|
||||
|
||||
fs.unlinkSync(logPath);
|
||||
});
|
||||
|
||||
it('read_background_output should deny access if log is a symbolic link', async () => {
|
||||
const pid = 66666;
|
||||
const logPath = ShellExecutionService.getLogFilePath(pid);
|
||||
const logDir = ShellExecutionService.getLogDir();
|
||||
|
||||
const history = new Map();
|
||||
history.set(pid, {
|
||||
command: 'symlink command',
|
||||
status: 'running',
|
||||
startTime: Date.now(),
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(ShellExecutionService as any).backgroundProcessHistory.set(
|
||||
'default',
|
||||
history,
|
||||
);
|
||||
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
fs.writeFileSync(logPath, 'dummy content');
|
||||
|
||||
// Mock open to throw ELOOP error for symbolic link
|
||||
const mockError = new Error('ELOOP: too many symbolic links encountered');
|
||||
Object.assign(mockError, { code: 'ELOOP' });
|
||||
vi.spyOn(fs.promises, 'open').mockRejectedValue(mockError);
|
||||
|
||||
const invocation = readTool.build({ pid });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(invocation as any).context = { config: { getSessionId: () => 'default' } };
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('Access is denied');
|
||||
expect(result.error?.message).toContain('Symbolic link detected');
|
||||
|
||||
fs.unlinkSync(logPath);
|
||||
});
|
||||
|
||||
it('read_background_output should tail reading trailing logic correctly', async () => {
|
||||
const pid = 77777;
|
||||
const logPath = ShellExecutionService.getLogFilePath(pid);
|
||||
const logDir = ShellExecutionService.getLogDir();
|
||||
|
||||
const history = new Map();
|
||||
history.set(pid, {
|
||||
command: 'tail command',
|
||||
status: 'running',
|
||||
startTime: Date.now(),
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(ShellExecutionService as any).backgroundProcessHistory.set(
|
||||
'default',
|
||||
history,
|
||||
);
|
||||
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
// Write 5 lines
|
||||
fs.writeFileSync(logPath, 'line1\nline2\nline3\nline4\nline5');
|
||||
|
||||
const invocation = readTool.build({ pid, lines: 2 });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(invocation as any).context = { config: { getSessionId: () => 'default' } };
|
||||
const result = await invocation.execute(new AbortController().signal);
|
||||
|
||||
expect(result.llmContent).toContain('line4\nline5');
|
||||
expect(result.llmContent).not.toContain('line1');
|
||||
|
||||
fs.unlinkSync(logPath);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { ShellExecutionService } from '../services/shellExecutionService.js';
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
Kind,
|
||||
type ToolResult,
|
||||
} from './tools.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { isNodeError } from '../utils/errors.js';
|
||||
|
||||
const MAX_BUFFER_LOAD_CAP_BYTES = 64 * 1024; // Safe 64KB buffer load Cap
|
||||
const DEFAULT_TAIL_LINES_COUNT = 100;
|
||||
|
||||
// --- list_background_processes ---
|
||||
|
||||
class ListBackgroundProcessesInvocation extends BaseToolInvocation<
|
||||
Record<string, never>,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(
|
||||
private readonly context: AgentLoopContext,
|
||||
params: Record<string, never>,
|
||||
messageBus: MessageBus,
|
||||
toolName?: string,
|
||||
toolDisplayName?: string,
|
||||
) {
|
||||
super(params, messageBus, toolName, toolDisplayName);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'Lists all active and recently completed background processes for the current session.';
|
||||
}
|
||||
|
||||
async execute(_signal: AbortSignal): Promise<ToolResult> {
|
||||
const processes = ShellExecutionService.listBackgroundProcesses(
|
||||
this.context.config.getSessionId(),
|
||||
);
|
||||
if (processes.length === 0) {
|
||||
return {
|
||||
llmContent: 'No background processes found.',
|
||||
returnDisplay: 'No background processes found.',
|
||||
};
|
||||
}
|
||||
|
||||
const lines = processes.map(
|
||||
(p) =>
|
||||
`- [PID ${p.pid}] ${p.status.toUpperCase()}: \`${p.command}\`${
|
||||
p.exitCode !== undefined ? ` (Exit Code: ${p.exitCode})` : ''
|
||||
}${p.signal ? ` (Signal: ${p.signal})` : ''}`,
|
||||
);
|
||||
|
||||
const content = lines.join('\n');
|
||||
return {
|
||||
llmContent: content,
|
||||
returnDisplay: content,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class ListBackgroundProcessesTool extends BaseDeclarativeTool<
|
||||
Record<string, never>,
|
||||
ToolResult
|
||||
> {
|
||||
static readonly Name = 'list_background_processes';
|
||||
|
||||
constructor(
|
||||
private readonly context: AgentLoopContext,
|
||||
messageBus: MessageBus,
|
||||
) {
|
||||
super(
|
||||
ListBackgroundProcessesTool.Name,
|
||||
'List Background Processes',
|
||||
'Lists all active and recently completed background shell processes orchestrating by the agent.',
|
||||
Kind.Read,
|
||||
{
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
messageBus,
|
||||
);
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: Record<string, never>,
|
||||
messageBus: MessageBus,
|
||||
) {
|
||||
return new ListBackgroundProcessesInvocation(
|
||||
this.context,
|
||||
params,
|
||||
messageBus,
|
||||
this.name,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- read_background_output ---
|
||||
|
||||
interface ReadBackgroundOutputParams {
|
||||
pid: number;
|
||||
lines?: number;
|
||||
delay_ms?: number;
|
||||
}
|
||||
|
||||
class ReadBackgroundOutputInvocation extends BaseToolInvocation<
|
||||
ReadBackgroundOutputParams,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(
|
||||
private readonly context: AgentLoopContext,
|
||||
params: ReadBackgroundOutputParams,
|
||||
messageBus: MessageBus,
|
||||
toolName?: string,
|
||||
toolDisplayName?: string,
|
||||
) {
|
||||
super(params, messageBus, toolName, toolDisplayName);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return `Reading output for background process ${this.params.pid}`;
|
||||
}
|
||||
|
||||
async execute(_signal: AbortSignal): Promise<ToolResult> {
|
||||
const pid = this.params.pid;
|
||||
|
||||
if (this.params.delay_ms && this.params.delay_ms > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, this.params.delay_ms));
|
||||
}
|
||||
|
||||
// Verify process belongs to this session to prevent reading logs of processes from other sessions/users
|
||||
const processes = ShellExecutionService.listBackgroundProcesses(
|
||||
this.context.config.getSessionId(),
|
||||
);
|
||||
if (!processes.some((p) => p.pid === pid)) {
|
||||
return {
|
||||
llmContent: `Access denied. Background process ID ${pid} not found in this session's history.`,
|
||||
returnDisplay: 'Access denied.',
|
||||
error: {
|
||||
message: `Background process history lookup failed for PID ${pid}`,
|
||||
type: ToolErrorType.EXECUTION_FAILED,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const logPath = ShellExecutionService.getLogFilePath(pid);
|
||||
|
||||
try {
|
||||
await fs.promises.access(logPath);
|
||||
} catch {
|
||||
return {
|
||||
llmContent: `No output log found for process ID ${pid}. It might not have produced output or was cleaned up.`,
|
||||
returnDisplay: `No log found for PID ${pid}`,
|
||||
error: {
|
||||
message: `Log file not found at ${logPath}`,
|
||||
type: ToolErrorType.EXECUTION_FAILED,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const fileHandle = await fs.promises.open(
|
||||
logPath,
|
||||
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW,
|
||||
);
|
||||
|
||||
let content = '';
|
||||
let position = 0;
|
||||
try {
|
||||
const stats = await fileHandle.stat();
|
||||
const readSize = Math.min(stats.size, MAX_BUFFER_LOAD_CAP_BYTES);
|
||||
position = Math.max(0, stats.size - readSize);
|
||||
|
||||
const buffer = Buffer.alloc(readSize);
|
||||
await fileHandle.read(buffer, 0, readSize, position);
|
||||
content = buffer.toString('utf-8');
|
||||
} finally {
|
||||
await fileHandle.close();
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return {
|
||||
llmContent: 'Log is empty.',
|
||||
returnDisplay: 'Log is empty.',
|
||||
};
|
||||
}
|
||||
|
||||
const logLines = content.split('\n');
|
||||
if (logLines.length > 0 && logLines[logLines.length - 1] === '') {
|
||||
logLines.pop();
|
||||
}
|
||||
|
||||
// Discard first line if we started reading from middle of file to avoid partial lines
|
||||
if (position > 0 && logLines.length > 0) {
|
||||
logLines.shift();
|
||||
}
|
||||
|
||||
const requestedLinesCount = this.params.lines ?? DEFAULT_TAIL_LINES_COUNT;
|
||||
const tailLines = logLines.slice(-requestedLinesCount);
|
||||
const output = tailLines.join('\n');
|
||||
|
||||
const header =
|
||||
requestedLinesCount < logLines.length
|
||||
? `Showing last ${requestedLinesCount} of ${logLines.length} lines:\n`
|
||||
: 'Full Log Output:\n';
|
||||
|
||||
const responseContent = header + output;
|
||||
|
||||
return {
|
||||
llmContent: responseContent,
|
||||
returnDisplay: responseContent,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ELOOP') {
|
||||
return {
|
||||
llmContent:
|
||||
'Symbolic link detected at predicted log path. Access is denied for security reasons.',
|
||||
returnDisplay: `Symlink detected for PID ${pid}`,
|
||||
error: {
|
||||
message:
|
||||
'Symbolic link detected at predicted log path. Access is denied for security reasons.',
|
||||
type: ToolErrorType.EXECUTION_FAILED,
|
||||
},
|
||||
};
|
||||
}
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
llmContent: `Error reading background log: ${errorMessage}`,
|
||||
returnDisplay: 'Failed to read log.',
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: ToolErrorType.EXECUTION_FAILED,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ReadBackgroundOutputTool extends BaseDeclarativeTool<
|
||||
ReadBackgroundOutputParams,
|
||||
ToolResult
|
||||
> {
|
||||
static readonly Name = 'read_background_output';
|
||||
|
||||
constructor(
|
||||
private readonly context: AgentLoopContext,
|
||||
messageBus: MessageBus,
|
||||
) {
|
||||
super(
|
||||
ReadBackgroundOutputTool.Name,
|
||||
'Read Background Output',
|
||||
'Reads the output log of a background shell process. Support reading tail snapshot.',
|
||||
Kind.Read,
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
pid: {
|
||||
type: 'integer',
|
||||
description:
|
||||
'The process ID (PID) of the background process to inspect.',
|
||||
},
|
||||
lines: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
description:
|
||||
'Optional. Number of lines to read from the end of the log. Defaults to 100.',
|
||||
},
|
||||
delay_ms: {
|
||||
type: 'integer',
|
||||
description:
|
||||
'Optional. Delay in milliseconds to wait before reading the output. Useful to allow the process to start and generate initial output.',
|
||||
},
|
||||
},
|
||||
required: ['pid'],
|
||||
},
|
||||
messageBus,
|
||||
);
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: ReadBackgroundOutputParams,
|
||||
messageBus: MessageBus,
|
||||
) {
|
||||
return new ReadBackgroundOutputInvocation(
|
||||
this.context,
|
||||
params,
|
||||
messageBus,
|
||||
this.name,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user