feat: Implement non-interactive mode for CLI (#675)

This commit is contained in:
N. Taylor Mullen
2025-06-01 16:11:37 -07:00
committed by GitHub
parent c51d6cc9d3
commit 2828fc6d66
6 changed files with 710 additions and 24 deletions

View File

@@ -0,0 +1,235 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { executeToolCall } from './nonInteractiveToolExecutor.js';
import {
ToolRegistry,
ToolCallRequestInfo,
ToolResult,
Tool,
ToolCallConfirmationDetails,
} from '../index.js';
import { Part, Type } from '@google/genai';
describe('executeToolCall', () => {
let mockToolRegistry: ToolRegistry;
let mockTool: Tool;
let abortController: AbortController;
beforeEach(() => {
mockTool = {
name: 'testTool',
displayName: 'Test Tool',
description: 'A tool for testing',
schema: {
name: 'testTool',
description: 'A tool for testing',
parameters: {
type: Type.OBJECT,
properties: {
param1: { type: Type.STRING },
},
required: ['param1'],
},
},
execute: vi.fn(),
validateToolParams: vi.fn(() => null),
shouldConfirmExecute: vi.fn(() =>
Promise.resolve(false as false | ToolCallConfirmationDetails),
),
isOutputMarkdown: false,
canUpdateOutput: false,
getDescription: vi.fn(),
};
mockToolRegistry = {
getTool: vi.fn(),
// Add other ToolRegistry methods if needed, or use a more complete mock
} as unknown as ToolRegistry;
abortController = new AbortController();
});
it('should execute a tool successfully', async () => {
const request: ToolCallRequestInfo = {
callId: 'call1',
name: 'testTool',
args: { param1: 'value1' },
};
const toolResult: ToolResult = {
llmContent: 'Tool executed successfully',
returnDisplay: 'Success!',
};
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
vi.mocked(mockTool.execute).mockResolvedValue(toolResult);
const response = await executeToolCall(
request,
mockToolRegistry,
abortController.signal,
);
expect(mockToolRegistry.getTool).toHaveBeenCalledWith('testTool');
expect(mockTool.execute).toHaveBeenCalledWith(
request.args,
abortController.signal,
);
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' },
},
},
]);
});
it('should return an error if tool is not found', async () => {
const request: ToolCallRequestInfo = {
callId: 'call2',
name: 'nonExistentTool',
args: {},
};
vi.mocked(mockToolRegistry.getTool).mockReturnValue(undefined);
const response = await executeToolCall(
request,
mockToolRegistry,
abortController.signal,
);
expect(response.callId).toBe('call2');
expect(response.error).toBeInstanceOf(Error);
expect(response.error?.message).toBe(
'Tool "nonExistentTool" not found in registry.',
);
expect(response.resultDisplay).toBe(
'Tool "nonExistentTool" not found in registry.',
);
expect(response.responseParts).toEqual([
{
functionResponse: {
name: 'nonExistentTool',
id: 'call2',
response: { error: 'Tool "nonExistentTool" not found in registry.' },
},
},
]);
});
it('should return an error if tool execution fails', async () => {
const request: ToolCallRequestInfo = {
callId: 'call3',
name: 'testTool',
args: { param1: 'value1' },
};
const executionError = new Error('Tool execution failed');
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
vi.mocked(mockTool.execute).mockRejectedValue(executionError);
const response = await executeToolCall(
request,
mockToolRegistry,
abortController.signal,
);
expect(response.callId).toBe('call3');
expect(response.error).toBe(executionError);
expect(response.resultDisplay).toBe('Tool execution failed');
expect(response.responseParts).toEqual([
{
functionResponse: {
name: 'testTool',
id: 'call3',
response: { error: 'Tool execution failed' },
},
},
]);
});
it('should handle cancellation during tool execution', async () => {
const request: ToolCallRequestInfo = {
callId: 'call4',
name: 'testTool',
args: { param1: 'value1' },
};
const cancellationError = new Error('Operation cancelled');
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
vi.mocked(mockTool.execute).mockImplementation(async (_args, signal) => {
if (signal?.aborted) {
return Promise.reject(cancellationError);
}
return new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () => {
reject(cancellationError);
});
// Simulate work that might happen if not aborted immediately
const timeoutId = setTimeout(
() =>
reject(
new Error('Should have been cancelled if not aborted prior'),
),
100,
);
signal?.addEventListener('abort', () => clearTimeout(timeoutId));
});
});
abortController.abort(); // Abort before calling
const response = await executeToolCall(
request,
mockToolRegistry,
abortController.signal,
);
expect(response.callId).toBe('call4');
expect(response.error?.message).toBe(cancellationError.message);
expect(response.resultDisplay).toBe('Operation cancelled');
});
it('should correctly format llmContent with inlineData', async () => {
const request: ToolCallRequestInfo = {
callId: 'call5',
name: 'testTool',
args: {},
};
const imageDataPart: Part = {
inlineData: { mimeType: 'image/png', data: 'base64data' },
};
const toolResult: ToolResult = {
llmContent: [imageDataPart],
returnDisplay: 'Image processed',
};
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockTool);
vi.mocked(mockTool.execute).mockResolvedValue(toolResult);
const response = await executeToolCall(
request,
mockToolRegistry,
abortController.signal,
);
expect(response.resultDisplay).toBe('Image processed');
expect(response.responseParts).toEqual([
{
functionResponse: {
name: 'testTool',
id: 'call5',
response: {
status: 'Binary content of type image/png was processed.',
},
},
},
imageDataPart,
]);
});
});

