diff options
| author | N. Taylor Mullen <[email protected]> | 2025-06-01 16:11:37 -0700 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-06-01 23:11:37 +0000 |
| commit | 2828fc6d66cd7a74db231143183bd7c44e55148d (patch) | |
| tree | 849e95afc23b32e5f3745a5655fde1fe7e0c57d5 /packages/core/src | |
| parent | c51d6cc9d34bb3ff083f359cdd300502ea901ec8 (diff) | |
feat: Implement non-interactive mode for CLI (#675)
Diffstat (limited to 'packages/core/src')
| -rw-r--r-- | packages/core/src/core/nonInteractiveToolExecutor.test.ts | 235 | ||||
| -rw-r--r-- | packages/core/src/core/nonInteractiveToolExecutor.ts | 91 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 4 |
3 files changed, 330 insertions, 0 deletions
diff --git a/packages/core/src/core/nonInteractiveToolExecutor.test.ts b/packages/core/src/core/nonInteractiveToolExecutor.test.ts new file mode 100644 index 00000000..3d7dc1a2 --- /dev/null +++ b/packages/core/src/core/nonInteractiveToolExecutor.test.ts @@ -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, + ]); + }); +}); diff --git a/packages/core/src/core/nonInteractiveToolExecutor.ts b/packages/core/src/core/nonInteractiveToolExecutor.ts new file mode 100644 index 00000000..5b5c9a13 --- /dev/null +++ b/packages/core/src/core/nonInteractiveToolExecutor.ts @@ -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, + }; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f8c42336..bd28c864 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; |
