refactor(core): Centralize tool response formatting (#743)

This commit is contained in:
N. Taylor Mullen 2025-06-04 00:24:25 -07:00 committed by GitHub
parent 4b2af10b04
commit d179b3aae4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 300 additions and 222 deletions

View File

@ -11,12 +11,7 @@ import {
useReactToolScheduler, useReactToolScheduler,
mapToDisplay, mapToDisplay,
} from './useReactToolScheduler.js'; } from './useReactToolScheduler.js';
import { import { PartUnion, FunctionResponse } from '@google/genai';
Part,
PartListUnion,
PartUnion,
FunctionResponse,
} from '@google/genai';
import { import {
Config, Config,
ToolCallRequestInfo, ToolCallRequestInfo,
@ -26,7 +21,6 @@ import {
ToolCallConfirmationDetails, ToolCallConfirmationDetails,
ToolConfirmationOutcome, ToolConfirmationOutcome,
ToolCallResponseInfo, ToolCallResponseInfo,
formatLlmContentForFunctionResponse, // Import from core
ToolCall, // Import from core ToolCall, // Import from core
Status as ToolCallStatusType, Status as ToolCallStatusType,
ApprovalMode, // Import from core ApprovalMode, // Import from core
@ -93,120 +87,6 @@ const mockToolRequiresConfirmation: Tool = {
), ),
}; };
describe('formatLlmContentForFunctionResponse', () => {
it('should handle simple string llmContent', () => {
const llmContent = 'Simple text output';
const { functionResponseJson, additionalParts } =
formatLlmContentForFunctionResponse(llmContent);
expect(functionResponseJson).toEqual({ output: 'Simple text output' });
expect(additionalParts).toEqual([]);
});
it('should handle llmContent as a single Part with text', () => {
const llmContent: Part = { text: 'Text from Part object' };
const { functionResponseJson, additionalParts } =
formatLlmContentForFunctionResponse(llmContent);
expect(functionResponseJson).toEqual({ output: 'Text from Part object' });
expect(additionalParts).toEqual([]);
});
it('should handle llmContent as a PartListUnion array with a single text Part', () => {
const llmContent: PartListUnion = [{ text: 'Text from array' }];
const { functionResponseJson, additionalParts } =
formatLlmContentForFunctionResponse(llmContent);
expect(functionResponseJson).toEqual({ output: 'Text from array' });
expect(additionalParts).toEqual([]);
});
it('should handle llmContent with inlineData', () => {
const llmContent: Part = {
inlineData: { mimeType: 'image/png', data: 'base64...' },
};
const { functionResponseJson, additionalParts } =
formatLlmContentForFunctionResponse(llmContent);
expect(functionResponseJson).toEqual({
status: 'Binary content of type image/png was processed.',
});
expect(additionalParts).toEqual([llmContent]);
});
it('should handle llmContent with fileData', () => {
const llmContent: Part = {
fileData: { mimeType: 'application/pdf', fileUri: 'gs://...' },
};
const { functionResponseJson, additionalParts } =
formatLlmContentForFunctionResponse(llmContent);
expect(functionResponseJson).toEqual({
status: 'Binary content of type application/pdf was processed.',
});
expect(additionalParts).toEqual([llmContent]);
});
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 { functionResponseJson, additionalParts } =
formatLlmContentForFunctionResponse(llmContent);
expect(functionResponseJson).toEqual({
status: 'Tool execution succeeded.',
});
expect(additionalParts).toEqual(llmContent);
});
it('should handle llmContent as an array with a single inlineData Part', () => {
const llmContent: PartListUnion = [
{ inlineData: { mimeType: 'image/gif', data: 'gifdata...' } },
];
const { functionResponseJson, additionalParts } =
formatLlmContentForFunctionResponse(llmContent);
expect(functionResponseJson).toEqual({
status: 'Binary content of type image/gif was processed.',
});
expect(additionalParts).toEqual(llmContent);
});
it('should handle llmContent as a generic Part (not text, inlineData, or fileData)', () => {
const llmContent: Part = { functionCall: { name: 'test', args: {} } };
const { functionResponseJson, additionalParts } =
formatLlmContentForFunctionResponse(llmContent);
expect(functionResponseJson).toEqual({
status: 'Tool execution succeeded.',
});
expect(additionalParts).toEqual([llmContent]);
});
it('should handle empty string llmContent', () => {
const llmContent = '';
const { functionResponseJson, additionalParts } =
formatLlmContentForFunctionResponse(llmContent);
expect(functionResponseJson).toEqual({ output: '' });
expect(additionalParts).toEqual([]);
});
it('should handle llmContent as an empty array', () => {
const llmContent: PartListUnion = [];
const { functionResponseJson, additionalParts } =
formatLlmContentForFunctionResponse(llmContent);
expect(functionResponseJson).toEqual({
status: 'Tool execution succeeded.',
});
expect(additionalParts).toEqual([]);
});
it('should handle llmContent as a Part with undefined inlineData/fileData/text', () => {
const llmContent: Part = {}; // An empty part object
const { functionResponseJson, additionalParts } =
formatLlmContentForFunctionResponse(llmContent);
expect(functionResponseJson).toEqual({
status: 'Tool execution succeeded.',
});
expect(additionalParts).toEqual([llmContent]);
});
});
describe('useReactToolScheduler in YOLO Mode', () => { describe('useReactToolScheduler in YOLO Mode', () => {
let onComplete: Mock; let onComplete: Mock;
let setPendingHistoryItem: Mock; let setPendingHistoryItem: Mock;
@ -289,13 +169,13 @@ describe('useReactToolScheduler in YOLO Mode', () => {
request, request,
response: expect.objectContaining({ response: expect.objectContaining({
resultDisplay: 'YOLO Formatted tool output', resultDisplay: 'YOLO Formatted tool output',
responseParts: expect.arrayContaining([ responseParts: {
expect.objectContaining({ functionResponse: {
functionResponse: expect.objectContaining({ id: 'yoloCall',
response: { output: expectedOutput }, name: 'mockToolRequiresConfirmation',
}), response: { output: expectedOutput },
}), },
]), },
}), }),
}), }),
]); ]);
@ -433,13 +313,13 @@ describe('useReactToolScheduler', () => {
request, request,
response: expect.objectContaining({ response: expect.objectContaining({
resultDisplay: 'Formatted tool output', resultDisplay: 'Formatted tool output',
responseParts: expect.arrayContaining([ responseParts: {
expect.objectContaining({ functionResponse: {
functionResponse: expect.objectContaining({ id: 'call1',
response: { output: 'Tool output' }, name: 'mockTool',
}), response: { output: 'Tool output' },
}), },
]), },
}), }),
}), }),
]); ]);
@ -917,13 +797,13 @@ describe('useReactToolScheduler', () => {
request: requests[0], request: requests[0],
response: expect.objectContaining({ response: expect.objectContaining({
resultDisplay: 'Display 1', resultDisplay: 'Display 1',
responseParts: expect.arrayContaining([ responseParts: {
expect.objectContaining({ functionResponse: {
functionResponse: expect.objectContaining({ id: 'multi1',
response: { output: 'Output 1' }, name: 'tool1',
}), response: { output: 'Output 1' },
}), },
]), },
}), }),
}); });
expect(call2Result).toMatchObject({ expect(call2Result).toMatchObject({
@ -931,13 +811,13 @@ describe('useReactToolScheduler', () => {
request: requests[1], request: requests[1],
response: expect.objectContaining({ response: expect.objectContaining({
resultDisplay: 'Display 2', resultDisplay: 'Display 2',
responseParts: expect.arrayContaining([ responseParts: {
expect.objectContaining({ functionResponse: {
functionResponse: expect.objectContaining({ id: 'multi2',
response: { output: 'Output 2' }, name: 'tool2',
}), response: { output: 'Output 2' },
}), },
]), },
}), }),
}); });
expect(result.current[0]).toEqual([]); expect(result.current[0]).toEqual([]);

View File

@ -0,0 +1,176 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { convertToFunctionResponse } from './coreToolScheduler.js';
import { Part, PartListUnion } from '@google/genai';
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);
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);
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);
expect(result).toEqual({
functionResponse: {
name: toolName,
id: callId,
response: { output: 'Text from array' },
},
});
});
it('should handle llmContent with inlineData', () => {
const llmContent: Part = {
inlineData: { mimeType: 'image/png', data: 'base64...' },
};
const result = convertToFunctionResponse(toolName, callId, llmContent);
expect(result).toEqual([
{
functionResponse: {
name: toolName,
id: callId,
response: {
output: 'Binary content of type image/png was processed.',
},
},
},
llmContent,
]);
});
it('should handle llmContent with fileData', () => {
const llmContent: Part = {
fileData: { mimeType: 'application/pdf', fileUri: 'gs://...' },
};
const result = convertToFunctionResponse(toolName, callId, llmContent);
expect(result).toEqual([
{
functionResponse: {
name: toolName,
id: callId,
response: {
output: 'Binary content of type application/pdf was processed.',
},
},
},
llmContent,
]);
});
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);
expect(result).toEqual([
{
functionResponse: {
name: toolName,
id: callId,
response: { output: 'Tool execution succeeded.' },
},
},
...llmContent,
]);
});
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);
expect(result).toEqual([
{
functionResponse: {
name: toolName,
id: callId,
response: {
output: 'Binary content of type image/gif was processed.',
},
},
},
...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);
expect(result).toEqual({
functionResponse: {
name: toolName,
id: callId,
response: { output: 'Tool execution succeeded.' },
},
});
});
it('should handle empty string llmContent', () => {
const llmContent = '';
const result = convertToFunctionResponse(toolName, callId, llmContent);
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);
expect(result).toEqual([
{
functionResponse: {
name: toolName,
id: callId,
response: { output: 'Tool execution succeeded.' },
},
},
]);
});
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);
expect(result).toEqual({
functionResponse: {
name: toolName,
id: callId,
response: { output: 'Tool execution succeeded.' },
},
});
});
});

