diff --git a/packages/toolbox-core/package-lock.json b/packages/toolbox-core/package-lock.json index 6cf4e61..4c6b251 100644 --- a/packages/toolbox-core/package-lock.json +++ b/packages/toolbox-core/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { + "axios": "^1.9.0", "zod": "^3.24.4" }, "devDependencies": { @@ -1701,6 +1702,23 @@ "dev": true, "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", + "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -1931,6 +1949,19 @@ "semver": "^7.0.0" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2123,6 +2154,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2280,6 +2323,15 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -2326,6 +2378,20 @@ "node": ">=6.0.0" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -2379,6 +2445,51 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3049,6 +3160,41 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3075,7 +3221,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3101,6 +3246,30 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -3111,6 +3280,19 @@ "node": ">=8.0.0" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -3180,6 +3362,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3247,11 +3441,37 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4451,6 +4671,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/meow": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz", @@ -4522,6 +4751,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -5016,6 +5266,12 @@ "node": ">= 6" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/packages/toolbox-core/package.json b/packages/toolbox-core/package.json index bb118fa..637b56a 100644 --- a/packages/toolbox-core/package.json +++ b/packages/toolbox-core/package.json @@ -45,6 +45,7 @@ "typescript": "^5.8.3" }, "dependencies": { + "axios": "^1.9.0", "zod": "^3.24.4" } } diff --git a/packages/toolbox-core/src/toolbox_core/client.ts b/packages/toolbox-core/src/toolbox_core/client.ts index 63d5845..615512d 100644 --- a/packages/toolbox-core/src/toolbox_core/client.ts +++ b/packages/toolbox-core/src/toolbox_core/client.ts @@ -13,26 +13,82 @@ // limitations under the License. import {ToolboxTool} from './tool'; +import axios from 'axios'; +import {type AxiosInstance, type AxiosResponse} from 'axios'; +import {ZodManifestSchema, createZodSchemaFromParams} from './protocol'; +import {logApiError} from './errorUtils'; +/** + * An asynchronous client for interacting with a Toolbox service. + * Manages an Axios Client Session, if not provided. + */ class ToolboxClient { - /** @private */ _baseUrl; + private _baseUrl: string; + private _session: AxiosInstance; /** - * @param {string} url - The base URL for the Toolbox service API. + * Initializes the ToolboxClient. + * @param {string} url - The base URL for the Toolbox service API (e.g., "http://localhost:5000"). + * @param {AxiosInstance} [session] - Optional Axios instance for making HTTP + * requests. If not provided, a new one will be created. */ - constructor(url: string) { + constructor(url: string, session?: AxiosInstance) { this._baseUrl = url; + this._session = session || axios.create({baseURL: url}); } /** - * @param {int} num1 - First number. - * @param {int} num2 - Second number. - * @returns {int} - Mock API response. + * Asynchronously loads a tool from the server. + * Retrieves the schema for the specified tool from the Toolbox server and + * returns a callable (`ToolboxTool`) that can be used to invoke the + * tool remotely. + * + * @param {string} name - The unique name or identifier of the tool to load. + * @returns {Promise>} A promise that resolves + * to a ToolboxTool function, ready for execution. + * @throws {Error} If the tool is not found in the manifest, the manifest structure is invalid, + * or if there's an error fetching data from the API. */ - async getToolResponse(num1: number, num2: number) { - const tool = ToolboxTool('tool1'); - const response = await tool({a: num1, b: num2}); - return response; + async loadTool(name: string): Promise> { + const url = `${this._baseUrl}/api/tool/${name}`; + try { + const response: AxiosResponse = await this._session.get(url); + const responseData = response.data; + + try { + const manifest = ZodManifestSchema.parse(responseData); + if ( + manifest.tools && + Object.prototype.hasOwnProperty.call(manifest.tools, name) + ) { + const specificToolSchema = manifest.tools[name]; + const paramZodSchema = createZodSchemaFromParams( + specificToolSchema.parameters + ); + return ToolboxTool( + this._session, + this._baseUrl, + name, + specificToolSchema.description, + paramZodSchema + ); + } else { + throw new Error(`Tool "${name}" not found in manifest.`); + } + } catch (validationError) { + if (validationError instanceof Error) { + throw new Error( + `Invalid manifest structure received: ${validationError.message}` + ); + } + throw new Error( + 'Invalid manifest structure received: Unknown validation error.' + ); + } + } catch (error) { + logApiError(`Error fetching data from ${url}:`, error); + throw error; + } } } diff --git a/packages/toolbox-core/src/toolbox_core/errorUtils.ts b/packages/toolbox-core/src/toolbox_core/errorUtils.ts new file mode 100644 index 0000000..e27f90f --- /dev/null +++ b/packages/toolbox-core/src/toolbox_core/errorUtils.ts @@ -0,0 +1,42 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {isAxiosError} from 'axios'; + +/** + * Logs a standardized error message to the console, differentiating between + * Axios errors with response data, Axios errors without response data (e.g., network errors), + * and other types of errors. + * + * @param {string} baseMessage - The base message to log, e.g., "Error fetching data from". + * @param {unknown} error - The error object caught. + */ +export function logApiError(baseMessage: string, error: unknown): void { + let loggableDetails: unknown; + + if (isAxiosError(error)) { + // Check if the error is from Axios and has response data + if (error.response && typeof error.response.data !== 'undefined') { + loggableDetails = error.response.data; + } else { + // Axios error without response data (e.g., network error, timeout) + loggableDetails = error.message; + } + } else if (error instanceof Error) { + loggableDetails = error.message; + } else { + loggableDetails = error; // Fallback for non-Error types or unknown errors + } + console.error(baseMessage, loggableDetails); +} diff --git a/packages/toolbox-core/src/toolbox_core/protocol.ts b/packages/toolbox-core/src/toolbox_core/protocol.ts index f6e13f8..80e6d21 100644 --- a/packages/toolbox-core/src/toolbox_core/protocol.ts +++ b/packages/toolbox-core/src/toolbox_core/protocol.ts @@ -93,6 +93,8 @@ export const ZodManifestSchema = z.object({ ), }); +export type ZodManifest = z.infer; + /** * Recursively builds a Zod schema for a single parameter based on its TypeScript definition. * @param param The ParameterSchema (TypeScript type) to convert. diff --git a/packages/toolbox-core/src/toolbox_core/tool.ts b/packages/toolbox-core/src/toolbox_core/tool.ts index 2cc72d6..d2ae50e 100644 --- a/packages/toolbox-core/src/toolbox_core/tool.ts +++ b/packages/toolbox-core/src/toolbox_core/tool.ts @@ -12,21 +12,74 @@ // See the License for the specific language governing permissions and // limitations under the License. -function ToolboxTool(name: string) { - const callable = async function (action: {a: number; b: number}) { - // MOCK API CALL - async function api_resp(a: number, b: number): Promise { - await new Promise(resolve => setTimeout(resolve, 10)); - return a + 2 * b; - } +import {ZodObject, ZodError, ZodRawShape} from 'zod'; +import {AxiosInstance, AxiosResponse} from 'axios'; +import {logApiError} from './errorUtils'; + +/** + * Creates a callable tool function representing a specific tool on a remote + * Toolbox server. + * + * @param {AxiosInstance} session - The Axios session for making HTTP requests. + * @param {string} baseUrl - The base URL of the Toolbox Server API. + * @param {string} name - The name of the remote tool. + * @param {string} description - A description of the remote tool. + * @param {ZodObject} paramSchema - The Zod schema for validating the tool's parameters. + * @returns {CallableTool & CallableToolProperties} An async function that, when + * called, invokes the tool with the provided arguments. Validates arguments + * against the tool's signature, then sends them + * as a JSON payload in a POST request to the tool's invoke URL. + */ - const result = await api_resp(action.a, action.b); - return result; +function ToolboxTool( + session: AxiosInstance, + baseUrl: string, + name: string, + description: string, + paramSchema: ZodObject +) { + const toolUrl = `${baseUrl}/api/tool/${name}/invoke`; + + const callable = async function ( + callArguments: Record = {} + ) { + let validatedPayload: Record; + try { + validatedPayload = paramSchema.parse(callArguments); + } catch (error) { + if (error instanceof ZodError) { + const errorMessages = error.errors.map( + e => `${e.path.join('.') || 'payload'}: ${e.message}` + ); + throw new Error( + `Argument validation failed for tool "${name}":\n - ${errorMessages.join('\n - ')}` + ); + } + throw new Error(`Argument validation failed: ${String(error)}`); + } + try { + const response: AxiosResponse = await session.post( + toolUrl, + validatedPayload + ); + return response.data; + } catch (error) { + logApiError(`Error posting data to ${toolUrl}:`, error); + throw error; + } }; callable.toolName = name; + callable.description = description; + callable.params = paramSchema; callable.getName = function () { return this.toolName; }; + callable.getDescription = function () { + return this.description; + }; + callable.getParamSchema = function () { + return this.params; + }; return callable; } diff --git a/packages/toolbox-core/test/test.client.ts b/packages/toolbox-core/test/test.client.ts index 6efa736..611d841 100644 --- a/packages/toolbox-core/test/test.client.ts +++ b/packages/toolbox-core/test/test.client.ts @@ -13,12 +13,267 @@ // limitations under the License. import {ToolboxClient} from '../src/toolbox_core/client'; +import {ToolboxTool} from '../src/toolbox_core/tool'; +import { + ZodManifestSchema, + createZodSchemaFromParams, + type ZodManifest, +} from '../src/toolbox_core/protocol'; +import axios, {AxiosInstance, AxiosResponse} from 'axios'; +import {ZodRawShape, ZodObject, ZodTypeAny} from 'zod'; -const client = new ToolboxClient('https://some_base_url'); +// --- Helper Types --- +type OriginalToolboxToolType = + typeof import('../src/toolbox_core/tool').ToolboxTool; -describe('getToolResponse', () => { - test('Should return a specific value based on inputs', async () => { - const response = await client.getToolResponse(3, 4); - expect(response).toBe(11); +type CallableToolReturnedByFactory = ReturnType; + +const createMockZodObject = ( + shape: ZodRawShape = {} +): ZodObject => + ({ + parse: jest.fn(args => args), // Simple pass-through + safeParse: jest.fn(args => ({success: true, data: args})), + _def: { + typeName: 'ZodObject', + shape: () => shape, + }, + shape: shape, + pick: jest.fn().mockReturnThis(), + omit: jest.fn().mockReturnThis(), + extend: jest.fn().mockReturnThis(), + }) as unknown as ZodObject; + +// --- Mocking External Dependencies --- +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +jest.mock('../src/toolbox_core/tool', () => ({ + ToolboxTool: jest.fn(), +})); + +const MockedToolboxToolFactory = + ToolboxTool as jest.MockedFunction; + +jest.mock('../src/toolbox_core/protocol', () => ({ + ZodManifestSchema: { + parse: jest.fn(), + }, + createZodSchemaFromParams: jest.fn(), +})); +const MockedZodManifestSchema = ZodManifestSchema as jest.Mocked< + typeof ZodManifestSchema +>; +const MockedCreateZodSchemaFromParams = + createZodSchemaFromParams as jest.MockedFunction< + typeof createZodSchemaFromParams + >; + +describe('ToolboxClient', () => { + const testBaseUrl = 'http://api.example.com'; + let consoleErrorSpy: jest.SpyInstance; + let mockSessionGet: jest.Mock; + + beforeEach(() => { + jest.resetAllMocks(); + + mockSessionGet = jest.fn(); + mockedAxios.create.mockReturnValue({ + get: mockSessionGet, + } as unknown as AxiosInstance); + + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + describe('constructor', () => { + it('should set baseUrl and create a new session if one is not provided', () => { + const client = new ToolboxClient(testBaseUrl); + + expect(client['_baseUrl']).toBe(testBaseUrl); + expect(mockedAxios.create).toHaveBeenCalledTimes(1); + expect(mockedAxios.create).toHaveBeenCalledWith({baseURL: testBaseUrl}); + expect(client['_session'].get).toBe(mockSessionGet); + }); + + it('should set baseUrl and use the provided session if one is given', () => { + const customMockSession = { + get: mockSessionGet, + } as unknown as AxiosInstance; + const client = new ToolboxClient(testBaseUrl, customMockSession); + + expect(client['_baseUrl']).toBe(testBaseUrl); + expect(client['_session']).toBe(customMockSession); + expect(mockedAxios.create).not.toHaveBeenCalled(); + }); + }); + + describe('loadTool', () => { + const toolName = 'calculator'; + const expectedApiUrl = `${testBaseUrl}/api/tool/${toolName}`; + let client: ToolboxClient; + + beforeEach(() => { + client = new ToolboxClient(testBaseUrl); + }); + + const setupMocksForSuccessfulLoad = ( + toolDefinition: { + description: string; + parameters: {name: string; type: string; description: string}[]; + }, + overrides: { + manifestData?: Partial; + zodParamsSchema?: ZodObject; + toolInstance?: Partial; + } = {} + ) => { + const manifestData: ZodManifest = { + serverVersion: '1.0.0', + tools: {[toolName]: toolDefinition}, + ...overrides.manifestData, + } as ZodManifest; + + const zodParamsSchema = + overrides.zodParamsSchema || + createMockZodObject( + toolDefinition.parameters.reduce( + (shapeAccumulator: ZodRawShape, param) => { + shapeAccumulator[param.name] = { + _def: {typeName: 'ZodString'}, + } as unknown as ZodTypeAny; + return shapeAccumulator; + }, + {} as ZodRawShape + ) + ); + + const defaultMockCallable = jest + .fn() + .mockResolvedValue({result: 'mock tool execution'}); + const defaultToolInstance: CallableToolReturnedByFactory = Object.assign( + defaultMockCallable, + { + toolName: toolName, + description: toolDefinition.description, + params: zodParamsSchema, + getName: jest.fn().mockReturnValue(toolName), + getDescription: jest.fn().mockReturnValue(toolDefinition.description), + getParamSchema: jest.fn().mockReturnValue(zodParamsSchema), + } + ); + + const toolInstance = overrides.toolInstance + ? {...defaultToolInstance, ...overrides.toolInstance} + : defaultToolInstance; + + mockSessionGet.mockResolvedValueOnce({ + data: manifestData, + } as AxiosResponse); + MockedZodManifestSchema.parse.mockReturnValueOnce(manifestData); + MockedCreateZodSchemaFromParams.mockReturnValueOnce(zodParamsSchema); + + MockedToolboxToolFactory.mockReturnValueOnce( + toolInstance as CallableToolReturnedByFactory + ); + + return {manifestData, zodParamsSchema, toolInstance}; + }; + + it('should successfully load a tool with valid manifest and API response', async () => { + const mockToolDefinition = { + description: 'Performs calculations', + parameters: [ + {name: 'expression', type: 'string', description: 'Math expression'}, + ], + }; + + const {zodParamsSchema, toolInstance, manifestData} = + setupMocksForSuccessfulLoad(mockToolDefinition); + const loadedTool = await client.loadTool(toolName); + + expect(mockSessionGet).toHaveBeenCalledWith(expectedApiUrl); + expect(MockedZodManifestSchema.parse).toHaveBeenCalledWith(manifestData); + expect(MockedCreateZodSchemaFromParams).toHaveBeenCalledWith( + mockToolDefinition.parameters + ); + expect(MockedToolboxToolFactory).toHaveBeenCalledWith( + client['_session'], + testBaseUrl, + toolName, + mockToolDefinition.description, + zodParamsSchema + ); + expect(loadedTool).toBe(toolInstance); + }); + + it('should throw an error if manifest parsing fails', async () => { + const mockApiResponseData = {invalid: 'manifest structure'}; + const mockZodError = new Error('Zod validation failed on manifest'); + + mockSessionGet.mockResolvedValueOnce({ + data: mockApiResponseData, + } as AxiosResponse); + MockedZodManifestSchema.parse.mockImplementationOnce(() => { + throw mockZodError; + }); + + await expect(client.loadTool(toolName)).rejects.toThrow( + `Invalid manifest structure received: ${mockZodError.message}` + ); + expect(MockedCreateZodSchemaFromParams).not.toHaveBeenCalled(); + expect(MockedToolboxToolFactory).not.toHaveBeenCalled(); + }); + + it('should throw an error if manifest.tools key is missing', async () => { + const mockManifestWithoutTools = {serverVersion: '1.0.0'}; // 'tools' key absent + + mockSessionGet.mockResolvedValueOnce({ + data: mockManifestWithoutTools, + } as AxiosResponse); + MockedZodManifestSchema.parse.mockReturnValueOnce( + mockManifestWithoutTools as unknown as ZodManifest + ); + + await expect(client.loadTool(toolName)).rejects.toThrow( + `Tool "${toolName}" not found in manifest.` + ); + expect(MockedCreateZodSchemaFromParams).not.toHaveBeenCalled(); + expect(MockedToolboxToolFactory).not.toHaveBeenCalled(); + }); + + it('should throw an error if the specific tool is not found in manifest.tools', async () => { + const mockManifestWithOtherTools = { + serverVersion: '1.0.0', + tools: {anotherTool: {description: 'A different tool', parameters: []}}, + } as ZodManifest; + mockSessionGet.mockResolvedValueOnce({ + data: mockManifestWithOtherTools, + } as AxiosResponse); + MockedZodManifestSchema.parse.mockReturnValueOnce( + mockManifestWithOtherTools as ZodManifest + ); + await expect(client.loadTool(toolName)).rejects.toThrow( + `Tool "${toolName}" not found in manifest.` + ); + expect(MockedCreateZodSchemaFromParams).not.toHaveBeenCalled(); + expect(MockedToolboxToolFactory).not.toHaveBeenCalled(); + }); + + it('should throw and log error if API GET request fails', async () => { + const apiError = new Error('Server-side issue'); + mockSessionGet.mockRejectedValueOnce(apiError); + + await expect(client.loadTool(toolName)).rejects.toThrow(apiError); + expect(mockSessionGet).toHaveBeenCalledWith(expectedApiUrl); + expect(consoleErrorSpy).toHaveBeenCalledWith( + `Error fetching data from ${expectedApiUrl}:`, + 'Server-side issue' + ); + expect(MockedZodManifestSchema.parse).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/toolbox-core/test/test.errorUtils.ts b/packages/toolbox-core/test/test.errorUtils.ts new file mode 100644 index 0000000..bc0facc --- /dev/null +++ b/packages/toolbox-core/test/test.errorUtils.ts @@ -0,0 +1,118 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {logApiError} from '../src/toolbox_core/errorUtils'; +import {isAxiosError} from 'axios'; + +// Mock the 'axios' module, specifically the isAxiosError function +jest.mock('axios', () => ({ + ...jest.requireActual('axios'), // Import and retain default behavior + isAxiosError: jest.fn(), // Mock isAxiosError +})); + +describe('logApiError', () => { + let consoleErrorSpy: jest.SpyInstance; + const baseMessage = 'Test error:'; + + beforeEach(() => { + // Spy on console.error before each test + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + // Reset the mock for isAxiosError before each test + (isAxiosError as jest.MockedFunction).mockReset(); + }); + + afterEach(() => { + // Restore console.error to its original implementation after each test + consoleErrorSpy.mockRestore(); + }); + + it('should log error.response.data if error is AxiosError with response data', () => { + (isAxiosError as jest.MockedFunction).mockReturnValue( + true + ); + const errorResponseData = {detail: 'API returned an error'}; + const mockError = { + isAxiosError: true, + response: {data: errorResponseData}, + message: 'Request failed with status code 500', + name: 'AxiosError', + config: {}, + code: 'ERR_BAD_RESPONSE', + }; + + logApiError(baseMessage, mockError); + + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledWith( + baseMessage, + errorResponseData + ); + }); + + it('should log error.message if error is AxiosError without response data', () => { + (isAxiosError as jest.MockedFunction).mockReturnValue( + true + ); + const errorMessage = 'Network Error'; + const mockError = { + isAxiosError: true, + message: errorMessage, + name: 'AxiosError', + config: {}, + code: 'ERR_NETWORK', + }; // No error.response or error.response.data + + logApiError(baseMessage, mockError); + + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledWith(baseMessage, errorMessage); + }); + + it('should log error.message if error is a standard Error instance', () => { + (isAxiosError as jest.MockedFunction).mockReturnValue( + false + ); + const errorMessage = 'This is a standard error'; + const mockError = new Error(errorMessage); + + logApiError(baseMessage, mockError); + + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledWith(baseMessage, errorMessage); + }); + + it('should log the error itself if it is a string', () => { + (isAxiosError as jest.MockedFunction).mockReturnValue( + false + ); + const mockError = 'A simple string error'; + + logApiError(baseMessage, mockError); + + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledWith(baseMessage, mockError); + }); + + it('should log the error itself if it is a plain object (not Error or AxiosError)', () => { + (isAxiosError as jest.MockedFunction).mockReturnValue( + false + ); + const mockError = {customError: 'Some custom error object'}; + + logApiError(baseMessage, mockError); + + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledWith(baseMessage, mockError); + }); +}); diff --git a/packages/toolbox-core/test/test.tool.ts b/packages/toolbox-core/test/test.tool.ts new file mode 100644 index 0000000..6fb5f2e --- /dev/null +++ b/packages/toolbox-core/test/test.tool.ts @@ -0,0 +1,283 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {ToolboxTool} from '../src/toolbox_core/tool'; +import {z, ZodObject, ZodRawShape} from 'zod'; +import {AxiosInstance, AxiosResponse} from 'axios'; + +// Global mocks for Axios +const mockAxiosPost = jest.fn(); +const mockSession = { + post: mockAxiosPost, +} as unknown as AxiosInstance; + +describe('ToolboxTool', () => { + // Common constants for the tool + const baseURL = 'http://api.example.com'; + const toolName = 'myTestTool'; + const toolDescription = 'This is a description for the test tool.'; + + // Variables to be initialized in beforeEach + let basicParamSchema: ZodObject; + let consoleErrorSpy: jest.SpyInstance; + let tool: ReturnType; + + beforeEach(() => { + // Reset mocks before each test + mockAxiosPost.mockReset(); + + // Initialize a basic schema used by many tests + basicParamSchema = z.object({ + query: z.string().min(1, 'Query cannot be empty'), + limit: z.number().optional(), + }); + + // Spy on console.error to prevent logging and allow assertions + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + // Restore the original console.error + consoleErrorSpy.mockRestore(); + }); + + describe('Factory Properties and Getters', () => { + beforeEach(() => { + tool = ToolboxTool( + mockSession, + baseURL, + toolName, + toolDescription, + basicParamSchema + ); + }); + + it('should correctly assign toolName, description, and params to the callable function', () => { + expect(tool.toolName).toBe(toolName); + expect(tool.description).toBe(toolDescription); + expect(tool.params).toBe(basicParamSchema); + }); + + it('getName() should return the tool name', () => { + expect(tool.getName()).toBe(toolName); + }); + + it('getDescription() should return the tool description', () => { + expect(tool.getDescription()).toBe(toolDescription); + }); + + it('getParamSchema() should return the parameter schema', () => { + expect(tool.getParamSchema()).toBe(basicParamSchema); + }); + }); + + describe('Callable Function - Argument Validation', () => { + it('should call paramSchema.parse with the provided arguments', async () => { + const currentTool = ToolboxTool( + mockSession, + baseURL, + toolName, + toolDescription, + basicParamSchema + ); + const parseSpy = jest.spyOn(basicParamSchema, 'parse'); + const callArgs = {query: 'test query'}; + mockAxiosPost.mockResolvedValueOnce({data: 'success'} as AxiosResponse); + + await currentTool(callArgs); + + expect(parseSpy).toHaveBeenCalledWith(callArgs); + parseSpy.mockRestore(); + }); + + it('should throw a formatted ZodError if argument validation fails', async () => { + const currentTool = ToolboxTool( + mockSession, + baseURL, + toolName, + toolDescription, + basicParamSchema + ); + const invalidArgs = {query: ''}; // Fails because of empty string + + try { + await currentTool(invalidArgs); + throw new Error( + `Expected currentTool to throw a Zod validation error for tool "${toolName}", but it did not.` + ); + } catch (e) { + expect((e as Error).message).toBe( + `Argument validation failed for tool "${toolName}":\n - query: Query cannot be empty` + ); + } + expect(mockAxiosPost).not.toHaveBeenCalled(); + }); + + it('should handle multiple ZodError issues in the validation error message', async () => { + const complexSchema = z.object({ + name: z.string().min(1, 'Name is required'), + age: z.number().positive('Age must be positive'), + }); + const currentTool = ToolboxTool( + mockSession, + baseURL, + toolName, + toolDescription, + complexSchema + ); + const invalidArgs = {name: '', age: -5}; + + try { + await currentTool(invalidArgs); + throw new Error( + 'Expected currentTool to throw a Zod validation error, but it did not.' + ); + } catch (e) { + expect((e as Error).message).toEqual( + expect.stringContaining( + `Argument validation failed for tool "${toolName}":` + ) + ); + expect((e as Error).message).toEqual( + expect.stringContaining('name: Name is required') + ); + expect((e as Error).message).toEqual( + expect.stringContaining('age: Age must be positive') + ); + } + expect(mockAxiosPost).not.toHaveBeenCalled(); + }); + + it('should throw a generic error if paramSchema.parse throws a non-ZodError', async () => { + const customError = new Error('A non-Zod parsing error occurred!'); + const failingSchema = { + parse: jest.fn().mockImplementation(() => { + throw customError; + }), + } as unknown as ZodObject; + const currentTool = ToolboxTool( + mockSession, + baseURL, + toolName, + toolDescription, + failingSchema + ); + const callArgs = {query: 'some query'}; + + try { + await currentTool(callArgs); + throw new Error( + 'Expected currentTool to throw a non-Zod error during parsing, but it did not.' + ); + } catch (e) { + expect((e as Error).message).toBe( + `Argument validation failed: ${String(customError)}` + ); + } + expect(mockAxiosPost).not.toHaveBeenCalled(); + }); + + it('should use an empty object as default if no arguments are provided and schema allows it', async () => { + const emptySchema = z.object({}); + const parseSpy = jest.spyOn(emptySchema, 'parse'); + const currentTool = ToolboxTool( + mockSession, + baseURL, + toolName, + toolDescription, + emptySchema + ); + mockAxiosPost.mockResolvedValueOnce({data: 'success'}); + + await currentTool(); + + expect(parseSpy).toHaveBeenCalledWith({}); + expect(mockAxiosPost).toHaveBeenCalled(); + parseSpy.mockRestore(); + }); + + it('should fail validation if no arguments are given and schema requires them', async () => { + const currentTool = ToolboxTool( + mockSession, + baseURL, + toolName, + toolDescription, + basicParamSchema + ); + try { + await currentTool(); + throw new Error( + `Expected currentTool to throw a Zod validation error for tool "${toolName}" when no args provided, but it did not.` + ); + } catch (e) { + expect((e as Error).message).toEqual( + expect.stringContaining( + 'Argument validation failed for tool "myTestTool":' + ) + ); + expect((e as Error).message).toEqual( + expect.stringContaining('query: Required') + ); + } + expect(mockAxiosPost).not.toHaveBeenCalled(); + }); + }); + + describe('Callable Function - API Call Execution', () => { + const validArgs = {query: 'search term', limit: 10}; + const expectedUrl = `${baseURL}/api/tool/${toolName}/invoke`; + const mockApiResponseData = {result: 'Data from API'}; + + beforeEach(() => { + tool = ToolboxTool( + mockSession, + baseURL, + toolName, + toolDescription, + basicParamSchema + ); + }); + + it('should make a POST request to the correct URL with the validated payload', async () => { + mockAxiosPost.mockResolvedValueOnce({ + data: mockApiResponseData, + } as AxiosResponse); + + const result = await tool(validArgs); + + expect(mockAxiosPost).toHaveBeenCalledTimes(1); + expect(mockAxiosPost).toHaveBeenCalledWith(expectedUrl, validArgs); + expect(result).toEqual(mockApiResponseData); + }); + + it('should re-throw the error and log to console.error if API call fails', async () => { + const apiError = new Error('API request failed'); + mockAxiosPost.mockRejectedValueOnce(apiError); + + try { + await tool(validArgs); + throw new Error( + 'Expected tool call to throw an API error with response data, but it did not.' + ); + } catch (e) { + expect(e as Error).toBe(apiError); + } + expect(mockAxiosPost).toHaveBeenCalledWith(expectedUrl, validArgs); + expect(consoleErrorSpy).toHaveBeenCalledWith( + `Error posting data to ${expectedUrl}:`, + apiError.message + ); + }); + }); +});