mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-19 02:20:42 -07:00
refactor(core): extract static concerns from CoreToolScheduler (#15589)
This commit is contained in:
@@ -17,7 +17,8 @@ import type {
|
||||
} from '@google/genai';
|
||||
import { executeToolCall } from '../core/nonInteractiveToolExecutor.js';
|
||||
import { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import { type ToolCallRequestInfo, CompressionStatus } from '../core/turn.js';
|
||||
import { CompressionStatus } from '../core/turn.js';
|
||||
import { type ToolCallRequestInfo } from '../scheduler/types.js';
|
||||
import { ChatCompressionService } from '../services/chatCompressionService.js';
|
||||
import { getDirectoryContextString } from '../utils/environmentContext.js';
|
||||
import { promptIdContext } from '../utils/promptIdContext.js';
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
type AnyToolInvocation,
|
||||
} from '../tools/tools.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { ToolCallResponseInfo } from '../core/turn.js';
|
||||
import type { ToolCallResponseInfo } from '../scheduler/types.js';
|
||||
|
||||
function createMockResponse(
|
||||
candidates: GenerateContentResponse['candidates'] = [],
|
||||
|
||||
@@ -4,18 +4,14 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
import {
|
||||
CoreToolScheduler,
|
||||
convertToFunctionResponse,
|
||||
truncateAndSaveToFile,
|
||||
} from './coreToolScheduler.js';
|
||||
import { CoreToolScheduler } from './coreToolScheduler.js';
|
||||
import type {
|
||||
ToolCall,
|
||||
WaitingToolCall,
|
||||
ErroredToolCall,
|
||||
} from './coreToolScheduler.js';
|
||||
} from '../scheduler/types.js';
|
||||
import type {
|
||||
ToolCallConfirmationDetails,
|
||||
ToolConfirmationPayload,
|
||||
@@ -36,20 +32,14 @@ import {
|
||||
HookSystem,
|
||||
} from '../index.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
import type { Part, PartListUnion } from '@google/genai';
|
||||
import {
|
||||
MockModifiableTool,
|
||||
MockTool,
|
||||
MOCK_TOOL_SHOULD_CONFIRM_EXECUTE,
|
||||
} from '../test-utils/mock-tool.js';
|
||||
import * as modifiableToolModule from '../tools/modifiable-tool.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { isShellInvocationAllowlisted } from '../utils/shell-permissions.js';
|
||||
import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
} from '../config/models.js';
|
||||
import { DEFAULT_GEMINI_MODEL } from '../config/models.js';
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
writeFile: vi.fn(),
|
||||
@@ -655,42 +645,6 @@ describe('CoreToolScheduler', () => {
|
||||
'Tool execution for "mockTool" requires user confirmation, which is not supported in non-interactive mode.',
|
||||
);
|
||||
});
|
||||
|
||||
describe('getToolSuggestion', () => {
|
||||
it('should suggest the top N closest tool names for a typo', () => {
|
||||
// Create mocked tool registry
|
||||
const mockToolRegistry = {
|
||||
getAllToolNames: () => ['list_files', 'read_file', 'write_file'],
|
||||
} as unknown as ToolRegistry;
|
||||
const mockConfig = createMockConfig({
|
||||
getToolRegistry: () => mockToolRegistry,
|
||||
isInteractive: () => false,
|
||||
});
|
||||
|
||||
// Create scheduler
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
});
|
||||
|
||||
// Test that the right tool is selected, with only 1 result, for typos
|
||||
// @ts-expect-error accessing private method
|
||||
const misspelledTool = scheduler.getToolSuggestion('list_fils', 1);
|
||||
expect(misspelledTool).toBe(' Did you mean "list_files"?');
|
||||
|
||||
// Test that the right tool is selected, with only 1 result, for prefixes
|
||||
// @ts-expect-error accessing private method
|
||||
const prefixedTool = scheduler.getToolSuggestion('github.list_files', 1);
|
||||
expect(prefixedTool).toBe(' Did you mean "list_files"?');
|
||||
|
||||
// Test that the right tool is first
|
||||
// @ts-expect-error accessing private method
|
||||
const suggestionMultiple = scheduler.getToolSuggestion('list_fils');
|
||||
expect(suggestionMultiple).toBe(
|
||||
' Did you mean one of: "list_files", "read_file", "write_file"?',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CoreToolScheduler with payload', () => {
|
||||
@@ -771,310 +725,6 @@ describe('CoreToolScheduler with payload', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertToFunctionResponse', () => {
|
||||
const toolName = 'testTool';
|
||||
const callId = 'call1';
|
||||
|
||||
it('should handle simple string llmContent', () => {
|
||||
const llmContent = 'Simple text output';
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: { output: 'Simple text output' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle llmContent as a single Part with text', () => {
|
||||
const llmContent: Part = { text: 'Text from Part object' };
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: { output: 'Text from Part object' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle llmContent as a PartListUnion array with a single text Part', () => {
|
||||
const llmContent: PartListUnion = [{ text: 'Text from array' }];
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: { output: 'Text from array' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle llmContent as a PartListUnion array with multiple Parts', () => {
|
||||
const llmContent: PartListUnion = [{ text: 'part1' }, { text: 'part2' }];
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: { output: 'part1\npart2' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle llmContent with fileData for Gemini 3 model (should be siblings)', () => {
|
||||
const llmContent: Part = {
|
||||
fileData: { mimeType: 'application/pdf', fileUri: 'gs://...' },
|
||||
};
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: { output: 'Binary content provided (1 item(s)).' },
|
||||
},
|
||||
},
|
||||
llmContent,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle llmContent with inlineData for Gemini 3 model (should be nested)', () => {
|
||||
const llmContent: Part = {
|
||||
inlineData: { mimeType: 'image/png', data: 'base64...' },
|
||||
};
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: { output: 'Binary content provided (1 item(s)).' },
|
||||
parts: [llmContent],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle llmContent with fileData for non-Gemini 3 models', () => {
|
||||
const llmContent: Part = {
|
||||
fileData: { mimeType: 'application/pdf', fileUri: 'gs://...' },
|
||||
};
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: { output: 'Binary content provided (1 item(s)).' },
|
||||
},
|
||||
},
|
||||
llmContent,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should preserve existing functionResponse metadata', () => {
|
||||
const innerId = 'inner-call-id';
|
||||
const innerName = 'inner-tool-name';
|
||||
const responseMetadata = {
|
||||
flags: ['flag1'],
|
||||
isError: false,
|
||||
customData: { key: 'value' },
|
||||
};
|
||||
const input: Part = {
|
||||
functionResponse: {
|
||||
id: innerId,
|
||||
name: innerName,
|
||||
response: responseMetadata,
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
input,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].functionResponse).toEqual({
|
||||
id: callId,
|
||||
name: toolName,
|
||||
response: responseMetadata,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle llmContent as an array of multiple Parts (text and inlineData)', () => {
|
||||
const llmContent: PartListUnion = [
|
||||
{ text: 'Some textual description' },
|
||||
{ inlineData: { mimeType: 'image/jpeg', data: 'base64data...' } },
|
||||
{ text: 'Another text part' },
|
||||
];
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: {
|
||||
output: 'Some textual description\nAnother text part',
|
||||
},
|
||||
parts: [
|
||||
{ inlineData: { mimeType: 'image/jpeg', data: 'base64data...' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle llmContent as an array with a single inlineData Part', () => {
|
||||
const llmContent: PartListUnion = [
|
||||
{ inlineData: { mimeType: 'image/gif', data: 'gifdata...' } },
|
||||
];
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: { output: 'Binary content provided (1 item(s)).' },
|
||||
parts: llmContent,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle llmContent as a generic Part (not text, inlineData, or fileData)', () => {
|
||||
const llmContent: Part = { functionCall: { name: 'test', args: {} } };
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: {},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty string llmContent', () => {
|
||||
const llmContent = '';
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: { output: '' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle llmContent as an empty array', () => {
|
||||
const llmContent: PartListUnion = [];
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: {},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle llmContent as a Part with undefined inlineData/fileData/text', () => {
|
||||
const llmContent: Part = {}; // An empty part object
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: {},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
class MockEditToolInvocation extends BaseToolInvocation<
|
||||
Record<string, unknown>,
|
||||
ToolResult
|
||||
@@ -2130,224 +1780,3 @@ describe('CoreToolScheduler Sequential Execution', () => {
|
||||
modifyWithEditorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateAndSaveToFile', () => {
|
||||
const mockWriteFile = vi.mocked(fs.writeFile);
|
||||
const THRESHOLD = 40_000;
|
||||
const TRUNCATE_LINES = 1000;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return content unchanged if below threshold', async () => {
|
||||
const content = 'Short content';
|
||||
const callId = 'test-call-id';
|
||||
const projectTempDir = '/tmp';
|
||||
|
||||
const result = await truncateAndSaveToFile(
|
||||
content,
|
||||
callId,
|
||||
projectTempDir,
|
||||
THRESHOLD,
|
||||
TRUNCATE_LINES,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ content });
|
||||
expect(mockWriteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should truncate content by lines when content has many lines', async () => {
|
||||
// Create content that exceeds 100,000 character threshold with many lines
|
||||
const lines = Array(2000).fill('x'.repeat(100)); // 100 chars per line * 2000 lines = 200,000 chars
|
||||
const content = lines.join('\n');
|
||||
const callId = 'test-call-id';
|
||||
const projectTempDir = '/tmp';
|
||||
|
||||
mockWriteFile.mockResolvedValue(undefined);
|
||||
|
||||
const result = await truncateAndSaveToFile(
|
||||
content,
|
||||
callId,
|
||||
projectTempDir,
|
||||
THRESHOLD,
|
||||
TRUNCATE_LINES,
|
||||
);
|
||||
|
||||
expect(result.outputFile).toBe(
|
||||
path.join(projectTempDir, `${callId}.output`),
|
||||
);
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
||||
path.join(projectTempDir, `${callId}.output`),
|
||||
content,
|
||||
);
|
||||
|
||||
// Should contain the first and last lines with 1/5 head and 4/5 tail
|
||||
const head = Math.floor(TRUNCATE_LINES / 5);
|
||||
const beginning = lines.slice(0, head);
|
||||
const end = lines.slice(-(TRUNCATE_LINES - head));
|
||||
const expectedTruncated =
|
||||
beginning.join('\n') + '\n... [CONTENT TRUNCATED] ...\n' + end.join('\n');
|
||||
|
||||
expect(result.content).toContain(
|
||||
'Tool output was too large and has been truncated',
|
||||
);
|
||||
expect(result.content).toContain('Truncated part of the output:');
|
||||
expect(result.content).toContain(expectedTruncated);
|
||||
});
|
||||
|
||||
it('should wrap and truncate content when content has few but long lines', async () => {
|
||||
const content = 'a'.repeat(200_000); // A single very long line
|
||||
const callId = 'test-call-id';
|
||||
const projectTempDir = '/tmp';
|
||||
const wrapWidth = 120;
|
||||
|
||||
mockWriteFile.mockResolvedValue(undefined);
|
||||
|
||||
// Manually wrap the content to generate the expected file content
|
||||
const wrappedLines: string[] = [];
|
||||
for (let i = 0; i < content.length; i += wrapWidth) {
|
||||
wrappedLines.push(content.substring(i, i + wrapWidth));
|
||||
}
|
||||
const expectedFileContent = wrappedLines.join('\n');
|
||||
|
||||
const result = await truncateAndSaveToFile(
|
||||
content,
|
||||
callId,
|
||||
projectTempDir,
|
||||
THRESHOLD,
|
||||
TRUNCATE_LINES,
|
||||
);
|
||||
|
||||
expect(result.outputFile).toBe(
|
||||
path.join(projectTempDir, `${callId}.output`),
|
||||
);
|
||||
// Check that the file was written with the wrapped content
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
||||
path.join(projectTempDir, `${callId}.output`),
|
||||
expectedFileContent,
|
||||
);
|
||||
|
||||
// Should contain the first and last lines with 1/5 head and 4/5 tail of the wrapped content
|
||||
const head = Math.floor(TRUNCATE_LINES / 5);
|
||||
const beginning = wrappedLines.slice(0, head);
|
||||
const end = wrappedLines.slice(-(TRUNCATE_LINES - head));
|
||||
const expectedTruncated =
|
||||
beginning.join('\n') + '\n... [CONTENT TRUNCATED] ...\n' + end.join('\n');
|
||||
expect(result.content).toContain(
|
||||
'Tool output was too large and has been truncated',
|
||||
);
|
||||
expect(result.content).toContain('Truncated part of the output:');
|
||||
expect(result.content).toContain(expectedTruncated);
|
||||
});
|
||||
|
||||
it('should handle file write errors gracefully', async () => {
|
||||
const content = 'a'.repeat(2_000_000);
|
||||
const callId = 'test-call-id';
|
||||
const projectTempDir = '/tmp';
|
||||
|
||||
mockWriteFile.mockRejectedValue(new Error('File write failed'));
|
||||
|
||||
const result = await truncateAndSaveToFile(
|
||||
content,
|
||||
callId,
|
||||
projectTempDir,
|
||||
THRESHOLD,
|
||||
TRUNCATE_LINES,
|
||||
);
|
||||
|
||||
expect(result.outputFile).toBeUndefined();
|
||||
expect(result.content).toContain(
|
||||
'[Note: Could not save full output to file]',
|
||||
);
|
||||
expect(mockWriteFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should save to correct file path with call ID', async () => {
|
||||
const content = 'a'.repeat(200_000);
|
||||
const callId = 'unique-call-123';
|
||||
const projectTempDir = '/custom/temp/dir';
|
||||
const wrapWidth = 120;
|
||||
|
||||
mockWriteFile.mockResolvedValue(undefined);
|
||||
|
||||
// Manually wrap the content to generate the expected file content
|
||||
const wrappedLines: string[] = [];
|
||||
for (let i = 0; i < content.length; i += wrapWidth) {
|
||||
wrappedLines.push(content.substring(i, i + wrapWidth));
|
||||
}
|
||||
const expectedFileContent = wrappedLines.join('\n');
|
||||
|
||||
const result = await truncateAndSaveToFile(
|
||||
content,
|
||||
callId,
|
||||
projectTempDir,
|
||||
THRESHOLD,
|
||||
TRUNCATE_LINES,
|
||||
);
|
||||
|
||||
const expectedPath = path.join(projectTempDir, `${callId}.output`);
|
||||
expect(result.outputFile).toBe(expectedPath);
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
||||
expectedPath,
|
||||
expectedFileContent,
|
||||
);
|
||||
});
|
||||
|
||||
it('should include helpful instructions in truncated message', async () => {
|
||||
const content = 'a'.repeat(2_000_000);
|
||||
const callId = 'test-call-id';
|
||||
const projectTempDir = '/tmp';
|
||||
|
||||
mockWriteFile.mockResolvedValue(undefined);
|
||||
|
||||
const result = await truncateAndSaveToFile(
|
||||
content,
|
||||
callId,
|
||||
projectTempDir,
|
||||
THRESHOLD,
|
||||
TRUNCATE_LINES,
|
||||
);
|
||||
|
||||
expect(result.content).toContain(
|
||||
'read_file tool with the absolute file path above',
|
||||
);
|
||||
expect(result.content).toContain('read_file tool with offset=0, limit=100');
|
||||
expect(result.content).toContain(
|
||||
'read_file tool with offset=N to skip N lines',
|
||||
);
|
||||
expect(result.content).toContain(
|
||||
'read_file tool with limit=M to read only M lines',
|
||||
);
|
||||
});
|
||||
|
||||
it('should sanitize callId to prevent path traversal', async () => {
|
||||
const content = 'a'.repeat(200_000);
|
||||
const callId = '../../../../../etc/passwd';
|
||||
const projectTempDir = '/tmp/safe_dir';
|
||||
const wrapWidth = 120;
|
||||
|
||||
mockWriteFile.mockResolvedValue(undefined);
|
||||
|
||||
// Manually wrap the content to generate the expected file content
|
||||
const wrappedLines: string[] = [];
|
||||
for (let i = 0; i < content.length; i += wrapWidth) {
|
||||
wrappedLines.push(content.substring(i, i + wrapWidth));
|
||||
}
|
||||
const expectedFileContent = wrappedLines.join('\n');
|
||||
|
||||
await truncateAndSaveToFile(
|
||||
content,
|
||||
callId,
|
||||
projectTempDir,
|
||||
THRESHOLD,
|
||||
TRUNCATE_LINES,
|
||||
);
|
||||
|
||||
const expectedPath = path.join(projectTempDir, 'passwd.output');
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
||||
expectedPath,
|
||||
expectedFileContent,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,44 +4,36 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
ToolCallRequestInfo,
|
||||
ToolCallResponseInfo,
|
||||
ToolCallConfirmationDetails,
|
||||
ToolResult,
|
||||
ToolResultDisplay,
|
||||
EditorType,
|
||||
Config,
|
||||
ToolConfirmationPayload,
|
||||
AnyDeclarativeTool,
|
||||
AnyToolInvocation,
|
||||
AnsiOutput,
|
||||
} from '../index.js';
|
||||
import {
|
||||
type ToolResult,
|
||||
type ToolResultDisplay,
|
||||
type AnyDeclarativeTool,
|
||||
type AnyToolInvocation,
|
||||
type ToolCallConfirmationDetails,
|
||||
type ToolConfirmationPayload,
|
||||
ToolConfirmationOutcome,
|
||||
ApprovalMode,
|
||||
logToolCall,
|
||||
ToolErrorType,
|
||||
ToolCallEvent,
|
||||
logToolOutputTruncated,
|
||||
ToolOutputTruncatedEvent,
|
||||
runInDevTraceSpan,
|
||||
} from '../index.js';
|
||||
import { READ_FILE_TOOL_NAME, SHELL_TOOL_NAME } from '../tools/tool-names.js';
|
||||
import type { Part, PartListUnion } from '@google/genai';
|
||||
import { supportsMultimodalFunctionResponse } from '../config/models.js';
|
||||
} from '../tools/tools.js';
|
||||
import type { EditorType } from '../utils/editor.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { AnsiOutput } from '../utils/terminalSerializer.js';
|
||||
import { ApprovalMode } from '../policy/types.js';
|
||||
import { logToolCall, logToolOutputTruncated } from '../telemetry/loggers.js';
|
||||
import { ToolErrorType } from '../tools/tool-error.js';
|
||||
import { ToolCallEvent, ToolOutputTruncatedEvent } from '../telemetry/types.js';
|
||||
import { runInDevTraceSpan } from '../telemetry/trace.js';
|
||||
import { SHELL_TOOL_NAME } from '../tools/tool-names.js';
|
||||
import type { ModifyContext } from '../tools/modifiable-tool.js';
|
||||
import {
|
||||
isModifiableDeclarativeTool,
|
||||
modifyWithEditor,
|
||||
} from '../tools/modifiable-tool.js';
|
||||
import * as Diff from 'diff';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { SHELL_TOOL_NAMES } from '../utils/shell-utils.js';
|
||||
import { doesToolInvocationMatch } from '../utils/tool-utils.js';
|
||||
import {
|
||||
doesToolInvocationMatch,
|
||||
getToolSuggestion,
|
||||
} from '../utils/tool-utils.js';
|
||||
import { isShellInvocationAllowlisted } from '../utils/shell-permissions.js';
|
||||
import levenshtein from 'fast-levenshtein';
|
||||
import { ShellToolInvocation } from '../tools/shell.js';
|
||||
import type { ToolConfirmationRequest } from '../confirmation-bus/types.js';
|
||||
import { MessageBusType } from '../confirmation-bus/types.js';
|
||||
@@ -50,221 +42,46 @@ import {
|
||||
fireToolNotificationHook,
|
||||
executeToolWithHooks,
|
||||
} from './coreToolHookTriggers.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import {
|
||||
type ToolCall,
|
||||
type ValidatingToolCall,
|
||||
type ScheduledToolCall,
|
||||
type ErroredToolCall,
|
||||
type SuccessfulToolCall,
|
||||
type ExecutingToolCall,
|
||||
type CancelledToolCall,
|
||||
type WaitingToolCall,
|
||||
type Status,
|
||||
type CompletedToolCall,
|
||||
type ConfirmHandler,
|
||||
type OutputUpdateHandler,
|
||||
type AllToolCallsCompleteHandler,
|
||||
type ToolCallsUpdateHandler,
|
||||
type ToolCallRequestInfo,
|
||||
type ToolCallResponseInfo,
|
||||
} from '../scheduler/types.js';
|
||||
import { saveTruncatedContent } from '../utils/fileUtils.js';
|
||||
import { convertToFunctionResponse } from '../utils/generateContentResponseUtilities.js';
|
||||
|
||||
export type ValidatingToolCall = {
|
||||
status: 'validating';
|
||||
request: ToolCallRequestInfo;
|
||||
tool: AnyDeclarativeTool;
|
||||
invocation: AnyToolInvocation;
|
||||
startTime?: number;
|
||||
outcome?: ToolConfirmationOutcome;
|
||||
export type {
|
||||
ToolCall,
|
||||
ValidatingToolCall,
|
||||
ScheduledToolCall,
|
||||
ErroredToolCall,
|
||||
SuccessfulToolCall,
|
||||
ExecutingToolCall,
|
||||
CancelledToolCall,
|
||||
WaitingToolCall,
|
||||
Status,
|
||||
CompletedToolCall,
|
||||
ConfirmHandler,
|
||||
OutputUpdateHandler,
|
||||
AllToolCallsCompleteHandler,
|
||||
ToolCallsUpdateHandler,
|
||||
ToolCallRequestInfo,
|
||||
ToolCallResponseInfo,
|
||||
};
|
||||
|
||||
export type ScheduledToolCall = {
|
||||
status: 'scheduled';
|
||||
request: ToolCallRequestInfo;
|
||||
tool: AnyDeclarativeTool;
|
||||
invocation: AnyToolInvocation;
|
||||
startTime?: number;
|
||||
outcome?: ToolConfirmationOutcome;
|
||||
};
|
||||
|
||||
export type ErroredToolCall = {
|
||||
status: 'error';
|
||||
request: ToolCallRequestInfo;
|
||||
response: ToolCallResponseInfo;
|
||||
tool?: AnyDeclarativeTool;
|
||||
durationMs?: number;
|
||||
outcome?: ToolConfirmationOutcome;
|
||||
};
|
||||
|
||||
export type SuccessfulToolCall = {
|
||||
status: 'success';
|
||||
request: ToolCallRequestInfo;
|
||||
tool: AnyDeclarativeTool;
|
||||
response: ToolCallResponseInfo;
|
||||
invocation: AnyToolInvocation;
|
||||
durationMs?: number;
|
||||
outcome?: ToolConfirmationOutcome;
|
||||
};
|
||||
|
||||
export type ExecutingToolCall = {
|
||||
status: 'executing';
|
||||
request: ToolCallRequestInfo;
|
||||
tool: AnyDeclarativeTool;
|
||||
invocation: AnyToolInvocation;
|
||||
liveOutput?: string | AnsiOutput;
|
||||
startTime?: number;
|
||||
outcome?: ToolConfirmationOutcome;
|
||||
pid?: number;
|
||||
};
|
||||
|
||||
export type CancelledToolCall = {
|
||||
status: 'cancelled';
|
||||
request: ToolCallRequestInfo;
|
||||
response: ToolCallResponseInfo;
|
||||
tool: AnyDeclarativeTool;
|
||||
invocation: AnyToolInvocation;
|
||||
durationMs?: number;
|
||||
outcome?: ToolConfirmationOutcome;
|
||||
};
|
||||
|
||||
export type WaitingToolCall = {
|
||||
status: 'awaiting_approval';
|
||||
request: ToolCallRequestInfo;
|
||||
tool: AnyDeclarativeTool;
|
||||
invocation: AnyToolInvocation;
|
||||
confirmationDetails: ToolCallConfirmationDetails;
|
||||
startTime?: number;
|
||||
outcome?: ToolConfirmationOutcome;
|
||||
};
|
||||
|
||||
export type Status = ToolCall['status'];
|
||||
|
||||
export type ToolCall =
|
||||
| ValidatingToolCall
|
||||
| ScheduledToolCall
|
||||
| ErroredToolCall
|
||||
| SuccessfulToolCall
|
||||
| ExecutingToolCall
|
||||
| CancelledToolCall
|
||||
| WaitingToolCall;
|
||||
|
||||
export type CompletedToolCall =
|
||||
| SuccessfulToolCall
|
||||
| CancelledToolCall
|
||||
| ErroredToolCall;
|
||||
|
||||
export type ConfirmHandler = (
|
||||
toolCall: WaitingToolCall,
|
||||
) => Promise<ToolConfirmationOutcome>;
|
||||
|
||||
export type OutputUpdateHandler = (
|
||||
toolCallId: string,
|
||||
outputChunk: string | AnsiOutput,
|
||||
) => void;
|
||||
|
||||
export type AllToolCallsCompleteHandler = (
|
||||
completedToolCalls: CompletedToolCall[],
|
||||
) => Promise<void>;
|
||||
|
||||
export type ToolCallsUpdateHandler = (toolCalls: ToolCall[]) => void;
|
||||
|
||||
/**
|
||||
* Formats tool output for a Gemini FunctionResponse.
|
||||
*/
|
||||
function createFunctionResponsePart(
|
||||
callId: string,
|
||||
toolName: string,
|
||||
output: string,
|
||||
): Part {
|
||||
return {
|
||||
functionResponse: {
|
||||
id: callId,
|
||||
name: toolName,
|
||||
response: { output },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function convertToFunctionResponse(
|
||||
toolName: string,
|
||||
callId: string,
|
||||
llmContent: PartListUnion,
|
||||
model: string,
|
||||
): Part[] {
|
||||
if (typeof llmContent === 'string') {
|
||||
return [createFunctionResponsePart(callId, toolName, llmContent)];
|
||||
}
|
||||
|
||||
const parts = toParts(llmContent);
|
||||
|
||||
// Separate text from binary types
|
||||
const textParts: string[] = [];
|
||||
const inlineDataParts: Part[] = [];
|
||||
const fileDataParts: Part[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.text !== undefined) {
|
||||
textParts.push(part.text);
|
||||
} else if (part.inlineData) {
|
||||
inlineDataParts.push(part);
|
||||
} else if (part.fileData) {
|
||||
fileDataParts.push(part);
|
||||
} else if (part.functionResponse) {
|
||||
if (parts.length > 1) {
|
||||
debugLogger.warn(
|
||||
'convertToFunctionResponse received multiple parts with a functionResponse. Only the functionResponse will be used, other parts will be ignored',
|
||||
);
|
||||
}
|
||||
// Handle passthrough case
|
||||
return [
|
||||
{
|
||||
functionResponse: {
|
||||
id: callId,
|
||||
name: toolName,
|
||||
response: part.functionResponse.response,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
// Ignore other part types
|
||||
}
|
||||
|
||||
// Build the primary response part
|
||||
const part: Part = {
|
||||
functionResponse: {
|
||||
id: callId,
|
||||
name: toolName,
|
||||
response: textParts.length > 0 ? { output: textParts.join('\n') } : {},
|
||||
},
|
||||
};
|
||||
|
||||
const isMultimodalFRSupported = supportsMultimodalFunctionResponse(model);
|
||||
const siblingParts: Part[] = [...fileDataParts];
|
||||
|
||||
if (inlineDataParts.length > 0) {
|
||||
if (isMultimodalFRSupported) {
|
||||
// Nest inlineData if supported by the model
|
||||
(part.functionResponse as unknown as { parts: Part[] }).parts =
|
||||
inlineDataParts;
|
||||
} else {
|
||||
// Otherwise treat as siblings
|
||||
siblingParts.push(...inlineDataParts);
|
||||
}
|
||||
}
|
||||
|
||||
// Add descriptive text if the response object is empty but we have binary content
|
||||
if (
|
||||
textParts.length === 0 &&
|
||||
(inlineDataParts.length > 0 || fileDataParts.length > 0)
|
||||
) {
|
||||
const totalBinaryItems = inlineDataParts.length + fileDataParts.length;
|
||||
part.functionResponse!.response = {
|
||||
output: `Binary content provided (${totalBinaryItems} item(s)).`,
|
||||
};
|
||||
}
|
||||
|
||||
if (siblingParts.length > 0) {
|
||||
return [part, ...siblingParts];
|
||||
}
|
||||
|
||||
return [part];
|
||||
}
|
||||
|
||||
function toParts(input: PartListUnion): Part[] {
|
||||
const parts: Part[] = [];
|
||||
for (const part of Array.isArray(input) ? input : [input]) {
|
||||
if (typeof part === 'string') {
|
||||
parts.push({ text: part });
|
||||
} else if (part) {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
const createErrorResponse = (
|
||||
request: ToolCallRequestInfo,
|
||||
error: Error,
|
||||
@@ -286,70 +103,6 @@ const createErrorResponse = (
|
||||
contentLength: error.message.length,
|
||||
});
|
||||
|
||||
export async function truncateAndSaveToFile(
|
||||
content: string,
|
||||
callId: string,
|
||||
projectTempDir: string,
|
||||
threshold: number,
|
||||
truncateLines: number,
|
||||
): Promise<{ content: string; outputFile?: string }> {
|
||||
if (content.length <= threshold) {
|
||||
return { content };
|
||||
}
|
||||
|
||||
let lines = content.split('\n');
|
||||
let fileContent = content;
|
||||
|
||||
// If the content is long but has few lines, wrap it to enable line-based truncation.
|
||||
if (lines.length <= truncateLines) {
|
||||
const wrapWidth = 120; // A reasonable width for wrapping.
|
||||
const wrappedLines: string[] = [];
|
||||
for (const line of lines) {
|
||||
if (line.length > wrapWidth) {
|
||||
for (let i = 0; i < line.length; i += wrapWidth) {
|
||||
wrappedLines.push(line.substring(i, i + wrapWidth));
|
||||
}
|
||||
} else {
|
||||
wrappedLines.push(line);
|
||||
}
|
||||
}
|
||||
lines = wrappedLines;
|
||||
fileContent = lines.join('\n');
|
||||
}
|
||||
|
||||
const head = Math.floor(truncateLines / 5);
|
||||
const beginning = lines.slice(0, head);
|
||||
const end = lines.slice(-(truncateLines - head));
|
||||
const truncatedContent =
|
||||
beginning.join('\n') + '\n... [CONTENT TRUNCATED] ...\n' + end.join('\n');
|
||||
|
||||
// Sanitize callId to prevent path traversal.
|
||||
const safeFileName = `${path.basename(callId)}.output`;
|
||||
const outputFile = path.join(projectTempDir, safeFileName);
|
||||
try {
|
||||
await fs.writeFile(outputFile, fileContent);
|
||||
|
||||
return {
|
||||
content: `Tool output was too large and has been truncated.
|
||||
The full output has been saved to: ${outputFile}
|
||||
To read the complete output, use the ${READ_FILE_TOOL_NAME} tool with the absolute file path above. For large files, you can use the offset and limit parameters to read specific sections:
|
||||
- ${READ_FILE_TOOL_NAME} tool with offset=0, limit=100 to see the first 100 lines
|
||||
- ${READ_FILE_TOOL_NAME} tool with offset=N to skip N lines from the beginning
|
||||
- ${READ_FILE_TOOL_NAME} tool with limit=M to read only M lines at a time
|
||||
The truncated output below shows the beginning and end of the content. The marker '... [CONTENT TRUNCATED] ...' indicates where content was removed.
|
||||
This allows you to efficiently examine different parts of the output without loading the entire file.
|
||||
Truncated part of the output:
|
||||
${truncatedContent}`,
|
||||
outputFile,
|
||||
};
|
||||
} catch (_error) {
|
||||
return {
|
||||
content:
|
||||
truncatedContent + `\n[Note: Could not save full output to file]`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface CoreToolSchedulerOptions {
|
||||
config: Config;
|
||||
outputUpdateHandler?: OutputUpdateHandler;
|
||||
@@ -656,40 +409,6 @@ export class CoreToolScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a suggestion string for a tool name that was not found in the registry.
|
||||
* It finds the closest matches based on Levenshtein distance.
|
||||
* @param unknownToolName The tool name that was not found.
|
||||
* @param topN The number of suggestions to return. Defaults to 3.
|
||||
* @returns A suggestion string like " Did you mean 'tool'?" or " Did you mean one of: 'tool1', 'tool2'?", or an empty string if no suggestions are found.
|
||||
*/
|
||||
private getToolSuggestion(unknownToolName: string, topN = 3): string {
|
||||
const allToolNames = this.config.getToolRegistry().getAllToolNames();
|
||||
|
||||
const matches = allToolNames.map((toolName) => ({
|
||||
name: toolName,
|
||||
distance: levenshtein.get(unknownToolName, toolName),
|
||||
}));
|
||||
|
||||
matches.sort((a, b) => a.distance - b.distance);
|
||||
|
||||
const topNResults = matches.slice(0, topN);
|
||||
|
||||
if (topNResults.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const suggestedNames = topNResults
|
||||
.map((match) => `"${match.name}"`)
|
||||
.join(', ');
|
||||
|
||||
if (topNResults.length > 1) {
|
||||
return ` Did you mean one of: ${suggestedNames}?`;
|
||||
} else {
|
||||
return ` Did you mean ${suggestedNames}?`;
|
||||
}
|
||||
}
|
||||
|
||||
schedule(
|
||||
request: ToolCallRequestInfo | ToolCallRequestInfo[],
|
||||
signal: AbortSignal,
|
||||
@@ -784,7 +503,10 @@ export class CoreToolScheduler {
|
||||
.getToolRegistry()
|
||||
.getTool(reqInfo.name);
|
||||
if (!toolInstance) {
|
||||
const suggestion = this.getToolSuggestion(reqInfo.name);
|
||||
const suggestion = getToolSuggestion(
|
||||
reqInfo.name,
|
||||
this.config.getToolRegistry().getAllToolNames(),
|
||||
);
|
||||
const errorMessage = `Tool "${reqInfo.name}" not found in registry. Tools must use the exact names that are registered.${suggestion}`;
|
||||
return {
|
||||
status: 'error',
|
||||
@@ -1222,7 +944,7 @@ export class CoreToolScheduler {
|
||||
const threshold =
|
||||
this.config.getTruncateToolOutputThreshold();
|
||||
const lines = this.config.getTruncateToolOutputLines();
|
||||
const truncatedResult = await truncateAndSaveToFile(
|
||||
const truncatedResult = await saveTruncatedContent(
|
||||
content,
|
||||
callId,
|
||||
this.config.storage.getProjectTempDir(),
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
Part,
|
||||
PartListUnion,
|
||||
GenerateContentResponse,
|
||||
FunctionCall,
|
||||
@@ -16,9 +15,7 @@ import type {
|
||||
import type {
|
||||
ToolCallConfirmationDetails,
|
||||
ToolResult,
|
||||
ToolResultDisplay,
|
||||
} from '../tools/tools.js';
|
||||
import type { ToolErrorType } from '../tools/tool-error.js';
|
||||
import { getResponseText } from '../utils/partUtils.js';
|
||||
import { reportError } from '../utils/errorReporting.js';
|
||||
import {
|
||||
@@ -33,7 +30,11 @@ import { createUserContent } from '@google/genai';
|
||||
import type { ModelConfigKey } from '../services/modelConfigService.js';
|
||||
import { getCitations } from '../utils/generateContentResponseUtilities.js';
|
||||
|
||||
// Define a structure for tools passed to the server
|
||||
import {
|
||||
type ToolCallRequestInfo,
|
||||
type ToolCallResponseInfo,
|
||||
} from '../scheduler/types.js';
|
||||
|
||||
export interface ServerTool {
|
||||
name: string;
|
||||
schema: FunctionDeclaration;
|
||||
@@ -102,26 +103,6 @@ export interface GeminiFinishedEventValue {
|
||||
usageMetadata: GenerateContentResponseUsageMetadata | undefined;
|
||||
}
|
||||
|
||||
export interface ToolCallRequestInfo {
|
||||
callId: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
isClientInitiated: boolean;
|
||||
prompt_id: string;
|
||||
checkpoint?: string;
|
||||
traceId?: string;
|
||||
}
|
||||
|
||||
export interface ToolCallResponseInfo {
|
||||
callId: string;
|
||||
responseParts: Part[];
|
||||
resultDisplay: ToolResultDisplay | undefined;
|
||||
error: Error | undefined;
|
||||
errorType: ToolErrorType | undefined;
|
||||
outputFile?: string | undefined;
|
||||
contentLength?: number;
|
||||
}
|
||||
|
||||
export interface ServerToolCallConfirmationDetails {
|
||||
request: ToolCallRequestInfo;
|
||||
details: ToolCallConfirmationDetails;
|
||||
|
||||
@@ -35,6 +35,7 @@ export * from './core/tokenLimits.js';
|
||||
export * from './core/turn.js';
|
||||
export * from './core/geminiRequest.js';
|
||||
export * from './core/coreToolScheduler.js';
|
||||
export * from './scheduler/types.js';
|
||||
export * from './core/nonInteractiveToolExecutor.js';
|
||||
export * from './core/recordingContentGenerator.js';
|
||||
|
||||
|
||||
135
packages/core/src/scheduler/types.ts
Normal file
135
packages/core/src/scheduler/types.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Part } from '@google/genai';
|
||||
import type {
|
||||
AnyDeclarativeTool,
|
||||
AnyToolInvocation,
|
||||
ToolCallConfirmationDetails,
|
||||
ToolConfirmationOutcome,
|
||||
ToolResultDisplay,
|
||||
} from '../tools/tools.js';
|
||||
import type { AnsiOutput } from '../utils/terminalSerializer.js';
|
||||
import type { ToolErrorType } from '../tools/tool-error.js';
|
||||
|
||||
export interface ToolCallRequestInfo {
|
||||
callId: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
isClientInitiated: boolean;
|
||||
prompt_id: string;
|
||||
checkpoint?: string;
|
||||
traceId?: string;
|
||||
}
|
||||
|
||||
export interface ToolCallResponseInfo {
|
||||
callId: string;
|
||||
responseParts: Part[];
|
||||
resultDisplay: ToolResultDisplay | undefined;
|
||||
error: Error | undefined;
|
||||
errorType: ToolErrorType | undefined;
|
||||
outputFile?: string | undefined;
|
||||
contentLength?: number;
|
||||
}
|
||||
|
||||
export type ValidatingToolCall = {
|
||||
status: 'validating';
|
||||
request: ToolCallRequestInfo;
|
||||
tool: AnyDeclarativeTool;
|
||||
invocation: AnyToolInvocation;
|
||||
startTime?: number;
|
||||
outcome?: ToolConfirmationOutcome;
|
||||
};
|
||||
|
||||
export type ScheduledToolCall = {
|
||||
status: 'scheduled';
|
||||
request: ToolCallRequestInfo;
|
||||
tool: AnyDeclarativeTool;
|
||||
invocation: AnyToolInvocation;
|
||||
startTime?: number;
|
||||
outcome?: ToolConfirmationOutcome;
|
||||
};
|
||||
|
||||
export type ErroredToolCall = {
|
||||
status: 'error';
|
||||
request: ToolCallRequestInfo;
|
||||
response: ToolCallResponseInfo;
|
||||
tool?: AnyDeclarativeTool;
|
||||
durationMs?: number;
|
||||
outcome?: ToolConfirmationOutcome;
|
||||
};
|
||||
|
||||
export type SuccessfulToolCall = {
|
||||
status: 'success';
|
||||
request: ToolCallRequestInfo;
|
||||
tool: AnyDeclarativeTool;
|
||||
response: ToolCallResponseInfo;
|
||||
invocation: AnyToolInvocation;
|
||||
durationMs?: number;
|
||||
outcome?: ToolConfirmationOutcome;
|
||||
};
|
||||
|
||||
export type ExecutingToolCall = {
|
||||
status: 'executing';
|
||||
request: ToolCallRequestInfo;
|
||||
tool: AnyDeclarativeTool;
|
||||
invocation: AnyToolInvocation;
|
||||
liveOutput?: string | AnsiOutput;
|
||||
startTime?: number;
|
||||
outcome?: ToolConfirmationOutcome;
|
||||
pid?: number;
|
||||
};
|
||||
|
||||
export type CancelledToolCall = {
|
||||
status: 'cancelled';
|
||||
request: ToolCallRequestInfo;
|
||||
response: ToolCallResponseInfo;
|
||||
tool: AnyDeclarativeTool;
|
||||
invocation: AnyToolInvocation;
|
||||
durationMs?: number;
|
||||
outcome?: ToolConfirmationOutcome;
|
||||
};
|
||||
|
||||
export type WaitingToolCall = {
|
||||
status: 'awaiting_approval';
|
||||
request: ToolCallRequestInfo;
|
||||
tool: AnyDeclarativeTool;
|
||||
invocation: AnyToolInvocation;
|
||||
confirmationDetails: ToolCallConfirmationDetails;
|
||||
startTime?: number;
|
||||
outcome?: ToolConfirmationOutcome;
|
||||
};
|
||||
|
||||
export type Status = ToolCall['status'];
|
||||
|
||||
export type ToolCall =
|
||||
| ValidatingToolCall
|
||||
| ScheduledToolCall
|
||||
| ErroredToolCall
|
||||
| SuccessfulToolCall
|
||||
| ExecutingToolCall
|
||||
| CancelledToolCall
|
||||
| WaitingToolCall;
|
||||
|
||||
export type CompletedToolCall =
|
||||
| SuccessfulToolCall
|
||||
| CancelledToolCall
|
||||
| ErroredToolCall;
|
||||
|
||||
export type ConfirmHandler = (
|
||||
toolCall: WaitingToolCall,
|
||||
) => Promise<ToolConfirmationOutcome>;
|
||||
|
||||
export type OutputUpdateHandler = (
|
||||
toolCallId: string,
|
||||
outputChunk: string | AnsiOutput,
|
||||
) => void;
|
||||
|
||||
export type AllToolCallsCompleteHandler = (
|
||||
completedToolCalls: CompletedToolCall[],
|
||||
) => Promise<void>;
|
||||
|
||||
export type ToolCallsUpdateHandler = (toolCalls: ToolCall[]) => void;
|
||||
@@ -13,10 +13,10 @@ import { logToolCall } from './loggers.js';
|
||||
import { ToolCallEvent } from './types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { CompletedToolCall } from '../core/coreToolScheduler.js';
|
||||
import type {
|
||||
ToolCallRequestInfo,
|
||||
ToolCallResponseInfo,
|
||||
} from '../core/turn.js';
|
||||
import {
|
||||
type ToolCallRequestInfo,
|
||||
type ToolCallResponseInfo,
|
||||
} from '../scheduler/types.js';
|
||||
import { MockTool } from '../test-utils/mock-tool.js';
|
||||
|
||||
describe('Circular Reference Handling', () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from './checkpointUtils.js';
|
||||
import type { GitService } from '../services/gitService.js';
|
||||
import type { GeminiClient } from '../core/client.js';
|
||||
import type { ToolCallRequestInfo } from '../core/turn.js';
|
||||
import type { ToolCallRequestInfo } from '../scheduler/types.js';
|
||||
|
||||
describe('checkpoint utils', () => {
|
||||
describe('getToolCallDataSchema', () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { GeminiClient } from '../core/client.js';
|
||||
import { getErrorMessage } from './errors.js';
|
||||
import { z } from 'zod';
|
||||
import type { Content } from '@google/genai';
|
||||
import type { ToolCallRequestInfo } from '../core/turn.js';
|
||||
import type { ToolCallRequestInfo } from '../scheduler/types.js';
|
||||
|
||||
export interface ToolCallData<HistoryType = unknown, ArgsType = unknown> {
|
||||
history?: HistoryType;
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
readFileWithEncoding,
|
||||
fileExists,
|
||||
readWasmBinaryFromDisk,
|
||||
saveTruncatedContent,
|
||||
} from './fileUtils.js';
|
||||
import { StandardFileSystemService } from '../services/fileSystemService.js';
|
||||
|
||||
@@ -1022,4 +1023,213 @@ describe('fileUtils', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveTruncatedContent', () => {
|
||||
const THRESHOLD = 40_000;
|
||||
const TRUNCATE_LINES = 1000;
|
||||
|
||||
it('should return content unchanged if below threshold', async () => {
|
||||
const content = 'Short content';
|
||||
const callId = 'test-call-id';
|
||||
|
||||
const result = await saveTruncatedContent(
|
||||
content,
|
||||
callId,
|
||||
tempRootDir,
|
||||
THRESHOLD,
|
||||
TRUNCATE_LINES,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ content });
|
||||
const outputFile = path.join(tempRootDir, `${callId}.output`);
|
||||
expect(await fileExists(outputFile)).toBe(false);
|
||||
});
|
||||
|
||||
it('should truncate content by lines when content has many lines', async () => {
|
||||
// Create content that exceeds 100,000 character threshold with many lines
|
||||
const lines = Array(2000).fill('x'.repeat(100));
|
||||
const content = lines.join('\n');
|
||||
const callId = 'test-call-id';
|
||||
|
||||
const result = await saveTruncatedContent(
|
||||
content,
|
||||
callId,
|
||||
tempRootDir,
|
||||
THRESHOLD,
|
||||
TRUNCATE_LINES,
|
||||
);
|
||||
|
||||
const expectedOutputFile = path.join(tempRootDir, `${callId}.output`);
|
||||
expect(result.outputFile).toBe(expectedOutputFile);
|
||||
|
||||
const savedContent = await fsPromises.readFile(
|
||||
expectedOutputFile,
|
||||
'utf-8',
|
||||
);
|
||||
expect(savedContent).toBe(content);
|
||||
|
||||
// Should contain the first and last lines with 1/5 head and 4/5 tail
|
||||
const head = Math.floor(TRUNCATE_LINES / 5);
|
||||
const beginning = lines.slice(0, head);
|
||||
const end = lines.slice(-(TRUNCATE_LINES - head));
|
||||
const expectedTruncated =
|
||||
beginning.join('\n') +
|
||||
'\n... [CONTENT TRUNCATED] ...\n' +
|
||||
end.join('\n');
|
||||
|
||||
expect(result.content).toContain(
|
||||
'Tool output was too large and has been truncated',
|
||||
);
|
||||
expect(result.content).toContain('Truncated part of the output:');
|
||||
expect(result.content).toContain(expectedTruncated);
|
||||
});
|
||||
|
||||
it('should wrap and truncate content when content has few but long lines', async () => {
|
||||
const content = 'a'.repeat(200_000); // A single very long line
|
||||
const callId = 'test-call-id';
|
||||
const wrapWidth = 120;
|
||||
|
||||
// Manually wrap the content to generate the expected file content
|
||||
const wrappedLines: string[] = [];
|
||||
for (let i = 0; i < content.length; i += wrapWidth) {
|
||||
wrappedLines.push(content.substring(i, i + wrapWidth));
|
||||
}
|
||||
const expectedFileContent = wrappedLines.join('\n');
|
||||
|
||||
const result = await saveTruncatedContent(
|
||||
content,
|
||||
callId,
|
||||
tempRootDir,
|
||||
THRESHOLD,
|
||||
TRUNCATE_LINES,
|
||||
);
|
||||
|
||||
const expectedOutputFile = path.join(tempRootDir, `${callId}.output`);
|
||||
expect(result.outputFile).toBe(expectedOutputFile);
|
||||
|
||||
const savedContent = await fsPromises.readFile(
|
||||
expectedOutputFile,
|
||||
'utf-8',
|
||||
);
|
||||
expect(savedContent).toBe(expectedFileContent);
|
||||
|
||||
// Should contain the first and last lines with 1/5 head and 4/5 tail of the wrapped content
|
||||
const head = Math.floor(TRUNCATE_LINES / 5);
|
||||
const beginning = wrappedLines.slice(0, head);
|
||||
const end = wrappedLines.slice(-(TRUNCATE_LINES - head));
|
||||
const expectedTruncated =
|
||||
beginning.join('\n') +
|
||||
'\n... [CONTENT TRUNCATED] ...\n' +
|
||||
end.join('\n');
|
||||
expect(result.content).toContain(
|
||||
'Tool output was too large and has been truncated',
|
||||
);
|
||||
expect(result.content).toContain('Truncated part of the output:');
|
||||
expect(result.content).toContain(expectedTruncated);
|
||||
});
|
||||
|
||||
it('should save to correct file path with call ID', async () => {
|
||||
const content = 'a'.repeat(200_000);
|
||||
const callId = 'unique-call-123';
|
||||
const wrapWidth = 120;
|
||||
|
||||
// Manually wrap the content to generate the expected file content
|
||||
const wrappedLines: string[] = [];
|
||||
for (let i = 0; i < content.length; i += wrapWidth) {
|
||||
wrappedLines.push(content.substring(i, i + wrapWidth));
|
||||
}
|
||||
const expectedFileContent = wrappedLines.join('\n');
|
||||
|
||||
const result = await saveTruncatedContent(
|
||||
content,
|
||||
callId,
|
||||
tempRootDir,
|
||||
THRESHOLD,
|
||||
TRUNCATE_LINES,
|
||||
);
|
||||
|
||||
const expectedPath = path.join(tempRootDir, `${callId}.output`);
|
||||
expect(result.outputFile).toBe(expectedPath);
|
||||
|
||||
const savedContent = await fsPromises.readFile(expectedPath, 'utf-8');
|
||||
expect(savedContent).toBe(expectedFileContent);
|
||||
});
|
||||
|
||||
it('should include helpful instructions in truncated message', async () => {
|
||||
const content = 'a'.repeat(200_000);
|
||||
const callId = 'test-call-id';
|
||||
|
||||
const result = await saveTruncatedContent(
|
||||
content,
|
||||
callId,
|
||||
tempRootDir,
|
||||
THRESHOLD,
|
||||
TRUNCATE_LINES,
|
||||
);
|
||||
|
||||
expect(result.content).toContain(
|
||||
'read_file tool with the absolute file path above',
|
||||
);
|
||||
expect(result.content).toContain(
|
||||
'read_file tool with offset=0, limit=100',
|
||||
);
|
||||
expect(result.content).toContain(
|
||||
'read_file tool with offset=N to skip N lines',
|
||||
);
|
||||
expect(result.content).toContain(
|
||||
'read_file tool with limit=M to read only M lines',
|
||||
);
|
||||
});
|
||||
|
||||
it('should sanitize callId to prevent path traversal', async () => {
|
||||
const content = 'a'.repeat(200_000);
|
||||
const callId = '../../../../../etc/passwd';
|
||||
const wrapWidth = 120;
|
||||
|
||||
// Manually wrap the content to generate the expected file content
|
||||
const wrappedLines: string[] = [];
|
||||
for (let i = 0; i < content.length; i += wrapWidth) {
|
||||
wrappedLines.push(content.substring(i, i + wrapWidth));
|
||||
}
|
||||
const expectedFileContent = wrappedLines.join('\n');
|
||||
|
||||
await saveTruncatedContent(
|
||||
content,
|
||||
callId,
|
||||
tempRootDir,
|
||||
THRESHOLD,
|
||||
TRUNCATE_LINES,
|
||||
);
|
||||
|
||||
const expectedPath = path.join(tempRootDir, 'passwd.output');
|
||||
|
||||
const savedContent = await fsPromises.readFile(expectedPath, 'utf-8');
|
||||
expect(savedContent).toBe(expectedFileContent);
|
||||
});
|
||||
|
||||
it('should handle file write errors gracefully', async () => {
|
||||
const content = 'a'.repeat(50_000);
|
||||
const callId = 'test-call-id-fail';
|
||||
|
||||
const writeFileSpy = vi
|
||||
.spyOn(fsPromises, 'writeFile')
|
||||
.mockRejectedValue(new Error('File write failed'));
|
||||
|
||||
const result = await saveTruncatedContent(
|
||||
content,
|
||||
callId,
|
||||
tempRootDir,
|
||||
THRESHOLD,
|
||||
TRUNCATE_LINES,
|
||||
);
|
||||
|
||||
expect(result.outputFile).toBeUndefined();
|
||||
expect(result.content).toContain(
|
||||
'[Note: Could not save full output to file]',
|
||||
);
|
||||
expect(writeFileSpy).toHaveBeenCalled();
|
||||
|
||||
writeFileSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ToolErrorType } from '../tools/tool-error.js';
|
||||
import { BINARY_EXTENSIONS } from './ignorePatterns.js';
|
||||
import { createRequire as createModuleRequire } from 'node:module';
|
||||
import { debugLogger } from './debugLogger.js';
|
||||
import { READ_FILE_TOOL_NAME } from '../tools/tool-names.js';
|
||||
|
||||
const requireModule = createModuleRequire(import.meta.url);
|
||||
|
||||
@@ -515,3 +516,67 @@ export async function fileExists(filePath: string): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveTruncatedContent(
|
||||
content: string,
|
||||
callId: string,
|
||||
projectTempDir: string,
|
||||
threshold: number,
|
||||
truncateLines: number,
|
||||
): Promise<{ content: string; outputFile?: string }> {
|
||||
if (content.length <= threshold) {
|
||||
return { content };
|
||||
}
|
||||
|
||||
let lines = content.split('\n');
|
||||
let fileContent = content;
|
||||
|
||||
// If the content is long but has few lines, wrap it to enable line-based truncation.
|
||||
if (lines.length <= truncateLines) {
|
||||
const wrapWidth = 120; // A reasonable width for wrapping.
|
||||
const wrappedLines: string[] = [];
|
||||
for (const line of lines) {
|
||||
if (line.length > wrapWidth) {
|
||||
for (let i = 0; i < line.length; i += wrapWidth) {
|
||||
wrappedLines.push(line.substring(i, i + wrapWidth));
|
||||
}
|
||||
} else {
|
||||
wrappedLines.push(line);
|
||||
}
|
||||
}
|
||||
lines = wrappedLines;
|
||||
fileContent = lines.join('\n');
|
||||
}
|
||||
|
||||
const head = Math.floor(truncateLines / 5);
|
||||
const beginning = lines.slice(0, head);
|
||||
const end = lines.slice(-(truncateLines - head));
|
||||
const truncatedContent =
|
||||
beginning.join('\n') + '\n... [CONTENT TRUNCATED] ...\n' + end.join('\n');
|
||||
|
||||
// Sanitize callId to prevent path traversal.
|
||||
const safeFileName = `${path.basename(callId)}.output`;
|
||||
const outputFile = path.join(projectTempDir, safeFileName);
|
||||
try {
|
||||
await fsPromises.writeFile(outputFile, fileContent);
|
||||
|
||||
return {
|
||||
content: `Tool output was too large and has been truncated.
|
||||
The full output has been saved to: ${outputFile}
|
||||
To read the complete output, use the ${READ_FILE_TOOL_NAME} tool with the absolute file path above. For large files, you can use the offset and limit parameters to read specific sections:
|
||||
- ${READ_FILE_TOOL_NAME} tool with offset=0, limit=100 to see the first 100 lines
|
||||
- ${READ_FILE_TOOL_NAME} tool with offset=N to skip N lines from the beginning
|
||||
- ${READ_FILE_TOOL_NAME} tool with limit=M to read only M lines at a time
|
||||
The truncated output below shows the beginning and end of the content. The marker '... [CONTENT TRUNCATED] ...' indicates where content was removed.
|
||||
This allows you to efficiently examine different parts of the output without loading the entire file.
|
||||
Truncated part of the output:
|
||||
${truncatedContent}`,
|
||||
outputFile,
|
||||
};
|
||||
} catch (_error) {
|
||||
return {
|
||||
content:
|
||||
truncatedContent + `\n[Note: Could not save full output to file]`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,14 +14,20 @@ import {
|
||||
getStructuredResponse,
|
||||
getStructuredResponseFromParts,
|
||||
getCitations,
|
||||
convertToFunctionResponse,
|
||||
} from './generateContentResponseUtilities.js';
|
||||
import type {
|
||||
GenerateContentResponse,
|
||||
Part,
|
||||
SafetyRating,
|
||||
CitationMetadata,
|
||||
PartListUnion,
|
||||
} from '@google/genai';
|
||||
import { FinishReason } from '@google/genai';
|
||||
import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
} from '../config/models.js';
|
||||
|
||||
const mockTextPart = (text: string): Part => ({ text });
|
||||
const mockFunctionCallPart = (
|
||||
@@ -72,6 +78,312 @@ const minimalMockResponse = (
|
||||
});
|
||||
|
||||
describe('generateContentResponseUtilities', () => {
|
||||
describe('convertToFunctionResponse', () => {
|
||||
const toolName = 'testTool';
|
||||
const callId = 'call1';
|
||||
|
||||
it('should handle simple string llmContent', () => {
|
||||
const llmContent = 'Simple text output';
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: { output: 'Simple text output' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle llmContent as a single Part with text', () => {
|
||||
const llmContent: Part = { text: 'Text from Part object' };
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: { output: 'Text from Part object' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle llmContent as a PartListUnion array with a single text Part', () => {
|
||||
const llmContent: PartListUnion = [{ text: 'Text from array' }];
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: { output: 'Text from array' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle llmContent as a PartListUnion array with multiple Parts', () => {
|
||||
const llmContent: PartListUnion = [{ text: 'part1' }, { text: 'part2' }];
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: { output: 'part1\npart2' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle llmContent with fileData for Gemini 3 model (should be siblings)', () => {
|
||||
const llmContent: Part = {
|
||||
fileData: { mimeType: 'application/pdf', fileUri: 'gs://...' },
|
||||
};
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: { output: 'Binary content provided (1 item(s)).' },
|
||||
},
|
||||
},
|
||||
llmContent,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle llmContent with inlineData for Gemini 3 model (should be nested)', () => {
|
||||
const llmContent: Part = {
|
||||
inlineData: { mimeType: 'image/png', data: 'base64...' },
|
||||
};
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: { output: 'Binary content provided (1 item(s)).' },
|
||||
parts: [llmContent],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle llmContent with fileData for non-Gemini 3 models', () => {
|
||||
const llmContent: Part = {
|
||||
fileData: { mimeType: 'application/pdf', fileUri: 'gs://...' },
|
||||
};
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: { output: 'Binary content provided (1 item(s)).' },
|
||||
},
|
||||
},
|
||||
llmContent,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should preserve existing functionResponse metadata', () => {
|
||||
const innerId = 'inner-call-id';
|
||||
const innerName = 'inner-tool-name';
|
||||
const responseMetadata = {
|
||||
flags: ['flag1'],
|
||||
isError: false,
|
||||
customData: { key: 'value' },
|
||||
};
|
||||
const input: Part = {
|
||||
functionResponse: {
|
||||
id: innerId,
|
||||
name: innerName,
|
||||
response: responseMetadata,
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
input,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].functionResponse).toEqual({
|
||||
id: callId,
|
||||
name: toolName,
|
||||
response: responseMetadata,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle llmContent as an array of multiple Parts (text and inlineData)', () => {
|
||||
const llmContent: PartListUnion = [
|
||||
{ text: 'Some textual description' },
|
||||
{ inlineData: { mimeType: 'image/jpeg', data: 'base64data...' } },
|
||||
{ text: 'Another text part' },
|
||||
];
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: {
|
||||
output: 'Some textual description\nAnother text part',
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
inlineData: { mimeType: 'image/jpeg', data: 'base64data...' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle llmContent as an array with a single inlineData Part', () => {
|
||||
const llmContent: PartListUnion = [
|
||||
{ inlineData: { mimeType: 'image/gif', data: 'gifdata...' } },
|
||||
];
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: { output: 'Binary content provided (1 item(s)).' },
|
||||
parts: llmContent,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle llmContent as a generic Part (not text, inlineData, or fileData)', () => {
|
||||
const llmContent: Part = { functionCall: { name: 'test', args: {} } };
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: {},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty string llmContent', () => {
|
||||
const llmContent = '';
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: { output: '' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle llmContent as an empty array', () => {
|
||||
const llmContent: PartListUnion = [];
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: {},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle llmContent as a Part with undefined inlineData/fileData/text', () => {
|
||||
const llmContent: Part = {}; // An empty part object
|
||||
const result = convertToFunctionResponse(
|
||||
toolName,
|
||||
callId,
|
||||
llmContent,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolName,
|
||||
id: callId,
|
||||
response: {},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCitations', () => {
|
||||
it('should return empty array for no candidates', () => {
|
||||
expect(getCitations(minimalMockResponse(undefined))).toEqual([]);
|
||||
|
||||
@@ -8,8 +8,125 @@ import type {
|
||||
GenerateContentResponse,
|
||||
Part,
|
||||
FunctionCall,
|
||||
PartListUnion,
|
||||
} from '@google/genai';
|
||||
import { getResponseText } from './partUtils.js';
|
||||
import { supportsMultimodalFunctionResponse } from '../config/models.js';
|
||||
import { debugLogger } from './debugLogger.js';
|
||||
|
||||
/**
|
||||
* Formats tool output for a Gemini FunctionResponse.
|
||||
*/
|
||||
function createFunctionResponsePart(
|
||||
callId: string,
|
||||
toolName: string,
|
||||
output: string,
|
||||
): Part {
|
||||
return {
|
||||
functionResponse: {
|
||||
id: callId,
|
||||
name: toolName,
|
||||
response: { output },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toParts(input: PartListUnion): Part[] {
|
||||
const parts: Part[] = [];
|
||||
for (const part of Array.isArray(input) ? input : [input]) {
|
||||
if (typeof part === 'string') {
|
||||
parts.push({ text: part });
|
||||
} else if (part) {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function convertToFunctionResponse(
|
||||
toolName: string,
|
||||
callId: string,
|
||||
llmContent: PartListUnion,
|
||||
model: string,
|
||||
): Part[] {
|
||||
if (typeof llmContent === 'string') {
|
||||
return [createFunctionResponsePart(callId, toolName, llmContent)];
|
||||
}
|
||||
|
||||
const parts = toParts(llmContent);
|
||||
|
||||
// Separate text from binary types
|
||||
const textParts: string[] = [];
|
||||
const inlineDataParts: Part[] = [];
|
||||
const fileDataParts: Part[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.text !== undefined) {
|
||||
textParts.push(part.text);
|
||||
} else if (part.inlineData) {
|
||||
inlineDataParts.push(part);
|
||||
} else if (part.fileData) {
|
||||
fileDataParts.push(part);
|
||||
} else if (part.functionResponse) {
|
||||
if (parts.length > 1) {
|
||||
debugLogger.warn(
|
||||
'convertToFunctionResponse received multiple parts with a functionResponse. Only the functionResponse will be used, other parts will be ignored',
|
||||
);
|
||||
}
|
||||
// Handle passthrough case
|
||||
return [
|
||||
{
|
||||
functionResponse: {
|
||||
id: callId,
|
||||
name: toolName,
|
||||
response: part.functionResponse.response,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
// Ignore other part types
|
||||
}
|
||||
|
||||
// Build the primary response part
|
||||
const part: Part = {
|
||||
functionResponse: {
|
||||
id: callId,
|
||||
name: toolName,
|
||||
response: textParts.length > 0 ? { output: textParts.join('\n') } : {},
|
||||
},
|
||||
};
|
||||
|
||||
const isMultimodalFRSupported = supportsMultimodalFunctionResponse(model);
|
||||
const siblingParts: Part[] = [...fileDataParts];
|
||||
|
||||
if (inlineDataParts.length > 0) {
|
||||
if (isMultimodalFRSupported) {
|
||||
// Nest inlineData if supported by the model
|
||||
(part.functionResponse as unknown as { parts: Part[] }).parts =
|
||||
inlineDataParts;
|
||||
} else {
|
||||
// Otherwise treat as siblings
|
||||
siblingParts.push(...inlineDataParts);
|
||||
}
|
||||
}
|
||||
|
||||
// Add descriptive text if the response object is empty but we have binary content
|
||||
if (
|
||||
textParts.length === 0 &&
|
||||
(inlineDataParts.length > 0 || fileDataParts.length > 0)
|
||||
) {
|
||||
const totalBinaryItems = inlineDataParts.length + fileDataParts.length;
|
||||
part.functionResponse!.response = {
|
||||
output: `Binary content provided (${totalBinaryItems} item(s)).`,
|
||||
};
|
||||
}
|
||||
|
||||
if (siblingParts.length > 0) {
|
||||
return [part, ...siblingParts];
|
||||
}
|
||||
|
||||
return [part];
|
||||
}
|
||||
|
||||
export function getResponseTextFromParts(parts: Part[]): string | undefined {
|
||||
if (!parts) {
|
||||
|
||||
@@ -5,10 +5,34 @@
|
||||
*/
|
||||
|
||||
import { expect, describe, it } from 'vitest';
|
||||
import { doesToolInvocationMatch } from './tool-utils.js';
|
||||
import { doesToolInvocationMatch, getToolSuggestion } from './tool-utils.js';
|
||||
import type { AnyToolInvocation, Config } from '../index.js';
|
||||
import { ReadFileTool } from '../tools/read-file.js';
|
||||
|
||||
describe('getToolSuggestion', () => {
|
||||
it('should suggest the top N closest tool names for a typo', () => {
|
||||
const allToolNames = ['list_files', 'read_file', 'write_file'];
|
||||
|
||||
// Test that the right tool is selected, with only 1 result, for typos
|
||||
const misspelledTool = getToolSuggestion('list_fils', allToolNames, 1);
|
||||
expect(misspelledTool).toBe(' Did you mean "list_files"?');
|
||||
|
||||
// Test that the right tool is selected, with only 1 result, for prefixes
|
||||
const prefixedTool = getToolSuggestion(
|
||||
'github.list_files',
|
||||
allToolNames,
|
||||
1,
|
||||
);
|
||||
expect(prefixedTool).toBe(' Did you mean "list_files"?');
|
||||
|
||||
// Test that the right tool is first
|
||||
const suggestionMultiple = getToolSuggestion('list_fils', allToolNames);
|
||||
expect(suggestionMultiple).toBe(
|
||||
' Did you mean one of: "list_files", "read_file", "write_file"?',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('doesToolInvocationMatch', () => {
|
||||
it('should not match a partial command prefix', () => {
|
||||
const invocation = {
|
||||
|
||||
@@ -7,6 +7,44 @@
|
||||
import type { AnyDeclarativeTool, AnyToolInvocation } from '../index.js';
|
||||
import { isTool } from '../index.js';
|
||||
import { SHELL_TOOL_NAMES } from './shell-utils.js';
|
||||
import levenshtein from 'fast-levenshtein';
|
||||
|
||||
/**
|
||||
* Generates a suggestion string for a tool name that was not found in the registry.
|
||||
* It finds the closest matches based on Levenshtein distance.
|
||||
* @param unknownToolName The tool name that was not found.
|
||||
* @param allToolNames The list of all available tool names.
|
||||
* @param topN The number of suggestions to return. Defaults to 3.
|
||||
* @returns A suggestion string like " Did you mean 'tool'?" or " Did you mean one of: 'tool1', 'tool2'?", or an empty string if no suggestions are found.
|
||||
*/
|
||||
export function getToolSuggestion(
|
||||
unknownToolName: string,
|
||||
allToolNames: string[],
|
||||
topN = 3,
|
||||
): string {
|
||||
const matches = allToolNames.map((toolName) => ({
|
||||
name: toolName,
|
||||
distance: levenshtein.get(unknownToolName, toolName),
|
||||
}));
|
||||
|
||||
matches.sort((a, b) => a.distance - b.distance);
|
||||
|
||||
const topNResults = matches.slice(0, topN);
|
||||
|
||||
if (topNResults.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const suggestedNames = topNResults
|
||||
.map((match) => `"${match.name}"`)
|
||||
.join(', ');
|
||||
|
||||
if (topNResults.length > 1) {
|
||||
return ` Did you mean one of: ${suggestedNames}?`;
|
||||
} else {
|
||||
return ` Did you mean ${suggestedNames}?`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tool invocation matches any of a list of patterns.
|
||||
|
||||
Reference in New Issue
Block a user