View File

@ -14,7 +14,8 @@ import {
ToolRegistry, ToolRegistry,
ApprovalMode, ApprovalMode,
} from '../index.js'; } from '../index.js';
import { Part, PartUnion, PartListUnion } from '@google/genai'; import { Part, PartListUnion } from '@google/genai';
import { getResponseTextFromParts } from '../utils/generateContentResponseUtilities.js';
export type ValidatingToolCall = { export type ValidatingToolCall = {
status: 'validating'; status: 'validating';
@ -96,51 +97,79 @@ export type ToolCallsUpdateHandler = (toolCalls: ToolCall[]) => void;
/** /**
* Formats tool output for a Gemini FunctionResponse. * Formats tool output for a Gemini FunctionResponse.
*/ */
export function formatLlmContentForFunctionResponse( function createFunctionResponsePart(
llmContent: PartListUnion, callId: string,
): { toolName: string,
functionResponseJson: Record<string, string>; output: string,
additionalParts: PartUnion[]; ): Part {
} { return {
const additionalParts: PartUnion[] = []; functionResponse: {
let functionResponseJson: Record<string, string>; id: callId,
name: toolName,
response: { output },
},
};
}
export function convertToFunctionResponse(
toolName: string,
callId: string,
llmContent: PartListUnion,
): PartListUnion {
const contentToProcess = const contentToProcess =
Array.isArray(llmContent) && llmContent.length === 1 Array.isArray(llmContent) && llmContent.length === 1
? llmContent[0] ? llmContent[0]
: llmContent; : llmContent;
if (typeof contentToProcess === 'string') { if (typeof contentToProcess === 'string') {
functionResponseJson = { output: contentToProcess }; return createFunctionResponsePart(callId, toolName, contentToProcess);
} else if (Array.isArray(contentToProcess)) { }
functionResponseJson = {
status: 'Tool execution succeeded.', if (Array.isArray(contentToProcess)) {
}; const functionResponse = createFunctionResponsePart(
additionalParts.push(...contentToProcess); callId,
} else if (contentToProcess.inlineData || contentToProcess.fileData) { toolName,
'Tool execution succeeded.',
);
return [functionResponse, ...contentToProcess];
}
// After this point, contentToProcess is a single Part object.
if (contentToProcess.functionResponse) {
if (contentToProcess.functionResponse.response?.content) {
const stringifiedOutput =
getResponseTextFromParts(
contentToProcess.functionResponse.response.content as Part[],
) || '';
return createFunctionResponsePart(callId, toolName, stringifiedOutput);
}
// It's a functionResponse that we should pass through as is.
return contentToProcess;
}
if (contentToProcess.inlineData || contentToProcess.fileData) {
const mimeType = const mimeType =
contentToProcess.inlineData?.mimeType || contentToProcess.inlineData?.mimeType ||
contentToProcess.fileData?.mimeType || contentToProcess.fileData?.mimeType ||
'unknown'; 'unknown';
functionResponseJson = { const functionResponse = createFunctionResponsePart(
status: `Binary content of type ${mimeType} was processed.`, callId,
}; toolName,
additionalParts.push(contentToProcess); `Binary content of type ${mimeType} was processed.`,
} else if (contentToProcess.text !== undefined) {
functionResponseJson = { output: contentToProcess.text };
} else if (contentToProcess.functionResponse) {
functionResponseJson = JSON.parse(
JSON.stringify(contentToProcess.functionResponse),
); );
} else { return [functionResponse, contentToProcess];
functionResponseJson = { status: 'Tool execution succeeded.' };
additionalParts.push(contentToProcess);
} }
return { if (contentToProcess.text !== undefined) {
functionResponseJson, return createFunctionResponsePart(callId, toolName, contentToProcess.text);
additionalParts, }
};
// Default case for other kinds of parts.
return createFunctionResponsePart(
callId,
toolName,
'Tool execution succeeded.',
);
} }
const createErrorResponse = ( const createErrorResponse = (
@ -452,20 +481,15 @@ export class CoreToolScheduler {
return; return;
} }
const { functionResponseJson, additionalParts } = const response = convertToFunctionResponse(
formatLlmContentForFunctionResponse(toolResult.llmContent); toolName,
callId,
const functionResponsePart: Part = { toolResult.llmContent,
functionResponse: { );
name: toolName,
id: callId,
response: functionResponseJson,
},
};
const successResponse: ToolCallResponseInfo = { const successResponse: ToolCallResponseInfo = {
callId, callId,
responseParts: [functionResponsePart, ...additionalParts], responseParts: response,
resultDisplay: toolResult.returnDisplay, resultDisplay: toolResult.returnDisplay,
error: undefined, error: undefined,
}; };

View File

@ -81,15 +81,13 @@ describe('executeToolCall', () => {
expect(response.callId).toBe('call1'); expect(response.callId).toBe('call1');
expect(response.error).toBeUndefined(); expect(response.error).toBeUndefined();
expect(response.resultDisplay).toBe('Success!'); expect(response.resultDisplay).toBe('Success!');
expect(response.responseParts).toEqual([ expect(response.responseParts).toEqual({
{ functionResponse: {
functionResponse: { name: 'testTool',
name: 'testTool', id: 'call1',
id: 'call1', response: { output: 'Tool executed successfully' },
response: { output: 'Tool executed successfully' },
},
}, },
]); });
}); });
it('should return an error if tool is not found', async () => { it('should return an error if tool is not found', async () => {
@ -225,7 +223,7 @@ describe('executeToolCall', () => {
name: 'testTool', name: 'testTool',
id: 'call5', id: 'call5',
response: { response: {
status: 'Binary content of type image/png was processed.', output: 'Binary content of type image/png was processed.',
}, },
}, },
}, },

View File

@ -4,14 +4,13 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { Part } from '@google/genai';
import { import {
ToolCallRequestInfo, ToolCallRequestInfo,
ToolCallResponseInfo, ToolCallResponseInfo,
ToolRegistry, ToolRegistry,
ToolResult, ToolResult,
} from '../index.js'; } from '../index.js';
import { formatLlmContentForFunctionResponse } from './coreToolScheduler.js'; import { convertToFunctionResponse } from './coreToolScheduler.js';
/** /**
* Executes a single tool call non-interactively. * Executes a single tool call non-interactively.
@ -54,20 +53,15 @@ export async function executeToolCall(
// No live output callback for non-interactive mode // No live output callback for non-interactive mode
); );
const { functionResponseJson, additionalParts } = const response = convertToFunctionResponse(
formatLlmContentForFunctionResponse(toolResult.llmContent); toolCallRequest.name,
toolCallRequest.callId,
const functionResponsePart: Part = { toolResult.llmContent,
functionResponse: { );
name: toolCallRequest.name,
id: toolCallRequest.callId,
response: functionResponseJson,
},
};
return { return {
callId: toolCallRequest.callId, callId: toolCallRequest.callId,
responseParts: [functionResponsePart, ...additionalParts], responseParts: response,
resultDisplay: toolResult.returnDisplay, resultDisplay: toolResult.returnDisplay,
error: undefined, error: undefined,
}; };

View File

@ -138,12 +138,7 @@ describe('DiscoveredMCPTool', () => {
const stringifiedResponseContent = JSON.stringify( const stringifiedResponseContent = JSON.stringify(
mockToolSuccessResultObject, mockToolSuccessResultObject,
); );
// getStringifiedResultForDisplay joins text parts, then wraps the array of processed parts in JSON expect(toolResult.returnDisplay).toBe(stringifiedResponseContent);
const expectedDisplayOutput =
'```json\n' +
JSON.stringify([stringifiedResponseContent], null, 2) +
'\n```';
expect(toolResult.returnDisplay).toBe(expectedDisplayOutput);
}); });
it('should handle empty result from getStringifiedResultForDisplay', async () => { it('should handle empty result from getStringifiedResultForDisplay', async () => {

View File

@ -149,6 +149,13 @@ function getStringifiedResultForDisplay(result: Part[]) {
return part; // Fallback for unexpected structure or non-FunctionResponsePart return part; // Fallback for unexpected structure or non-FunctionResponsePart
}; };
const processedResults = result.map(processFunctionResponse); const processedResults =
result.length === 1
? processFunctionResponse(result[0])
: result.map(processFunctionResponse);
if (typeof processedResults === 'string') {
return processedResults;
}
return '```json\n' + JSON.stringify(processedResults, null, 2) + '\n```'; return '```json\n' + JSON.stringify(processedResults, null, 2) + '\n```';
} }

View File

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { GenerateContentResponse } from '@google/genai'; import { GenerateContentResponse, Part } from '@google/genai';
export function getResponseText( export function getResponseText(
response: GenerateContentResponse, response: GenerateContentResponse,
@ -15,3 +15,7 @@ export function getResponseText(
.join('') || undefined .join('') || undefined
); );
} }
export function getResponseTextFromParts(parts: Part[]): string | undefined {
return parts?.map((part) => part.text).join('') || undefined;
}