Skip to content

Commit 5238fca

Browse files
authored
feat(toolbox-core): add bound params (#25)
* unclean working code * fix client tests * add utils test * cleanup * fix * fix tests * lint * fix any type issues * fix docstrings * remove redundant check * merge fix * Rename private variables and methods * use default bound params
1 parent cb9ca6d commit 5238fca

File tree

7 files changed

+566
-47
lines changed

7 files changed

+566
-47
lines changed

packages/toolbox-core/src/toolbox_core/client.ts

Lines changed: 82 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {type AxiosInstance, type AxiosResponse} from 'axios';
1818
import {ZodManifestSchema, createZodSchemaFromParams} from './protocol.js';
1919
import {logApiError} from './errorUtils.js';
2020
import {ZodError} from 'zod';
21+
import {BoundParams, BoundValue} from './utils.js';
2122

2223
type Manifest = import('zod').infer<typeof ZodManifestSchema>;
2324
type ToolSchemaFromManifest = Manifest['tools'][string];
@@ -27,8 +28,8 @@ type ToolSchemaFromManifest = Manifest['tools'][string];
2728
* Manages an Axios Client Session, if not provided.
2829
*/
2930
class ToolboxClient {
30-
private _baseUrl: string;
31-
private _session: AxiosInstance;
31+
#baseUrl: string;
32+
#session: AxiosInstance;
3233

3334
/**
3435
* Initializes the ToolboxClient.
@@ -37,21 +38,20 @@ class ToolboxClient {
3738
* requests. If not provided, a new one will be created.
3839
*/
3940
constructor(url: string, session?: AxiosInstance) {
40-
this._baseUrl = url;
41-
this._session = session || axios.create({baseURL: this._baseUrl});
41+
this.#baseUrl = url;
42+
this.#session = session || axios.create({baseURL: this.#baseUrl});
4243
}
4344

4445
/**
4546
* Fetches and parses the manifest from a given API path.
4647
* @param {string} apiPath - The API path to fetch the manifest from (e.g., "/api/tool/mytool").
4748
* @returns {Promise<Manifest>} A promise that resolves to the parsed manifest.
4849
* @throws {Error} If there's an error fetching data or if the manifest structure is invalid.
49-
* @private
5050
*/
51-
private async _fetchAndParseManifest(apiPath: string): Promise<Manifest> {
52-
const url = `${this._baseUrl}${apiPath}`;
51+
async #fetchAndParseManifest(apiPath: string): Promise<Manifest> {
52+
const url = `${this.#baseUrl}${apiPath}`;
5353
try {
54-
const response: AxiosResponse = await this._session.get(url);
54+
const response: AxiosResponse = await this.#session.get(url);
5555
const responseData = response.data;
5656

5757
try {
@@ -85,21 +85,38 @@ class ToolboxClient {
8585
* Creates a ToolboxTool instance from its schema.
8686
* @param {string} toolName - The name of the tool.
8787
* @param {ToolSchemaFromManifest} toolSchema - The schema definition of the tool from the manifest.
88+
* @param {BoundParams} [boundParams] - A map of all candidate parameters to bind.
8889
* @returns {ReturnType<typeof ToolboxTool>} A ToolboxTool function.
89-
* @private
9090
*/
91-
private _createToolInstance(
91+
#createToolInstance(
9292
toolName: string,
93-
toolSchema: ToolSchemaFromManifest
94-
): ReturnType<typeof ToolboxTool> {
93+
toolSchema: ToolSchemaFromManifest,
94+
boundParams: BoundParams = {}
95+
): {
96+
tool: ReturnType<typeof ToolboxTool>;
97+
usedBoundKeys: Set<string>;
98+
} {
99+
const toolParamNames = new Set(toolSchema.parameters.map(p => p.name));
100+
const applicableBoundParams: Record<string, BoundValue> = {};
101+
const usedBoundKeys = new Set<string>();
102+
103+
for (const key in boundParams) {
104+
if (toolParamNames.has(key)) {
105+
applicableBoundParams[key] = boundParams[key];
106+
usedBoundKeys.add(key);
107+
}
108+
}
109+
95110
const paramZodSchema = createZodSchemaFromParams(toolSchema.parameters);
96-
return ToolboxTool(
97-
this._session,
98-
this._baseUrl,
111+
const tool = ToolboxTool(
112+
this.#session,
113+
this.#baseUrl,
99114
toolName,
100115
toolSchema.description,
101-
paramZodSchema
116+
paramZodSchema,
117+
boundParams
102118
);
119+
return {tool, usedBoundKeys};
103120
}
104121

105122
/**
@@ -108,22 +125,42 @@ class ToolboxClient {
108125
* returns a callable (`ToolboxTool`) that can be used to invoke the
109126
* tool remotely.
110127
*
128+
* @param {BoundParams} [boundParams] - Optional parameters to pre-bind to the tool.
111129
* @param {string} name - The unique name or identifier of the tool to load.
112130
* @returns {Promise<ReturnType<typeof ToolboxTool>>} A promise that resolves
113131
* to a ToolboxTool function, ready for execution.
114132
* @throws {Error} If the tool is not found in the manifest, the manifest structure is invalid,
115133
* or if there's an error fetching data from the API.
116134
*/
117-
async loadTool(name: string): Promise<ReturnType<typeof ToolboxTool>> {
135+
async loadTool(
136+
name: string,
137+
boundParams: BoundParams = {}
138+
): Promise<ReturnType<typeof ToolboxTool>> {
118139
const apiPath = `/api/tool/${name}`;
119-
const manifest = await this._fetchAndParseManifest(apiPath);
140+
const manifest = await this.#fetchAndParseManifest(apiPath);
120141

121142
if (
122143
manifest.tools && // Zod ensures manifest.tools exists if schema requires it
123144
Object.prototype.hasOwnProperty.call(manifest.tools, name)
124145
) {
125146
const specificToolSchema = manifest.tools[name];
126-
return this._createToolInstance(name, specificToolSchema);
147+
const {tool, usedBoundKeys} = this.#createToolInstance(
148+
name,
149+
specificToolSchema,
150+
boundParams
151+
);
152+
153+
const providedBoundKeys = Object.keys(boundParams);
154+
const unusedBound = providedBoundKeys.filter(
155+
key => !usedBoundKeys.has(key)
156+
);
157+
158+
if (unusedBound.length > 0) {
159+
throw new Error(
160+
`Validation failed for tool '${name}': unused bound parameters: ${unusedBound.join(', ')}.`
161+
);
162+
}
163+
return tool;
127164
} else {
128165
throw new Error(`Tool "${name}" not found in manifest from ${apiPath}.`);
129166
}
@@ -133,21 +170,43 @@ class ToolboxClient {
133170
* Asynchronously fetches a toolset and loads all tools defined within it.
134171
*
135172
* @param {string | null} [name] - Name of the toolset to load. If null or undefined, loads the default toolset.
173+
* @param {BoundParams} [boundParams] - Optional parameters to pre-bind to the tools in the toolset.
136174
* @returns {Promise<Array<ReturnType<typeof ToolboxTool>>>} A promise that resolves
137175
* to a list of ToolboxTool functions, ready for execution.
138176
* @throws {Error} If the manifest structure is invalid or if there's an error fetching data from the API.
139177
*/
140178
async loadToolset(
141-
name?: string
179+
name?: string,
180+
boundParams: BoundParams = {}
142181
): Promise<Array<ReturnType<typeof ToolboxTool>>> {
143182
const toolsetName = name || '';
144183
const apiPath = `/api/toolset/${toolsetName}`;
145-
const manifest = await this._fetchAndParseManifest(apiPath);
184+
185+
const manifest = await this.#fetchAndParseManifest(apiPath);
146186
const tools: Array<ReturnType<typeof ToolboxTool>> = [];
147187

188+
const providedBoundKeys = new Set(Object.keys(boundParams));
189+
const overallUsedBoundParams: Set<string> = new Set();
190+
148191
for (const [toolName, toolSchema] of Object.entries(manifest.tools)) {
149-
const toolInstance = this._createToolInstance(toolName, toolSchema);
150-
tools.push(toolInstance);
192+
const {tool, usedBoundKeys} = this.#createToolInstance(
193+
toolName,
194+
toolSchema,
195+
boundParams
196+
);
197+
tools.push(tool);
198+
usedBoundKeys.forEach((key: string) => overallUsedBoundParams.add(key));
199+
}
200+
201+
const unusedBound = [...providedBoundKeys].filter(
202+
k => !overallUsedBoundParams.has(k)
203+
);
204+
if (unusedBound.length > 0) {
205+
throw new Error(
206+
`Validation failed for toolset '${
207+
name || 'default'
208+
}': unused bound parameters could not be applied to any tool: ${unusedBound.join(', ')}.`
209+
);
151210
}
152211
return tools;
153212
}

packages/toolbox-core/src/toolbox_core/tool.ts

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import {ZodObject, ZodError, ZodRawShape} from 'zod';
1616
import {AxiosInstance, AxiosResponse} from 'axios';
1717
import {logApiError} from './errorUtils.js';
18+
import {BoundParams, BoundValue, resolveValue} from './utils.js';
1819

1920
/**
2021
* Creates a callable tool function representing a specific tool on a remote
@@ -25,6 +26,7 @@ import {logApiError} from './errorUtils.js';
2526
* @param {string} name - The name of the remote tool.
2627
* @param {string} description - A description of the remote tool.
2728
* @param {ZodObject<any>} paramSchema - The Zod schema for validating the tool's parameters.
29+
* @param {BoundParams} [boundParams] - Optional parameters to pre-bind to the tool.
2830
* @returns {CallableTool & CallableToolProperties} An async function that, when
2931
* called, invokes the tool with the provided arguments. Validates arguments
3032
* against the tool's signature, then sends them
@@ -36,16 +38,21 @@ function ToolboxTool(
3638
baseUrl: string,
3739
name: string,
3840
description: string,
39-
paramSchema: ZodObject<ZodRawShape>
41+
paramSchema: ZodObject<ZodRawShape>,
42+
boundParams: BoundParams = {}
4043
) {
4144
const toolUrl = `${baseUrl}/api/tool/${name}/invoke`;
45+
const boundKeys = Object.keys(boundParams);
46+
const userParamSchema = paramSchema.omit(
47+
Object.fromEntries(boundKeys.map(k => [k, true]))
48+
);
4249

4350
const callable = async function (
4451
callArguments: Record<string, unknown> = {}
4552
) {
46-
let validatedPayload: Record<string, unknown>;
53+
let validatedUserArgs: Record<string, unknown>;
4754
try {
48-
validatedPayload = paramSchema.parse(callArguments);
55+
validatedUserArgs = userParamSchema.parse(callArguments);
4956
} catch (error) {
5057
if (error instanceof ZodError) {
5158
const errorMessages = error.errors.map(
@@ -57,11 +64,18 @@ function ToolboxTool(
5764
}
5865
throw new Error(`Argument validation failed: ${String(error)}`);
5966
}
67+
68+
// Resolve any bound parameters that are functions.
69+
const resolvedEntries = await Promise.all(
70+
Object.entries(boundParams).map(async ([key, value]) => {
71+
const resolved = await resolveValue(value);
72+
return [key, resolved];
73+
})
74+
);
75+
const resolvedBoundParams = Object.fromEntries(resolvedEntries);
76+
const payload = {...validatedUserArgs, ...resolvedBoundParams};
6077
try {
61-
const response: AxiosResponse = await session.post(
62-
toolUrl,
63-
validatedPayload
64-
);
78+
const response: AxiosResponse = await session.post(toolUrl, payload);
6579
return response.data;
6680
} catch (error) {
6781
logApiError(`Error posting data to ${toolUrl}:`, error);
@@ -71,6 +85,8 @@ function ToolboxTool(
7185
callable.toolName = name;
7286
callable.description = description;
7387
callable.params = paramSchema;
88+
callable.boundParams = boundParams;
89+
7490
callable.getName = function () {
7591
return this.toolName;
7692
};
@@ -80,6 +96,36 @@ function ToolboxTool(
8096
callable.getParamSchema = function () {
8197
return this.params;
8298
};
99+
100+
callable.bindParams = function (paramsToBind: BoundParams) {
101+
const originalParamKeys = Object.keys(this.params.shape);
102+
for (const paramName of Object.keys(paramsToBind)) {
103+
if (paramName in this.boundParams) {
104+
throw new Error(
105+
`Cannot re-bind parameter: parameter '${paramName}' is already bound in tool '${this.toolName}'.`
106+
);
107+
}
108+
if (!originalParamKeys.includes(paramName)) {
109+
throw new Error(
110+
`Unable to bind parameter: no parameter named '${paramName}' in tool '${this.toolName}'.`
111+
);
112+
}
113+
}
114+
115+
const newBoundParams = {...this.boundParams, ...paramsToBind};
116+
return ToolboxTool(
117+
session,
118+
baseUrl,
119+
this.toolName,
120+
this.description,
121+
this.params,
122+
newBoundParams
123+
);
124+
};
125+
126+
callable.bindParam = function (paramName: string, paramValue: BoundValue) {
127+
return this.bindParams({[paramName]: paramValue});
128+
};
83129
return callable;
84130
}
85131

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export type BoundValue = unknown | (() => unknown) | (() => Promise<unknown>);
2+
3+
export type BoundParams = Record<string, BoundValue>;
4+
5+
/**
6+
* Resolves a value that might be a literal, a function, or a promise-returning function.
7+
* @param {BoundValue} value The value to resolve.
8+
* @returns {Promise<unknown>} A promise that resolves to the final literal value.
9+
*/
10+
export async function resolveValue(value: BoundValue): Promise<unknown> {
11+
if (typeof value === 'function') {
12+
// Execute the function and await its result, correctly handling both sync and async functions.
13+
return await Promise.resolve(value());
14+
}
15+
return value;
16+
}

packages/toolbox-core/test/e2e/test.e2e.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,77 @@ describe('ToolboxClient E2E Tests', () => {
127127
).rejects.toThrow('Request failed with status code 404');
128128
});
129129
});
130+
describe('bindParams', () => {
131+
it('should successfully bind a parameter with bindParam and invoke', async () => {
132+
const newTool = getNRowsTool.bindParam('num_rows', '3');
133+
const response = await newTool(); // Invoke with no args
134+
const result = response['result'];
135+
expect(result).toContain('row1');
136+
expect(result).toContain('row2');
137+
expect(result).toContain('row3');
138+
expect(result).not.toContain('row4');
139+
});
140+
141+
it('should successfully bind parameters with bindParams and invoke', async () => {
142+
const newTool = getNRowsTool.bindParams({num_rows: '3'});
143+
const response = await newTool(); // Invoke with no args
144+
const result = response['result'];
145+
expect(result).toContain('row1');
146+
expect(result).toContain('row2');
147+
expect(result).toContain('row3');
148+
expect(result).not.toContain('row4');
149+
});
150+
151+
it('should successfully bind a synchronous function value', async () => {
152+
const newTool = getNRowsTool.bindParams({num_rows: () => '1'});
153+
const response = await newTool();
154+
const result = response['result'];
155+
expect(result).toContain('row1');
156+
expect(result).not.toContain('row2');
157+
});
158+
159+
it('should successfully bind an asynchronous function value', async () => {
160+
const asyncNumProvider = async () => {
161+
await new Promise(resolve => setTimeout(resolve, 10));
162+
return '1';
163+
};
164+
165+
const newTool = getNRowsTool.bindParams({num_rows: asyncNumProvider});
166+
const response = await newTool();
167+
const result = response['result'];
168+
169+
expect(result).toContain('row1');
170+
171+
expect(result).not.toContain('row2');
172+
});
173+
174+
it('should successfully bind parameters at load time', async () => {
175+
const tool = await commonToolboxClient.loadTool('get-n-rows', {
176+
num_rows: '3',
177+
});
178+
const response = await tool();
179+
const result = response['result'];
180+
expect(result).toContain('row1');
181+
expect(result).toContain('row2');
182+
expect(result).toContain('row3');
183+
expect(result).not.toContain('row4');
184+
});
185+
186+
it('should throw an error when re-binding an existing parameter', () => {
187+
const newTool = getNRowsTool.bindParam('num_rows', '1');
188+
expect(() => {
189+
newTool.bindParam('num_rows', '2');
190+
}).toThrow(
191+
"Cannot re-bind parameter: parameter 'num_rows' is already bound in tool 'get-n-rows'."
192+
);
193+
});
194+
195+
it('should throw an error when binding a non-existent parameter', () => {
196+
expect(() => {
197+
getNRowsTool.bindParam('non_existent_param', '2');
198+
}).toThrow(
199+
"Unable to bind parameter: no parameter named 'non_existent_param' in tool 'get-n-rows'."
200+
);
201+
});
202+
});
130203
});

0 commit comments

Comments
 (0)