Checkpoint of shell optimization

fix(cli): Write shell command output to a file and limit memory buffered in UI

Fixes.

Checkpoint.

fix(core, cli): await outputStream.end() to prevent race conditions

This commit fixes a critical race condition where
was called synchronously without being awaited. This led to potential file
truncation or EBUSY errors on Windows when attempting to manipulate the file
immediately after the  call.

Additionally, this change removes fixed wait times (`setTimeout`) that
were previously used in test files as a band-aid.

fix(core): stream processed xterm output to file to remove spurious escape codes

test(core): update shell regression tests to use file_data events
This commit is contained in:
jacob314
2026-02-19 11:12:13 -08:00
committed by Spencer
parent 986293bd38
commit 859c7c3a70
16 changed files with 959 additions and 62 deletions
@@ -661,4 +661,82 @@ describe('ToolOutputMaskingService', () => {
)['output'],
).toContain(MASKING_INDICATOR_TAG);
});
it('should use existing outputFile if available in the tool response', async () => {
// Setup: Create a large history to trigger masking
const largeContent = 'a'.repeat(60000);
const existingOutputFile = path.join(testTempDir, 'truly_full_output.txt');
await fs.promises.writeFile(existingOutputFile, 'truly full content');
const history: Content[] = [
{
role: 'user',
parts: [{ text: 'Old turn' }],
},
{
role: 'model',
parts: [
{
functionResponse: {
name: 'shell',
id: 'call-1',
response: {
output: largeContent,
outputFile: existingOutputFile,
},
},
},
],
},
// Protection buffer
{
role: 'user',
parts: [
{
functionResponse: {
name: 'padding',
response: { output: 'B'.repeat(60000) },
},
},
],
},
{
role: 'user',
parts: [{ text: 'Newest turn' }],
},
];
mockedEstimateTokenCountSync.mockImplementation((parts: Part[]) => {
const resp = parts[0].functionResponse?.response as Record<
string,
unknown
>;
const content = (resp?.['output'] as string) ?? JSON.stringify(resp);
if (content.includes(`<${MASKING_INDICATOR_TAG}`)) return 100;
const name = parts[0].functionResponse?.name;
if (name === 'shell') return 60000;
if (name === 'padding') return 60000;
return 10;
});
// Trigger masking
const result = await service.mask(history, mockConfig);
expect(result.maskedCount).toBe(2);
const maskedPart = result.newHistory[1].parts![0];
const maskedResponse = maskedPart.functionResponse?.response as Record<
string,
unknown
>;
const maskedOutput = maskedResponse['output'] as string;
// Verify the masked snippet points to the existing file
expect(maskedOutput).toContain(
`Full output available at: ${existingOutputFile}`,
);
// Verify the path in maskedOutput is exactly the one we provided
expect(maskedOutput).toContain(existingOutputFile);
});
});
@@ -182,25 +182,47 @@ export class ToolOutputMaskingService {
const toolName = part.functionResponse.name || 'unknown_tool';
const callId = part.functionResponse.id || Date.now().toString();
const safeToolName = sanitizeFilenamePart(toolName).toLowerCase();
const safeCallId = sanitizeFilenamePart(callId).toLowerCase();
const fileName = `${safeToolName}_${safeCallId}_${Math.random()
.toString(36)
.substring(7)}.txt`;
const filePath = path.join(toolOutputsDir, fileName);
await fsPromises.writeFile(filePath, content, 'utf-8');
const originalResponse =
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(part.functionResponse.response as Record<string, unknown>) || {};
const totalLines = content.split('\n').length;
const fileSizeMB = (
Buffer.byteLength(content, 'utf8') /
1024 /
1024
).toFixed(2);
let filePath = '';
let fileSizeMB = '0.00';
let totalLines = 0;
if (
typeof originalResponse['outputFile'] === 'string' &&
originalResponse['outputFile']
) {
filePath = originalResponse['outputFile'];
try {
const stats = await fsPromises.stat(filePath);
fileSizeMB = (stats.size / 1024 / 1024).toFixed(2);
// For truly full files, we don't count lines as it's too slow.
// We just indicate it's the full file.
totalLines = -1;
} catch {
// Fallback if file is gone
filePath = '';
}
}
if (!filePath) {
const safeToolName = sanitizeFilenamePart(toolName).toLowerCase();
const safeCallId = sanitizeFilenamePart(callId).toLowerCase();
const fileName = `${safeToolName}_${safeCallId}_${Math.random()
.toString(36)
.substring(7)}.txt`;
filePath = path.join(toolOutputsDir, fileName);
await fsPromises.writeFile(filePath, content, 'utf-8');
totalLines = content.split('\n').length;
fileSizeMB = (Buffer.byteLength(content, 'utf8') / 1024 / 1024).toFixed(
2,
);
}
let preview = '';
if (toolName === SHELL_TOOL_NAME) {
@@ -5,6 +5,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fsPromises from 'node:fs/promises';
import { ToolExecutor } from './tool-executor.js';
import {
type Config,
@@ -597,6 +598,215 @@ describe('ToolExecutor', () => {
expect(result.status).toBe(CoreToolCallStatus.Success);
});
it('should truncate large output and move file when fullOutputFilePath is provided', async () => {
// 1. Setup Config for Truncation
vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(10);
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue('/tmp');
vi.spyOn(fileUtils, 'moveToolOutputToFile').mockResolvedValue({
outputFile: '/tmp/moved_output.txt',
});
const mockTool = new MockTool({ name: SHELL_TOOL_NAME });
const invocation = mockTool.build({});
const longOutput = 'This is a very long output that should be truncated.';
// 2. Mock execution returning long content AND fullOutputFilePath
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({
llmContent: longOutput,
returnDisplay: longOutput,
fullOutputFilePath: '/tmp/temp_full_output.txt',
});
const scheduledCall: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
callId: 'call-trunc-full',
name: SHELL_TOOL_NAME,
args: { command: 'echo long' },
isClientInitiated: false,
prompt_id: 'prompt-trunc-full',
},
tool: mockTool,
invocation: invocation as unknown as AnyToolInvocation,
startTime: Date.now(),
};
// 3. Execute
const result = await executor.execute({
call: scheduledCall,
signal: new AbortController().signal,
onUpdateToolCall: vi.fn(),
});
// 4. Verify Truncation Logic
expect(fileUtils.moveToolOutputToFile).toHaveBeenCalledWith(
'/tmp/temp_full_output.txt',
SHELL_TOOL_NAME,
'call-trunc-full',
expect.any(String), // temp dir
'test-session-id', // session id from makeFakeConfig
);
expect(fileUtils.formatTruncatedToolOutput).toHaveBeenCalledWith(
longOutput,
'/tmp/moved_output.txt',
10, // threshold (maxChars)
);
expect(result.status).toBe(CoreToolCallStatus.Success);
if (result.status === CoreToolCallStatus.Success) {
const response = result.response.responseParts[0]?.functionResponse
?.response as Record<string, unknown>;
// The content should be the *truncated* version returned by the mock formatTruncatedToolOutput
expect(response).toEqual({
output: 'TruncatedContent...',
outputFile: '/tmp/moved_output.txt',
});
expect(result.response.outputFile).toBe('/tmp/moved_output.txt');
}
});
it('should delete temporary file when fullOutputFilePath is provided but output is not truncated', async () => {
// 1. Setup Config for Truncation
vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(100);
const unlinkSpy = vi
.spyOn(fsPromises, 'unlink')
.mockResolvedValue(undefined);
const mockTool = new MockTool({ name: SHELL_TOOL_NAME });
const invocation = mockTool.build({});
const shortOutput = 'Short';
// 2. Mock execution returning short content AND fullOutputFilePath
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({
llmContent: shortOutput,
returnDisplay: shortOutput,
fullOutputFilePath: '/tmp/temp_full_output_short.txt',
});
const scheduledCall: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
callId: 'call-short-full',
name: SHELL_TOOL_NAME,
args: { command: 'echo short' },
isClientInitiated: false,
prompt_id: 'prompt-short-full',
},
tool: mockTool,
invocation: invocation as unknown as AnyToolInvocation,
startTime: Date.now(),
};
// 3. Execute
const result = await executor.execute({
call: scheduledCall,
signal: new AbortController().signal,
onUpdateToolCall: vi.fn(),
});
// 4. Verify file deletion
expect(unlinkSpy).toHaveBeenCalledWith('/tmp/temp_full_output_short.txt');
expect(fileUtils.formatTruncatedToolOutput).not.toHaveBeenCalled();
// We should not save it since it was not truncated
expect(result.status).toBe(CoreToolCallStatus.Success);
if (result.status === CoreToolCallStatus.Success) {
const response = result.response.responseParts[0]?.functionResponse
?.response as Record<string, unknown>;
expect(response).toEqual({
output: 'Short',
});
expect(result.response.outputFile).toBeUndefined();
}
unlinkSpy.mockRestore();
});
it('should delete temporary file on error if fullOutputFilePath is provided', async () => {
const unlinkSpy = vi
.spyOn(fsPromises, 'unlink')
.mockResolvedValue(undefined);
const mockTool = new MockTool({ name: 'failTool' });
const invocation = mockTool.build({});
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({
llmContent: 'partial',
returnDisplay: 'partial',
fullOutputFilePath: '/tmp/temp_error.txt',
error: { message: 'Tool Failed' },
});
const scheduledCall: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
callId: 'call-err',
name: 'failTool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-err',
},
tool: mockTool,
invocation: invocation as unknown as AnyToolInvocation,
startTime: Date.now(),
};
const result = await executor.execute({
call: scheduledCall,
signal: new AbortController().signal,
onUpdateToolCall: vi.fn(),
});
expect(unlinkSpy).toHaveBeenCalledWith('/tmp/temp_error.txt');
expect(result.status).toBe(CoreToolCallStatus.Error);
unlinkSpy.mockRestore();
});
it('should delete temporary file on abort if fullOutputFilePath is provided', async () => {
const unlinkSpy = vi
.spyOn(fsPromises, 'unlink')
.mockResolvedValue(undefined);
const mockTool = new MockTool({ name: 'slowTool' });
const invocation = mockTool.build({});
const controller = new AbortController();
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockImplementation(
async () => {
controller.abort();
return {
llmContent: 'partial',
returnDisplay: 'partial',
fullOutputFilePath: '/tmp/temp_abort.txt',
};
},
);
const scheduledCall: ScheduledToolCall = {
status: CoreToolCallStatus.Scheduled,
request: {
callId: 'call-abort',
name: 'slowTool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-abort',
},
tool: mockTool,
invocation: invocation as unknown as AnyToolInvocation,
startTime: Date.now(),
};
const result = await executor.execute({
call: scheduledCall,
signal: controller.signal,
onUpdateToolCall: vi.fn(),
});
expect(unlinkSpy).toHaveBeenCalledWith('/tmp/temp_abort.txt');
expect(result.status).toBe(CoreToolCallStatus.Cancelled);
unlinkSpy.mockRestore();
});
it('should report execution ID updates for backgroundable tools', async () => {
// 1. Setup ShellToolInvocation
const messageBus = createMockMessageBus();
+51 -1
View File
@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import fsPromises from 'node:fs/promises';
import { debugLogger } from '../utils/debugLogger.js';
import {
ToolErrorType,
ToolOutputTruncatedEvent,
@@ -137,6 +139,16 @@ export class ToolExecutor {
}
if (signal.aborted) {
if (toolResult.fullOutputFilePath) {
await fsPromises
.unlink(toolResult.fullOutputFilePath)
.catch((error) => {
debugLogger.warn(
`Failed to delete temporary tool output file on abort: ${toolResult.fullOutputFilePath}`,
error,
);
});
}
completedToolCall = await this.createCancelledResult(
call,
'User cancelled tool execution.',
@@ -367,12 +379,50 @@ export class ToolExecutor {
call: ToolCall,
toolResult: ToolResult,
): Promise<SuccessfulToolCall> {
const { truncatedContent: content, outputFile } =
let { truncatedContent: content, outputFile } =
await this.truncateOutputIfNeeded(call, toolResult.llmContent);
const toolName = call.request.originalRequestName || call.request.name;
const callId = call.request.callId;
if (toolResult.fullOutputFilePath) {
const threshold = this.config.getTruncateToolOutputThreshold();
if (
threshold > 0 &&
typeof content === 'string' &&
content.length > threshold
) {
const { outputFile: savedPath } = await moveToolOutputToFile(
toolResult.fullOutputFilePath,
toolName,
callId,
this.config.storage.getProjectTempDir(),
this.config.getSessionId(),
);
outputFile = savedPath;
content = formatTruncatedToolOutput(content, outputFile, threshold);
logToolOutputTruncated(
this.config,
new ToolOutputTruncatedEvent(call.request.prompt_id, {
toolName,
originalContentLength: content.length, // approximation
truncatedContentLength: content.length,
threshold,
}),
);
} else {
try {
await fsPromises.unlink(toolResult.fullOutputFilePath);
} catch (error) {
debugLogger.warn(
`Failed to delete temporary tool output file: ${toolResult.fullOutputFilePath}`,
error,
);
}
}
}
const response = convertToFunctionResponse(
toolName,
callId,
@@ -35,6 +35,14 @@ export interface ExecutionHandle {
}
export type ExecutionOutputEvent =
| {
type: 'raw_data';
chunk: string;
}
| {
type: 'file_data';
chunk: string;
}
| {
type: 'data';
chunk: string | AnsiOutput;
@@ -487,6 +487,7 @@ describe('ShellExecutionService', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
headlessTerminal: mockHeadlessTerminal as any,
command: 'some-command',
lastCommittedLine: -1,
});
});
@@ -987,6 +988,8 @@ describe('ShellExecutionService', () => {
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
});
// We don't check result here because result is not available in the test body as written
// The test body doesn't capture the return value of simulateExecution correctly for this assertion.
expect(onOutputEventMock).toHaveBeenCalledTimes(4);
expect(onOutputEventMock.mock.calls[0][0]).toEqual({
type: 'binary_detected',
@@ -1568,6 +1571,8 @@ describe('ShellExecutionService child_process fallback', () => {
cp.emit('exit', 0, null);
});
// We don't check result here because result is not available in the test body as written
// The test body doesn't capture the return value of simulateExecution correctly for this assertion.
expect(onOutputEventMock).toHaveBeenCalledTimes(4);
expect(onOutputEventMock.mock.calls[0][0]).toEqual({
type: 'binary_detected',
@@ -118,6 +118,8 @@ interface ActivePty {
maxSerializedLines?: number;
command: string;
sessionId?: string;
lastSerializedOutput?: AnsiOutput;
lastCommittedLine: number;
}
interface ActiveChildProcess {
@@ -146,6 +148,48 @@ const findLastContentLine = (
return -1;
};
const emitPendingLines = (
activePty: ActivePty,
pid: number,
onOutputEvent: (event: ShellOutputEvent) => void,
forceAll = false,
) => {
const buffer = activePty.headlessTerminal.buffer.active;
const limit = forceAll ? buffer.length : buffer.baseY;
let chunks = '';
for (let i = activePty.lastCommittedLine + 1; i < limit; i++) {
const line = buffer.getLine(i);
if (!line) continue;
let trimRight = true;
let isNextLineWrapped = false;
if (i + 1 < buffer.length) {
const nextLine = buffer.getLine(i + 1);
if (nextLine?.isWrapped) {
isNextLineWrapped = true;
trimRight = false;
}
}
const lineContent = line.translateToString(trimRight);
chunks += lineContent;
if (!isNextLineWrapped) {
chunks += '\n';
}
}
if (chunks.length > 0) {
const event: ShellOutputEvent = {
type: 'file_data',
chunk: chunks,
};
onOutputEvent(event);
ExecutionLifecycleService.emitEvent(pid, event);
activePty.lastCommittedLine = limit - 1;
}
};
const getFullBufferText = (terminal: pkg.Terminal, startLine = 0): string => {
const buffer = terminal.buffer.active;
const lines: string[] = [];
@@ -776,6 +820,15 @@ export class ShellExecutionService {
if (remaining) {
state.output += remaining;
if (isStreamingRawContent) {
const rawEvent: ShellOutputEvent = {
type: 'raw_data',
chunk: remaining,
};
onOutputEvent(rawEvent);
if (child.pid) {
ExecutionLifecycleService.emitEvent(child.pid, rawEvent);
}
const event: ShellOutputEvent = {
type: 'data',
chunk: remaining,
@@ -784,6 +837,15 @@ export class ShellExecutionService {
if (child.pid) {
ExecutionLifecycleService.emitEvent(child.pid, event);
}
const fileEvent: ShellOutputEvent = {
type: 'file_data',
chunk: stripAnsi(remaining),
};
onOutputEvent(fileEvent);
if (child.pid) {
ExecutionLifecycleService.emitEvent(child.pid, fileEvent);
}
}
}
}
@@ -792,6 +854,15 @@ export class ShellExecutionService {
if (remaining) {
state.output += remaining;
if (isStreamingRawContent) {
const rawEvent: ShellOutputEvent = {
type: 'raw_data',
chunk: remaining,
};
onOutputEvent(rawEvent);
if (child.pid) {
ExecutionLifecycleService.emitEvent(child.pid, rawEvent);
}
const event: ShellOutputEvent = {
type: 'data',
chunk: remaining,
@@ -800,6 +871,15 @@ export class ShellExecutionService {
if (child.pid) {
ExecutionLifecycleService.emitEvent(child.pid, event);
}
const fileEvent: ShellOutputEvent = {
type: 'file_data',
chunk: stripAnsi(remaining),
};
onOutputEvent(fileEvent);
if (child.pid) {
ExecutionLifecycleService.emitEvent(child.pid, fileEvent);
}
}
}
}
@@ -887,6 +967,7 @@ export class ShellExecutionService {
maxSerializedLines: shellExecutionConfig.maxSerializedLines,
command: shellExecutionConfig.originalCommand ?? commandToExecute,
sessionId: shellExecutionConfig.sessionId,
lastCommittedLine: -1,
});
const result = ExecutionLifecycleService.attachExecution(ptyPid, {
@@ -1059,10 +1140,37 @@ export class ShellExecutionService {
}, 68);
};
headlessTerminal.onScroll(() => {
let lastYdisp = 0;
let hasReachedMax = false;
const scrollbackLimit = shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT;
headlessTerminal.onScroll((ydisp) => {
if (!isWriting) {
render();
}
if (
ydisp === scrollbackLimit &&
lastYdisp === scrollbackLimit &&
hasReachedMax
) {
const activePty = this.activePtys.get(ptyPid);
if (activePty) {
activePty.lastCommittedLine--;
}
}
if (
ydisp === scrollbackLimit &&
headlessTerminal.buffer.active.length === scrollbackLimit + rows
) {
hasReachedMax = true;
}
lastYdisp = ydisp;
const activePtyForEmit = this.activePtys.get(ptyPid);
if (activePtyForEmit) {
emitPendingLines(activePtyForEmit, ptyPid, onOutputEvent);
}
});
const handleOutput = (data: Buffer) => {
@@ -1447,6 +1555,7 @@ export class ShellExecutionService {
startLine,
endLine,
);
activePty.lastSerializedOutput = bufferData;
const event: ShellOutputEvent = { type: 'data', chunk: bufferData };
ExecutionLifecycleService.emitEvent(pid, event);
}
+1
View File
@@ -119,6 +119,7 @@ describe('ShellTool', () => {
getDebugMode: vi.fn().mockReturnValue(false),
getTargetDir: vi.fn().mockReturnValue(tempRootDir),
getSummarizeToolOutputConfig: vi.fn().mockReturnValue(undefined),
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(40000),
getWorkspaceContext: vi
.fn()
.mockReturnValue(new WorkspaceContext(tempRootDir)),
+71 -18
View File
@@ -9,7 +9,8 @@ import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import { debugLogger } from '../index.js';
import type { Config } from '../config/config.js';
import { debugLogger } from '../utils/debugLogger.js';
import {
type SandboxPermissions,
getPathIdentity,
@@ -458,6 +459,12 @@ export class ShellToolInvocation extends BaseToolInvocation<
const onAbort = () => combinedController.abort();
const outputFileName = `gemini_shell_output_${crypto.randomBytes(6).toString('hex')}.log`;
const outputFilePath = path.join(os.tmpdir(), outputFileName);
const outputStream = fs.createWriteStream(outputFilePath);
let fullOutputReturned = false;
try {
// pgrep is not available on Windows, so we can't get background PIDs
const commandToExecute = this.wrapCommandForPgrep(
@@ -484,6 +491,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
let cumulativeOutput: string | AnsiOutput = '';
let lastUpdateTime = Date.now();
let isBinaryStream = false;
let totalBytesWritten = 0;
const resetTimeout = () => {
if (timeoutMs <= 0) {
@@ -509,31 +517,46 @@ export class ShellToolInvocation extends BaseToolInvocation<
cwd,
(event: ShellOutputEvent) => {
resetTimeout(); // Reset timeout on any event
if (!updateOutput) {
return;
}
let shouldUpdate = false;
switch (event.type) {
case 'raw_data':
// We do not write raw data to the file to avoid spurious escape codes.
// We rely on 'file_data' for the clean output stream.
break;
case 'file_data':
if (!isBinaryStream) {
totalBytesWritten += Buffer.byteLength(event.chunk);
outputStream.write(event.chunk);
}
break;
case 'data':
if (isBinaryStream) break;
cumulativeOutput = event.chunk;
shouldUpdate = true;
if (updateOutput && !this.params.is_background) {
updateOutput(cumulativeOutput);
lastUpdateTime = Date.now();
}
break;
case 'binary_detected':
isBinaryStream = true;
cumulativeOutput =
'[Binary output detected. Halting stream...]';
shouldUpdate = true;
if (updateOutput && !this.params.is_background) {
updateOutput(cumulativeOutput);
}
break;
case 'binary_progress':
isBinaryStream = true;
cumulativeOutput = `[Receiving binary output... ${formatBytes(
event.bytesReceived,
)} received]`;
if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) {
shouldUpdate = true;
if (
updateOutput &&
!this.params.is_background &&
Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS
) {
updateOutput(cumulativeOutput);
lastUpdateTime = Date.now();
}
break;
case 'exit':
@@ -542,11 +565,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
throw new Error('An unhandled ShellOutputEvent was found.');
}
}
if (shouldUpdate && !this.params.is_background) {
updateOutput(cumulativeOutput);
lastUpdateTime = Date.now();
}
},
combinedController.signal,
this.context.config.getEnableInteractiveShell(),
@@ -619,6 +637,9 @@ export class ShellToolInvocation extends BaseToolInvocation<
}
const result = await resultPromise;
await new Promise<void>((resolve) => {
outputStream.end(resolve);
});
const backgroundPIDs: number[] = [];
if (os.platform() !== 'win32') {
@@ -912,21 +933,46 @@ export class ShellToolInvocation extends BaseToolInvocation<
this.context.geminiClient,
signal,
);
return {
const threshold = this.config.getTruncateToolOutputThreshold();
const fullOutputFilePath =
threshold > 0 && totalBytesWritten >= threshold
? outputFilePath
: undefined;
const toolResult: ToolResult = {
llmContent: summary,
returnDisplay,
returnDisplay: typeof returnDisplayMessage !== 'undefined' ? returnDisplayMessage : returnDisplay,
fullOutputFilePath,
...executionError,
};
if (toolResult.fullOutputFilePath) {
fullOutputReturned = true;
}
return toolResult;
}
return {
const threshold = this.config.getTruncateToolOutputThreshold();
const fullOutputFilePath =
threshold > 0 && totalBytesWritten >= threshold
? outputFilePath
: undefined;
const toolResult: ToolResult = {
llmContent,
returnDisplay,
data,
fullOutputFilePath,
...executionError,
};
if (toolResult.fullOutputFilePath) {
fullOutputReturned = true;
}
return toolResult;
} finally {
if (timeoutTimer) clearTimeout(timeoutTimer);
if (!outputStream.closed) {
outputStream.destroy();
}
signal.removeEventListener('abort', onAbort);
timeoutController.signal.removeEventListener('abort', onAbort);
try {
@@ -934,6 +980,13 @@ export class ShellToolInvocation extends BaseToolInvocation<
} catch {
// Ignore errors during unlink
}
if (!fullOutputReturned) {
try {
await fsPromises.unlink(outputFilePath);
} catch {
// Ignore errors during unlink
}
}
}
}
}
+7
View File
@@ -780,6 +780,13 @@ export interface ToolResult {
name: string;
args: Record<string, unknown>;
};
/**
* Optional path to a file containing the full, non-truncated output of the tool.
* If provided, the scheduler may use this file for long-term storage and
* reference it in the conversation history if the output is truncated.
*/
fullOutputFilePath?: string;
}
/**
+42
View File
@@ -615,6 +615,48 @@ ${head}
${tail}`;
}
/**
* Moves tool output from a source path to a temporary file for later retrieval.
*/
export async function moveToolOutputToFile(
sourcePath: string,
toolName: string,
id: string | number, // Accept string (callId) or number (truncationId)
projectTempDir: string,
sessionId?: string,
): Promise<{ outputFile: string }> {
const safeToolName = sanitizeFilenamePart(toolName).toLowerCase();
const safeId = sanitizeFilenamePart(id.toString()).toLowerCase();
const fileName = safeId.startsWith(safeToolName)
? `${safeId}.txt`
: `${safeToolName}_${safeId}.txt`;
let toolOutputDir = path.join(projectTempDir, TOOL_OUTPUTS_DIR);
if (sessionId) {
const safeSessionId = sanitizeFilenamePart(sessionId);
toolOutputDir = path.join(toolOutputDir, `session-${safeSessionId}`);
}
const outputFile = path.join(toolOutputDir, fileName);
await fsPromises.mkdir(toolOutputDir, { recursive: true });
try {
// Attempt rename (efficient if on the same filesystem)
await fsPromises.rename(sourcePath, outputFile);
} catch (error: unknown) {
// If rename fails (e.g. cross-filesystem), copy and then delete
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
if ((error as { code?: string }).code === 'EXDEV') {
await fsPromises.copyFile(sourcePath, outputFile);
await fsPromises.unlink(sourcePath);
} else {
throw error;
}
}
return { outputFile };
}
/**
* Saves tool output to a temporary file for later retrieval.
*/
@@ -22,12 +22,13 @@ function createFunctionResponsePart(
callId: string,
toolName: string,
output: string,
outputFile?: string,
): Part {
return {
functionResponse: {
id: callId,
name: toolName,
response: { output },
response: { output, outputFile },
},
};
}
@@ -50,9 +51,12 @@ export function convertToFunctionResponse(
llmContent: PartListUnion,
model: string,
config?: Config,
outputFile?: string,
): Part[] {
if (typeof llmContent === 'string') {
return [createFunctionResponsePart(callId, toolName, llmContent)];
return [
createFunctionResponsePart(callId, toolName, llmContent, outputFile),
];
}
const parts = toParts(llmContent);
@@ -94,7 +98,10 @@ export function convertToFunctionResponse(
functionResponse: {
id: callId,
name: toolName,
response: textParts.length > 0 ? { output: textParts.join('\n') } : {},
response: {
...(textParts.length > 0 ? { output: textParts.join('\n') } : {}),
outputFile,
},
},
};