View File

@@ -0,0 +1,91 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Part } from '@google/genai';
import {
ToolCallRequestInfo,
ToolCallResponseInfo,
ToolRegistry,
ToolResult,
} from '../index.js';
import { formatLlmContentForFunctionResponse } from './coreToolScheduler.js';
/**
* Executes a single tool call non-interactively.
* It does not handle confirmations, multiple calls, or live updates.
*/
export async function executeToolCall(
toolCallRequest: ToolCallRequestInfo,
toolRegistry: ToolRegistry,
abortSignal?: AbortSignal,
): Promise<ToolCallResponseInfo> {
const tool = toolRegistry.getTool(toolCallRequest.name);
if (!tool) {
const error = new Error(
`Tool "${toolCallRequest.name}" not found in registry.`,
);
// Ensure the response structure matches what the API expects for an error
return {
callId: toolCallRequest.callId,
responseParts: [
{
functionResponse: {
id: toolCallRequest.callId,
name: toolCallRequest.name,
response: { error: error.message },
},
},
],
resultDisplay: error.message,
error,
};
}
try {
// Directly execute without confirmation or live output handling
const effectiveAbortSignal = abortSignal ?? new AbortController().signal;
const toolResult: ToolResult = await tool.execute(
toolCallRequest.args,
effectiveAbortSignal,
// 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,
},
};
return {
callId: toolCallRequest.callId,
responseParts: [functionResponsePart, ...additionalParts],
resultDisplay: toolResult.returnDisplay,
error: undefined,
};
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e));
return {
callId: toolCallRequest.callId,
responseParts: [
{
functionResponse: {
id: toolCallRequest.callId,
name: toolCallRequest.name,
response: { error: error.message },
},
},
],
resultDisplay: error.message,
error,
};
}
}

View File

@@ -14,6 +14,7 @@ export * from './core/prompts.js';
export * from './core/turn.js';
export * from './core/geminiRequest.js';
export * from './core/coreToolScheduler.js';
export * from './core/nonInteractiveToolExecutor.js';
// Export utilities
export * from './utils/paths.js';
@@ -35,3 +36,6 @@ export * from './tools/edit.js';
export * from './tools/write-file.js';
export * from './tools/web-fetch.js';
export * from './tools/memoryTool.js';
export * from './tools/shell.js';
export * from './tools/web-search.js';
export * from './tools/read-many-files.js';