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,
mapToDisplay,
} from './useReactToolScheduler.js';
import {
Part,
PartListUnion,
PartUnion,
FunctionResponse,
} from '@google/genai';
import { PartUnion, FunctionResponse } from '@google/genai';
import {
Config,
ToolCallRequestInfo,
@ -26,7 +21,6 @@ import {
ToolCallConfirmationDetails,
ToolConfirmationOutcome,
ToolCallResponseInfo,
formatLlmContentForFunctionResponse, // Import from core
ToolCall, // Import from core
Status as ToolCallStatusType,
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', () => {
let onComplete: Mock;
let setPendingHistoryItem: Mock;
@ -289,13 +169,13 @@ describe('useReactToolScheduler in YOLO Mode', () => {
request,
response: expect.objectContaining({
resultDisplay: 'YOLO Formatted tool output',
responseParts: expect.arrayContaining([
expect.objectContaining({
functionResponse: expect.objectContaining({
response: { output: expectedOutput },
}),
}),
]),
responseParts: {
functionResponse: {
id: 'yoloCall',
name: 'mockToolRequiresConfirmation',
response: { output: expectedOutput },
},
},
}),
}),
]);
@ -433,13 +313,13 @@ describe('useReactToolScheduler', () => {
request,
response: expect.objectContaining({
resultDisplay: 'Formatted tool output',
responseParts: expect.arrayContaining([
expect.objectContaining({
functionResponse: expect.objectContaining({
response: { output: 'Tool output' },
}),
}),
]),
responseParts: {
functionResponse: {
id: 'call1',
name: 'mockTool',
response: { output: 'Tool output' },
},
},
}),
}),
]);
@ -917,13 +797,13 @@ describe('useReactToolScheduler', () => {
request: requests[0],
response: expect.objectContaining({
resultDisplay: 'Display 1',
responseParts: expect.arrayContaining([
expect.objectContaining({
functionResponse: expect.objectContaining({
response: { output: 'Output 1' },
}),
}),
]),
responseParts: {
functionResponse: {
id: 'multi1',
name: 'tool1',
response: { output: 'Output 1' },
},
},
}),
});
expect(call2Result).toMatchObject({
@ -931,13 +811,13 @@ describe('useReactToolScheduler', () => {
request: requests[1],
response: expect.objectContaining({
resultDisplay: 'Display 2',
responseParts: expect.arrayContaining([
expect.objectContaining({
functionResponse: expect.objectContaining({
response: { output: 'Output 2' },
}),
}),
]),
responseParts: {
functionResponse: {
id: 'multi2',
name: 'tool2',
response: { output: 'Output 2' },
},
},
}),
});
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,
ApprovalMode,
} 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 = {
status: 'validating';
@ -96,51 +97,79 @@ export type ToolCallsUpdateHandler = (toolCalls: ToolCall[]) => void;
/**
* Formats tool output for a Gemini FunctionResponse.
*/
export function formatLlmContentForFunctionResponse(
llmContent: PartListUnion,
): {
functionResponseJson: Record<string, string>;
additionalParts: PartUnion[];
} {
const additionalParts: PartUnion[] = [];
let functionResponseJson: Record<string, string>;
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,
): PartListUnion {
const contentToProcess =
Array.isArray(llmContent) && llmContent.length === 1
? llmContent[0]
: llmContent;
if (typeof contentToProcess === 'string') {
functionResponseJson = { output: contentToProcess };
} else if (Array.isArray(contentToProcess)) {
functionResponseJson = {
status: 'Tool execution succeeded.',
};
additionalParts.push(...contentToProcess);
} else if (contentToProcess.inlineData || contentToProcess.fileData) {
return createFunctionResponsePart(callId, toolName, contentToProcess);
}
if (Array.isArray(contentToProcess)) {
const functionResponse = createFunctionResponsePart(
callId,
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 =
contentToProcess.inlineData?.mimeType ||
contentToProcess.fileData?.mimeType ||
'unknown';
functionResponseJson = {
status: `Binary content of type ${mimeType} was processed.`,
};
additionalParts.push(contentToProcess);
} else if (contentToProcess.text !== undefined) {
functionResponseJson = { output: contentToProcess.text };
} else if (contentToProcess.functionResponse) {
functionResponseJson = JSON.parse(
JSON.stringify(contentToProcess.functionResponse),
const functionResponse = createFunctionResponsePart(
callId,
toolName,
`Binary content of type ${mimeType} was processed.`,
);
} else {
functionResponseJson = { status: 'Tool execution succeeded.' };
additionalParts.push(contentToProcess);
return [functionResponse, contentToProcess];
}
return {
functionResponseJson,
additionalParts,
};
if (contentToProcess.text !== undefined) {
return createFunctionResponsePart(callId, toolName, contentToProcess.text);
}
// Default case for other kinds of parts.
return createFunctionResponsePart(
callId,
toolName,
'Tool execution succeeded.',
);
}
const createErrorResponse = (
@ -452,20 +481,15 @@ export class CoreToolScheduler {
return;
}
const { functionResponseJson, additionalParts } =
formatLlmContentForFunctionResponse(toolResult.llmContent);
const functionResponsePart: Part = {
functionResponse: {
name: toolName,
id: callId,
response: functionResponseJson,
},
};
const response = convertToFunctionResponse(
toolName,
callId,
toolResult.llmContent,
);
const successResponse: ToolCallResponseInfo = {
callId,
responseParts: [functionResponsePart, ...additionalParts],
responseParts: response,
resultDisplay: toolResult.returnDisplay,
error: undefined,
};

View File

@ -81,15 +81,13 @@ describe('executeToolCall', () => {
expect(response.callId).toBe('call1');
expect(response.error).toBeUndefined();
expect(response.resultDisplay).toBe('Success!');
expect(response.responseParts).toEqual([
{
functionResponse: {
name: 'testTool',
id: 'call1',
response: { output: 'Tool executed successfully' },
},
expect(response.responseParts).toEqual({
functionResponse: {
name: 'testTool',
id: 'call1',
response: { output: 'Tool executed successfully' },
},
]);
});
});
it('should return an error if tool is not found', async () => {
@ -225,7 +223,7 @@ describe('executeToolCall', () => {
name: 'testTool',
id: 'call5',
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
*/
import { Part } from '@google/genai';
import {
ToolCallRequestInfo,
ToolCallResponseInfo,
ToolRegistry,
ToolResult,
} from '../index.js';
import { formatLlmContentForFunctionResponse } from './coreToolScheduler.js';
import { convertToFunctionResponse } from './coreToolScheduler.js';
/**
* Executes a single tool call non-interactively.
@ -54,20 +53,15 @@ export async function executeToolCall(
// No live output callback for non-interactive mode
);
const { functionResponseJson, additionalParts } =
formatLlmContentForFunctionResponse(toolResult.llmContent);
const functionResponsePart: Part = {
functionResponse: {
name: toolCallRequest.name,
id: toolCallRequest.callId,
response: functionResponseJson,
},
};
const response = convertToFunctionResponse(
toolCallRequest.name,
toolCallRequest.callId,
toolResult.llmContent,
);
return {
callId: toolCallRequest.callId,
responseParts: [functionResponsePart, ...additionalParts],
responseParts: response,
resultDisplay: toolResult.returnDisplay,
error: undefined,
};

View File

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

View File

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