Implement background process monitoring and inspection tools (#23799)

This commit is contained in:
Coco Sheng
2026-04-02 11:01:00 -04:00
committed by GitHub
parent 18e20c8650
commit daddac16e3
13 changed files with 1181 additions and 12 deletions
@@ -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]: {
+10 -2
View File
@@ -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;
});
+27 -2
View File
@@ -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,
);
}
}