From f081c86c61f370f9eb8e289e29cd94b6533d6509 Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Wed, 11 Mar 2026 00:09:30 +0000 Subject: [PATCH 01/14] add LLM-driven tool_search and tool_execute --- examples/meta-tools.ts | 159 ++++++++++++++++++++++++++++++++++++++++ src/index.ts | 1 + src/meta-tools.ts | 162 +++++++++++++++++++++++++++++++++++++++++ src/toolsets.ts | 35 +++++++++ 4 files changed, 357 insertions(+) create mode 100644 examples/meta-tools.ts create mode 100644 src/meta-tools.ts diff --git a/examples/meta-tools.ts b/examples/meta-tools.ts new file mode 100644 index 0000000..7c720e4 --- /dev/null +++ b/examples/meta-tools.ts @@ -0,0 +1,159 @@ +/** + * This example demonstrates the meta tools pattern (tool_search + tool_execute) + * for LLM-driven tool discovery and execution. + * + * Instead of loading all tools upfront, the LLM autonomously searches for + * relevant tools and executes them — keeping token usage minimal. + * + * @example + * ```bash + * # Run with required environment variables: + * STACKONE_API_KEY=your-key OPENAI_API_KEY=your-key STACKONE_ACCOUNT_ID=your-account npx tsx examples/meta-tools.ts + * ``` + */ + +import process from 'node:process'; +import { openai } from '@ai-sdk/openai'; +import { StackOneToolSet } from '@stackone/ai'; +import { generateText, stepCountIs } from 'ai'; + +const apiKey = process.env.STACKONE_API_KEY; +if (!apiKey) { + console.error('STACKONE_API_KEY environment variable is required'); + process.exit(1); +} + +if (!process.env.OPENAI_API_KEY) { + console.error('OPENAI_API_KEY environment variable is required'); + process.exit(1); +} + +const accountId = process.env.STACKONE_ACCOUNT_ID; + +/** + * Example 1: Meta tools with Vercel AI SDK + * + * The LLM receives only tool_search and tool_execute — two small tool definitions + * regardless of how many tools exist. It searches for what it needs and executes. + */ +const metaToolsWithAISDK = async (): Promise => { + console.log('Example 1: Meta tools with Vercel AI SDK\n'); + + const toolset = new StackOneToolSet({ + search: { method: 'semantic', topK: 3 }, + ...(accountId ? { accountId } : {}), + }); + + // Get meta tools — returns a Tools collection with tool_search + tool_execute + const accountIds = accountId ? [accountId] : []; + const metaTools = toolset.getMetaTools({ accountIds }); + + console.log(`Meta tools: ${metaTools.toArray().map((t) => t.name).join(', ')}`); + console.log(); + + // Pass to the LLM — it will search for calendly tools, then execute + const { text, steps } = await generateText({ + model: openai('gpt-4o'), + tools: await metaTools.toAISDK(), + prompt: 'List my upcoming Calendly events for the next week.', + stopWhen: stepCountIs(10), + }); + + console.log('AI Response:', text); + console.log('\nSteps taken:'); + for (const step of steps) { + for (const call of step.toolCalls ?? []) { + const argsStr = call.args ? JSON.stringify(call.args).slice(0, 100) : '{}'; + console.log(` - ${call.toolName}(${argsStr})`); + } + } +}; + +/** + * Example 2: Meta tools with OpenAI Chat Completions + * + * Same pattern, different framework. The meta tools convert to any format. + */ +const metaToolsWithOpenAI = async (): Promise => { + console.log('\nExample 2: Meta tools with OpenAI Chat Completions\n'); + + const { default: OpenAI } = await import('openai'); + + const toolset = new StackOneToolSet({ + search: { method: 'semantic', topK: 3 }, + ...(accountId ? { accountId } : {}), + }); + + const accountIds = accountId ? [accountId] : []; + const metaTools = toolset.getMetaTools({ accountIds }); + const openaiTools = metaTools.toOpenAI(); + + const client = new OpenAI(); + const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ + { + role: 'system', + content: + 'You are a helpful scheduling assistant. Use tool_search to find relevant tools, then tool_execute to run them. Always read the parameter schemas from tool_search results carefully. If a tool needs a user URI, first search for and call a "get current user" tool to obtain it. If a tool execution fails, try different parameters or a different tool.', + }, + { + role: 'user', + content: 'Check my upcoming Calendly events and list them.', + }, + ]; + + // Agent loop — let the LLM drive search and execution + let continueLoop = true; + while (continueLoop) { + const response = await client.chat.completions.create({ + model: 'gpt-4o', + messages, + tools: openaiTools, + tool_choice: 'auto', + }); + + const choice = response.choices[0]; + + if (!choice.message.tool_calls?.length) { + console.log('Final response:', choice.message.content); + continueLoop = false; + break; + } + + // Add assistant message with tool calls + messages.push(choice.message); + + // Execute each tool call + for (const toolCall of choice.message.tool_calls) { + console.log(`LLM called: ${toolCall.function.name}(${toolCall.function.arguments})`); + + const tool = metaTools.getTool(toolCall.function.name); + if (!tool) { + messages.push({ + role: 'tool', + tool_call_id: toolCall.id, + content: JSON.stringify({ error: `Unknown tool: ${toolCall.function.name}` }), + }); + continue; + } + + const result = await tool.execute(toolCall.function.arguments); + messages.push({ + role: 'tool', + tool_call_id: toolCall.id, + content: JSON.stringify(result), + }); + } + } +}; + +// Main execution +const main = async (): Promise => { + try { + await metaToolsWithAISDK(); + await metaToolsWithOpenAI(); + } catch (error) { + console.error('Error running examples:', error); + } +}; + +await main(); diff --git a/src/index.ts b/src/index.ts index f890e59..fabf102 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ export { BaseTool, StackOneTool, Tools } from './tool'; export { createFeedbackTool } from './feedback'; +export { type MetaToolsOptions } from './meta-tools'; export { StackOneError } from './utils/error-stackone'; export { StackOneAPIError } from './utils/error-stackone-api'; diff --git a/src/meta-tools.ts b/src/meta-tools.ts new file mode 100644 index 0000000..074dc0a --- /dev/null +++ b/src/meta-tools.ts @@ -0,0 +1,162 @@ +import { z } from 'zod/v4'; +import { BaseTool } from './tool'; +import type { ExecuteOptions, JsonObject, LocalExecuteConfig, ToolParameters } from './types'; +import { StackOneError } from './utils/error-stackone'; +import { StackOneAPIError } from './utils/error-stackone-api'; + +import type { SearchMode, StackOneToolSet } from './toolsets'; + +/** + * Options for getMetaTools(). + */ +export interface MetaToolsOptions { + /** Account IDs to scope tool discovery and execution */ + accountIds?: string[]; + /** Search mode for tool discovery */ + search?: SearchMode; + /** Optional connector filter (e.g. 'bamboohr') */ + connector?: string; + /** Maximum number of search results. Defaults to 5. */ + topK?: number; + /** Minimum similarity score threshold 0-1 */ + minSimilarity?: number; +} + +const localConfig = (id: string): LocalExecuteConfig => ({ + kind: 'local', + identifier: `meta:${id}`, +}); + +// --- tool_search --- + +const searchInputSchema = z.object({ + query: z + .string() + .transform((v) => v.trim()) + .refine((v) => v.length > 0, { message: 'query must be a non-empty string' }), + connector: z.string().optional(), + top_k: z.number().int().min(1).max(50).optional(), +}); + +const searchParameters = { + type: 'object', + properties: { + query: { + type: 'string', + description: + 'Natural language description of what you need (e.g. "create an employee", "list time off requests")', + }, + connector: { + type: 'string', + description: 'Optional connector filter (e.g. "bamboohr", "hibob")', + }, + top_k: { + type: 'integer', + description: 'Max results to return (1-50, default 5)', + minimum: 1, + maximum: 50, + }, + }, + required: ['query'], +} as const satisfies ToolParameters; + +export function createSearchTool(toolset: StackOneToolSet, options: MetaToolsOptions = {}): BaseTool { + const tool = new BaseTool( + 'tool_search', + 'Search for available tools by describing what you need. Returns matching tool names, descriptions, and parameter schemas. Use the returned parameter schemas to know exactly what to pass when calling tool_execute.', + searchParameters, + localConfig('search'), + ); + + tool.execute = async (inputParams?: JsonObject | string): Promise => { + const raw = typeof inputParams === 'string' ? JSON.parse(inputParams) : inputParams || {}; + const parsed = searchInputSchema.parse(raw); + + const results = await toolset.searchTools(parsed.query, { + connector: parsed.connector ?? options.connector, + topK: parsed.top_k ?? options.topK ?? 5, + minSimilarity: options.minSimilarity, + search: options.search, + accountIds: options.accountIds, + }); + + return { + tools: results.toArray().map((t) => ({ + name: t.name, + description: t.description, + parameters: t.parameters.properties, + })), + total: results.length, + query: parsed.query, + }; + }; + + return tool; +} + +// --- tool_execute --- + +const executeInputSchema = z.object({ + tool_name: z + .string() + .transform((v) => v.trim()) + .refine((v) => v.length > 0, { message: 'tool_name must be a non-empty string' }), + parameters: z.record(z.string(), z.unknown()).optional().default({}), +}); + +const executeParameters = { + type: 'object', + properties: { + tool_name: { + type: 'string', + description: 'Exact tool name from tool_search results', + }, + parameters: { + type: 'object', + description: 'Parameters for the tool. Pass an empty object {} if no parameters are needed.', + }, + }, + required: ['tool_name'], +} as const satisfies ToolParameters; + +export function createExecuteTool(toolset: StackOneToolSet, options: MetaToolsOptions = {}): BaseTool { + const tool = new BaseTool( + 'tool_execute', + 'Execute a tool by name with the given parameters. Use tool_search first to find available tools. The parameters field must match the parameter schema returned by tool_search. Pass parameters as a nested object matching the schema structure.', + executeParameters, + localConfig('execute'), + ); + + tool.execute = async ( + inputParams?: JsonObject | string, + executeOptions?: ExecuteOptions, + ): Promise => { + const raw = typeof inputParams === 'string' ? JSON.parse(inputParams) : inputParams || {}; + const parsed = executeInputSchema.parse(raw); + + const allTools = await toolset.fetchTools({ accountIds: options.accountIds }); + const target = allTools.getTool(parsed.tool_name); + + if (!target) { + return { + error: `Tool "${parsed.tool_name}" not found. Use tool_search to find available tools.`, + }; + } + + try { + return await target.execute(parsed.parameters as JsonObject, executeOptions); + } catch (error) { + // Return API errors to the LLM so it can adjust parameters and retry + if (error instanceof StackOneAPIError) { + return { + error: error.message, + status_code: error.statusCode, + tool_name: parsed.tool_name, + }; + } + throw error; + } + }; + + return tool; +} diff --git a/src/toolsets.ts b/src/toolsets.ts index 18a1324..ee891ba 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -2,6 +2,7 @@ import { defu } from 'defu'; import type { MergeExclusive, SimplifyDeep } from 'type-fest'; import { DEFAULT_BASE_URL } from './consts'; import { createFeedbackTool } from './feedback'; +import { type MetaToolsOptions, createExecuteTool, createSearchTool } from './meta-tools'; import { type StackOneHeaders, normalizeHeaders, stackOneHeadersSchema } from './headers'; import { ToolIndex } from './local-search'; import { createMCPClient } from './mcp-client'; @@ -432,6 +433,40 @@ export class StackOneToolSet { return new SearchTool(this, config); } + /** + * Get LLM-callable meta tools (tool_search + tool_execute) for agent-driven workflows. + * + * Returns a Tools collection that can be passed directly to any LLM framework. + * The LLM uses tool_search to discover available tools, then tool_execute to run them. + * + * @param options - Options to scope search and execution (account IDs, search mode, etc.) + * @returns Tools collection containing tool_search and tool_execute + * + * @example + * ```typescript + * const toolset = new StackOneToolSet({ accountIds: ['acc-123'] }); + * const metaTools = toolset.getMetaTools(); + * + * // Pass to any framework + * const result = await generateText({ + * model: openai('gpt-4o'), + * tools: await metaTools.toAISDK(), + * prompt: 'Create an employee in BambooHR', + * }); + * ``` + */ + getMetaTools(options?: MetaToolsOptions): Tools { + if (this.searchConfig === null) { + throw new ToolSetConfigError( + 'Search is disabled. Initialize StackOneToolSet with a search config to enable.', + ); + } + + const searchTool = createSearchTool(this, options); + const executeTool = createExecuteTool(this, options); + return new Tools([searchTool, executeTool]); + } + /** * Search for and fetch tools using semantic or local search. * From 56ed22caad57e49c8349d0be266b6e74d523180e Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Wed, 11 Mar 2026 00:21:10 +0000 Subject: [PATCH 02/14] chore: retrigger CI From 6e9b7ff1c49d551bf3bbeba9909adf4f8b41cb5c Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Wed, 11 Mar 2026 00:38:21 +0000 Subject: [PATCH 03/14] fix lint and tests --- src/meta-tools.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/meta-tools.ts b/src/meta-tools.ts index 074dc0a..d8ebb20 100644 --- a/src/meta-tools.ts +++ b/src/meta-tools.ts @@ -84,7 +84,7 @@ export function createSearchTool(toolset: StackOneToolSet, options: MetaToolsOpt tools: results.toArray().map((t) => ({ name: t.name, description: t.description, - parameters: t.parameters.properties, + parameters: t.parameters.properties as unknown as JsonObject, })), total: results.length, query: parsed.query, From 5678ae0a0c03aebd3018b92fc6b02b1b9e307363 Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Wed, 11 Mar 2026 00:55:24 +0000 Subject: [PATCH 04/14] lint and tests --- examples/meta-tools.ts | 7 ++++++- src/meta-tools.ts | 10 ++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/examples/meta-tools.ts b/examples/meta-tools.ts index 7c720e4..e37a291 100644 --- a/examples/meta-tools.ts +++ b/examples/meta-tools.ts @@ -48,7 +48,12 @@ const metaToolsWithAISDK = async (): Promise => { const accountIds = accountId ? [accountId] : []; const metaTools = toolset.getMetaTools({ accountIds }); - console.log(`Meta tools: ${metaTools.toArray().map((t) => t.name).join(', ')}`); + console.log( + `Meta tools: ${metaTools + .toArray() + .map((t) => t.name) + .join(', ')}`, + ); console.log(); // Pass to the LLM — it will search for calendly tools, then execute diff --git a/src/meta-tools.ts b/src/meta-tools.ts index d8ebb20..9a76350 100644 --- a/src/meta-tools.ts +++ b/src/meta-tools.ts @@ -60,7 +60,10 @@ const searchParameters = { required: ['query'], } as const satisfies ToolParameters; -export function createSearchTool(toolset: StackOneToolSet, options: MetaToolsOptions = {}): BaseTool { +export function createSearchTool( + toolset: StackOneToolSet, + options: MetaToolsOptions = {}, +): BaseTool { const tool = new BaseTool( 'tool_search', 'Search for available tools by describing what you need. Returns matching tool names, descriptions, and parameter schemas. Use the returned parameter schemas to know exactly what to pass when calling tool_execute.', @@ -119,7 +122,10 @@ const executeParameters = { required: ['tool_name'], } as const satisfies ToolParameters; -export function createExecuteTool(toolset: StackOneToolSet, options: MetaToolsOptions = {}): BaseTool { +export function createExecuteTool( + toolset: StackOneToolSet, + options: MetaToolsOptions = {}, +): BaseTool { const tool = new BaseTool( 'tool_execute', 'Execute a tool by name with the given parameters. Use tool_search first to find available tools. The parameters field must match the parameter schema returned by tool_search. Pass parameters as a nested object matching the schema structure.', From 0758bea2d1169b46da1d8ff72f91cd233018627d Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Wed, 11 Mar 2026 01:03:05 +0000 Subject: [PATCH 05/14] Lint formatter CI vs local --- examples/meta-tools.ts | 6 +++--- src/meta-tools.ts | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/examples/meta-tools.ts b/examples/meta-tools.ts index e37a291..2cb2a1d 100644 --- a/examples/meta-tools.ts +++ b/examples/meta-tools.ts @@ -16,6 +16,7 @@ import process from 'node:process'; import { openai } from '@ai-sdk/openai'; import { StackOneToolSet } from '@stackone/ai'; import { generateText, stepCountIs } from 'ai'; +import OpenAI from 'openai'; const apiKey = process.env.STACKONE_API_KEY; if (!apiKey) { @@ -68,7 +69,8 @@ const metaToolsWithAISDK = async (): Promise => { console.log('\nSteps taken:'); for (const step of steps) { for (const call of step.toolCalls ?? []) { - const argsStr = call.args ? JSON.stringify(call.args).slice(0, 100) : '{}'; + const args = (call as unknown as Record).args; + const argsStr = args ? JSON.stringify(args).slice(0, 100) : '{}'; console.log(` - ${call.toolName}(${argsStr})`); } } @@ -82,8 +84,6 @@ const metaToolsWithAISDK = async (): Promise => { const metaToolsWithOpenAI = async (): Promise => { console.log('\nExample 2: Meta tools with OpenAI Chat Completions\n'); - const { default: OpenAI } = await import('openai'); - const toolset = new StackOneToolSet({ search: { method: 'semantic', topK: 3 }, ...(accountId ? { accountId } : {}), diff --git a/src/meta-tools.ts b/src/meta-tools.ts index 9a76350..87a569b 100644 --- a/src/meta-tools.ts +++ b/src/meta-tools.ts @@ -1,7 +1,6 @@ import { z } from 'zod/v4'; import { BaseTool } from './tool'; import type { ExecuteOptions, JsonObject, LocalExecuteConfig, ToolParameters } from './types'; -import { StackOneError } from './utils/error-stackone'; import { StackOneAPIError } from './utils/error-stackone-api'; import type { SearchMode, StackOneToolSet } from './toolsets'; From c633fa64539369dca1651e12d800bff8691fd94f Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Wed, 11 Mar 2026 10:17:43 +0000 Subject: [PATCH 06/14] PR Suggestion from bots --- examples/meta-tools.ts | 5 +- src/meta-tools.test.ts | 264 +++++++++++++++++++++++++++++++++++++++++ src/meta-tools.ts | 88 +++++++++----- 3 files changed, 322 insertions(+), 35 deletions(-) create mode 100644 src/meta-tools.test.ts diff --git a/examples/meta-tools.ts b/examples/meta-tools.ts index 2cb2a1d..fad6308 100644 --- a/examples/meta-tools.ts +++ b/examples/meta-tools.ts @@ -107,8 +107,8 @@ const metaToolsWithOpenAI = async (): Promise => { ]; // Agent loop — let the LLM drive search and execution - let continueLoop = true; - while (continueLoop) { + const maxIterations = 10; + for (let i = 0; i < maxIterations; i++) { const response = await client.chat.completions.create({ model: 'gpt-4o', messages, @@ -120,7 +120,6 @@ const metaToolsWithOpenAI = async (): Promise => { if (!choice.message.tool_calls?.length) { console.log('Final response:', choice.message.content); - continueLoop = false; break; } diff --git a/src/meta-tools.test.ts b/src/meta-tools.test.ts new file mode 100644 index 0000000..7b73264 --- /dev/null +++ b/src/meta-tools.test.ts @@ -0,0 +1,264 @@ +import { z } from 'zod/v4'; +import { createExecuteTool, createSearchTool } from './meta-tools'; +import { BaseTool, Tools } from './tool'; +import type { ToolParameters } from './types'; +import { StackOneAPIError } from './utils/error-stackone-api'; + +// --- Helpers --- + +function createMockToolset(options?: { searchResults?: BaseTool[]; fetchResults?: BaseTool[] }): { + toolset: { searchTools: ReturnType; fetchTools: ReturnType }; +} { + const mockTool = new BaseTool( + 'test_tool', + 'A test tool', + { + type: 'object', + properties: { + id: { type: 'string', description: 'The ID' }, + count: { type: 'integer', description: 'A count' }, + }, + } satisfies ToolParameters, + { kind: 'local', identifier: 'test:mock' }, + ); + + const tools = new Tools(options?.searchResults ?? [mockTool]); + const fetchTools = new Tools(options?.fetchResults ?? [mockTool]); + + return { + toolset: { + searchTools: vi.fn().mockResolvedValue(tools), + fetchTools: vi.fn().mockResolvedValue(fetchTools), + }, + }; +} + +// --- Tests --- + +describe('createSearchTool', () => { + it('returns a BaseTool named tool_search', () => { + const { toolset } = createMockToolset(); + const tool = createSearchTool(toolset as never); + expect(tool).toBeInstanceOf(BaseTool); + expect(tool.name).toBe('tool_search'); + }); + + it('delegates to toolset.searchTools', async () => { + const { toolset } = createMockToolset(); + const tool = createSearchTool(toolset as never); + + await tool.execute({ query: 'find employees' }); + + expect(toolset.searchTools).toHaveBeenCalledOnce(); + expect(toolset.searchTools).toHaveBeenCalledWith('find employees', expect.any(Object)); + }); + + it('returns tool names, descriptions, and parameter properties', async () => { + const { toolset } = createMockToolset(); + const tool = createSearchTool(toolset as never); + + const result = await tool.execute({ query: 'test' }); + + expect(result.total).toBe(1); + const tools = result.tools as Array<{ + name: string; + description: string; + parameters: Record; + }>; + expect(tools[0].name).toBe('test_tool'); + expect(tools[0].description).toBe('A test tool'); + expect(tools[0].parameters).toHaveProperty('id'); + }); + + it('passes connector and accountIds from options', async () => { + const { toolset } = createMockToolset(); + const tool = createSearchTool(toolset as never, { + connector: 'bamboohr', + accountIds: ['acc-1'], + }); + + await tool.execute({ query: 'test' }); + + const callOpts = toolset.searchTools.mock.calls[0][1]; + expect(callOpts.connector).toBe('bamboohr'); + expect(callOpts.accountIds).toEqual(['acc-1']); + }); + + it('does not hardcode topK fallback — lets searchTools default', async () => { + const { toolset } = createMockToolset(); + const tool = createSearchTool(toolset as never); + + await tool.execute({ query: 'test' }); + + const callOpts = toolset.searchTools.mock.calls[0][1]; + expect(callOpts.topK).toBeUndefined(); + }); + + it('accepts string JSON arguments', async () => { + const { toolset } = createMockToolset(); + const tool = createSearchTool(toolset as never); + + const result = await tool.execute(JSON.stringify({ query: 'employees' })); + + expect(result).toHaveProperty('tools'); + expect(toolset.searchTools).toHaveBeenCalledOnce(); + }); + + it('returns error dict on invalid JSON', async () => { + const { toolset } = createMockToolset(); + const tool = createSearchTool(toolset as never); + + const result = await tool.execute('not valid json'); + + expect(result).toHaveProperty('error'); + expect(toolset.searchTools).not.toHaveBeenCalled(); + }); + + it('returns error dict on validation failure', async () => { + const { toolset } = createMockToolset(); + const tool = createSearchTool(toolset as never); + + const result = await tool.execute({ query: '' }); + + expect(result).toHaveProperty('error'); + expect(toolset.searchTools).not.toHaveBeenCalled(); + }); + + it('returns error dict on missing query', async () => { + const { toolset } = createMockToolset(); + const tool = createSearchTool(toolset as never); + + const result = await tool.execute({}); + + expect(result).toHaveProperty('error'); + }); +}); + +describe('createExecuteTool', () => { + it('returns a BaseTool named tool_execute', () => { + const { toolset } = createMockToolset(); + const tool = createExecuteTool(toolset as never); + expect(tool).toBeInstanceOf(BaseTool); + expect(tool.name).toBe('tool_execute'); + }); + + it('delegates to fetchTools and executes the target tool', async () => { + const mockTarget = new BaseTool( + 'test_tool', + 'Test', + { type: 'object', properties: {} }, + { kind: 'local', identifier: 'test:target' }, + ); + mockTarget.execute = vi.fn().mockResolvedValue({ result: 'ok' }); + + const { toolset } = createMockToolset({ fetchResults: [mockTarget] }); + const tool = createExecuteTool(toolset as never); + + const result = await tool.execute({ tool_name: 'test_tool', parameters: { id: '123' } }); + + expect(result).toEqual({ result: 'ok' }); + expect(mockTarget.execute).toHaveBeenCalledOnce(); + }); + + it('returns error when tool not found', async () => { + const { toolset } = createMockToolset(); + const tool = createExecuteTool(toolset as never); + + const result = await tool.execute({ tool_name: 'nonexistent_tool' }); + + expect(result).toHaveProperty('error'); + expect(result.error as string).toContain('not found'); + }); + + it('returns API errors as error dict with response_body', async () => { + const mockTarget = new BaseTool( + 'test_tool', + 'Test', + { type: 'object', properties: {} }, + { kind: 'local', identifier: 'test:target' }, + ); + mockTarget.execute = vi + .fn() + .mockRejectedValue(new StackOneAPIError('Bad Request', 400, { message: 'Invalid params' })); + + const { toolset } = createMockToolset({ fetchResults: [mockTarget] }); + const tool = createExecuteTool(toolset as never); + + const result = await tool.execute({ tool_name: 'test_tool', parameters: {} }); + + expect(result).toHaveProperty('error'); + expect(result.status_code).toBe(400); + expect(result.tool_name).toBe('test_tool'); + expect(result.response_body).toEqual({ message: 'Invalid params' }); + }); + + it('returns error dict on invalid JSON', async () => { + const { toolset } = createMockToolset(); + const tool = createExecuteTool(toolset as never); + + const result = await tool.execute('not valid json'); + + expect(result).toHaveProperty('error'); + }); + + it('returns error dict on validation failure', async () => { + const { toolset } = createMockToolset(); + const tool = createExecuteTool(toolset as never); + + const result = await tool.execute({ tool_name: '' }); + + expect(result).toHaveProperty('error'); + }); + + it('caches fetchTools calls', async () => { + const mockTarget = new BaseTool( + 'test_tool', + 'Test', + { type: 'object', properties: {} }, + { kind: 'local', identifier: 'test:target' }, + ); + mockTarget.execute = vi.fn().mockResolvedValue({ ok: true }); + + const { toolset } = createMockToolset({ fetchResults: [mockTarget] }); + const tool = createExecuteTool(toolset as never); + + await tool.execute({ tool_name: 'test_tool' }); + await tool.execute({ tool_name: 'test_tool' }); + + expect(toolset.fetchTools).toHaveBeenCalledOnce(); + }); + + it('passes accountIds to fetchTools', async () => { + const mockTarget = new BaseTool( + 'test_tool', + 'Test', + { type: 'object', properties: {} }, + { kind: 'local', identifier: 'test:target' }, + ); + mockTarget.execute = vi.fn().mockResolvedValue({ ok: true }); + + const { toolset } = createMockToolset({ fetchResults: [mockTarget] }); + const tool = createExecuteTool(toolset as never, { accountIds: ['acc-1'] }); + + await tool.execute({ tool_name: 'test_tool' }); + + expect(toolset.fetchTools).toHaveBeenCalledWith({ accountIds: ['acc-1'] }); + }); + + it('accepts string JSON arguments', async () => { + const mockTarget = new BaseTool( + 'test_tool', + 'Test', + { type: 'object', properties: {} }, + { kind: 'local', identifier: 'test:target' }, + ); + mockTarget.execute = vi.fn().mockResolvedValue({ ok: true }); + + const { toolset } = createMockToolset({ fetchResults: [mockTarget] }); + const tool = createExecuteTool(toolset as never); + + const result = await tool.execute(JSON.stringify({ tool_name: 'test_tool', parameters: {} })); + + expect(result).toEqual({ ok: true }); + }); +}); diff --git a/src/meta-tools.ts b/src/meta-tools.ts index 87a569b..e1c65cd 100644 --- a/src/meta-tools.ts +++ b/src/meta-tools.ts @@ -71,26 +71,38 @@ export function createSearchTool( ); tool.execute = async (inputParams?: JsonObject | string): Promise => { - const raw = typeof inputParams === 'string' ? JSON.parse(inputParams) : inputParams || {}; - const parsed = searchInputSchema.parse(raw); - - const results = await toolset.searchTools(parsed.query, { - connector: parsed.connector ?? options.connector, - topK: parsed.top_k ?? options.topK ?? 5, - minSimilarity: options.minSimilarity, - search: options.search, - accountIds: options.accountIds, - }); - - return { - tools: results.toArray().map((t) => ({ - name: t.name, - description: t.description, - parameters: t.parameters.properties as unknown as JsonObject, - })), - total: results.length, - query: parsed.query, - }; + try { + const raw = typeof inputParams === 'string' ? JSON.parse(inputParams) : inputParams || {}; + const parsed = searchInputSchema.parse(raw); + + const results = await toolset.searchTools(parsed.query, { + connector: parsed.connector ?? options.connector, + topK: parsed.top_k ?? options.topK, + minSimilarity: options.minSimilarity, + search: options.search, + accountIds: options.accountIds, + }); + + return { + tools: results.toArray().map((t) => ({ + name: t.name, + description: t.description, + parameters: t.parameters.properties as unknown as JsonObject, + })), + total: results.length, + query: parsed.query, + }; + } catch (error) { + if (error instanceof StackOneAPIError) { + return { error: error.message, status_code: error.statusCode }; + } + if (error instanceof SyntaxError || error instanceof z.ZodError) { + return { + error: `Invalid input: ${error instanceof z.ZodError ? error.issues.map((i) => i.message).join(', ') : error.message}`, + }; + } + throw error; + } }; return tool; @@ -125,6 +137,8 @@ export function createExecuteTool( toolset: StackOneToolSet, options: MetaToolsOptions = {}, ): BaseTool { + let cachedTools: Awaited> | null = null; + const tool = new BaseTool( 'tool_execute', 'Execute a tool by name with the given parameters. Use tool_search first to find available tools. The parameters field must match the parameter schema returned by tool_search. Pass parameters as a nested object matching the schema structure.', @@ -136,27 +150,37 @@ export function createExecuteTool( inputParams?: JsonObject | string, executeOptions?: ExecuteOptions, ): Promise => { - const raw = typeof inputParams === 'string' ? JSON.parse(inputParams) : inputParams || {}; - const parsed = executeInputSchema.parse(raw); + let toolName = 'unknown'; + try { + const raw = typeof inputParams === 'string' ? JSON.parse(inputParams) : inputParams || {}; + const parsed = executeInputSchema.parse(raw); + toolName = parsed.tool_name; - const allTools = await toolset.fetchTools({ accountIds: options.accountIds }); - const target = allTools.getTool(parsed.tool_name); + if (!cachedTools) { + cachedTools = await toolset.fetchTools({ accountIds: options.accountIds }); + } + const target = cachedTools.getTool(parsed.tool_name); - if (!target) { - return { - error: `Tool "${parsed.tool_name}" not found. Use tool_search to find available tools.`, - }; - } + if (!target) { + return { + error: `Tool "${parsed.tool_name}" not found. Use tool_search to find available tools.`, + }; + } - try { return await target.execute(parsed.parameters as JsonObject, executeOptions); } catch (error) { - // Return API errors to the LLM so it can adjust parameters and retry if (error instanceof StackOneAPIError) { return { error: error.message, status_code: error.statusCode, - tool_name: parsed.tool_name, + response_body: error.responseBody as JsonObject, + tool_name: toolName, + }; + } + if (error instanceof SyntaxError || error instanceof z.ZodError) { + return { + error: `Invalid input: ${error instanceof z.ZodError ? error.issues.map((i) => i.message).join(', ') : error.message}`, + tool_name: toolName, }; } throw error; From f49d51a578a6a979adce2411e9b3a3335e93b4ee Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Thu, 12 Mar 2026 09:56:34 +0000 Subject: [PATCH 07/14] Fix linter error --- examples/meta-tools.ts | 4 ++++ src/meta-tools.test.ts | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/examples/meta-tools.ts b/examples/meta-tools.ts index fad6308..f3007bd 100644 --- a/examples/meta-tools.ts +++ b/examples/meta-tools.ts @@ -128,6 +128,10 @@ const metaToolsWithOpenAI = async (): Promise => { // Execute each tool call for (const toolCall of choice.message.tool_calls) { + if (toolCall.type !== 'function') { + continue; + } + console.log(`LLM called: ${toolCall.function.name}(${toolCall.function.arguments})`); const tool = metaTools.getTool(toolCall.function.name); diff --git a/src/meta-tools.test.ts b/src/meta-tools.test.ts index 7b73264..97a1d1d 100644 --- a/src/meta-tools.test.ts +++ b/src/meta-tools.test.ts @@ -1,4 +1,3 @@ -import { z } from 'zod/v4'; import { createExecuteTool, createSearchTool } from './meta-tools'; import { BaseTool, Tools } from './tool'; import type { ToolParameters } from './types'; @@ -143,13 +142,14 @@ describe('createExecuteTool', () => { }); it('delegates to fetchTools and executes the target tool', async () => { + const executeMock = vi.fn().mockResolvedValue({ result: 'ok' }); const mockTarget = new BaseTool( 'test_tool', 'Test', { type: 'object', properties: {} }, { kind: 'local', identifier: 'test:target' }, ); - mockTarget.execute = vi.fn().mockResolvedValue({ result: 'ok' }); + mockTarget.execute = executeMock; const { toolset } = createMockToolset({ fetchResults: [mockTarget] }); const tool = createExecuteTool(toolset as never); @@ -157,7 +157,7 @@ describe('createExecuteTool', () => { const result = await tool.execute({ tool_name: 'test_tool', parameters: { id: '123' } }); expect(result).toEqual({ result: 'ok' }); - expect(mockTarget.execute).toHaveBeenCalledOnce(); + expect(executeMock).toHaveBeenCalledOnce(); }); it('returns error when tool not found', async () => { From 6b9bb8b7a6ffe7bad0383e095d02fcb2e2b3e25b Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Fri, 13 Mar 2026 10:28:38 +0000 Subject: [PATCH 08/14] Port the search execute changes to the node --- examples/search-tools.ts | 6 +- src/index.ts | 1 + src/meta-tools.test.ts | 116 +++++++++++++++++++++++++++++++++++++++ src/toolsets.test.ts | 10 ++++ src/toolsets.ts | 60 +++++++++++++++++++- 5 files changed, 187 insertions(+), 6 deletions(-) diff --git a/examples/search-tools.ts b/examples/search-tools.ts index e69cbac..39509e8 100644 --- a/examples/search-tools.ts +++ b/examples/search-tools.ts @@ -59,8 +59,8 @@ const searchToolsWithAISDK = async (): Promise => { const searchToolWithAgentLoop = async (): Promise => { console.log('\nExample 2: SearchTool for agent loops\n'); - // Default constructor — search enabled with method: 'auto' - const toolset = new StackOneToolSet(); + // Enable search with default method: 'auto' + const toolset = new StackOneToolSet({ search: {} }); // Per-call options override constructor defaults when needed const searchTool = toolset.getSearchTool({ search: 'auto' }); @@ -81,7 +81,7 @@ const searchToolWithAgentLoop = async (): Promise => { const searchActionNames = async (): Promise => { console.log('\nExample 3: Lightweight action name search\n'); - const toolset = new StackOneToolSet(); + const toolset = new StackOneToolSet({ search: {} }); // Search for action names without fetching full tool definitions const results = await toolset.searchActionNames('manage employees', { diff --git a/src/index.ts b/src/index.ts index fabf102..119ff30 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ export { ToolSetLoadError, type AuthenticationConfig, type BaseToolSetConfig, + type ExecuteToolsConfig, type SearchMode, type SearchToolsOptions, type SearchActionNamesOptions, diff --git a/src/meta-tools.test.ts b/src/meta-tools.test.ts index 97a1d1d..ab4a7d6 100644 --- a/src/meta-tools.test.ts +++ b/src/meta-tools.test.ts @@ -262,3 +262,119 @@ describe('createExecuteTool', () => { expect(result).toEqual({ ok: true }); }); }); + +describe('StackOneToolSet.openai()', () => { + function createMockToolSetInstance(options?: { + executeConfig?: { accountIds?: string[] }; + searchConfig?: Record; + }): { + toolset: { + fetchTools: ReturnType; + getMetaTools: ReturnType; + openai: (opts?: { mode?: 'search_and_execute'; accountIds?: string[] }) => Promise; + }; + } { + const mockTool = new BaseTool( + 'test_tool', + 'A test tool', + { type: 'object', properties: {} } satisfies ToolParameters, + { kind: 'local', identifier: 'test:mock' }, + ); + const tools = new Tools([mockTool]); + + const metaSearchTool = new BaseTool( + 'tool_search', + 'Search for tools', + { type: 'object', properties: { query: { type: 'string' } } } satisfies ToolParameters, + { kind: 'local', identifier: 'meta:search' }, + ); + const metaExecuteTool = new BaseTool( + 'tool_execute', + 'Execute a tool', + { type: 'object', properties: { tool_name: { type: 'string' } } } satisfies ToolParameters, + { kind: 'local', identifier: 'meta:execute' }, + ); + const metaTools = new Tools([metaSearchTool, metaExecuteTool]); + + const fetchTools = vi.fn().mockResolvedValue(tools); + const getMetaTools = vi.fn().mockReturnValue(metaTools); + + const executeConfig = options?.executeConfig; + + const toolset = { + fetchTools, + getMetaTools, + async openai(opts?: { mode?: 'search_and_execute'; accountIds?: string[] }): Promise { + const effectiveAccountIds = opts?.accountIds ?? executeConfig?.accountIds; + + if (opts?.mode === 'search_and_execute') { + return getMetaTools({ accountIds: effectiveAccountIds }).toOpenAI(); + } + + const fetchedTools = await fetchTools({ accountIds: effectiveAccountIds }); + return fetchedTools.toOpenAI(); + }, + }; + + return { toolset }; + } + + it('default fetches all tools', async () => { + const { toolset } = createMockToolSetInstance(); + + const result = await toolset.openai(); + + expect(toolset.fetchTools).toHaveBeenCalledOnce(); + expect(toolset.fetchTools).toHaveBeenCalledWith({ accountIds: undefined }); + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty('type', 'function'); + }); + + it('search_and_execute returns meta tools', async () => { + const { toolset } = createMockToolSetInstance(); + + const result = await toolset.openai({ mode: 'search_and_execute' }); + + expect(toolset.getMetaTools).toHaveBeenCalledOnce(); + expect(toolset.fetchTools).not.toHaveBeenCalled(); + expect(result).toHaveLength(2); + }); + + it('passes accountIds to fetchTools', async () => { + const { toolset } = createMockToolSetInstance(); + + await toolset.openai({ accountIds: ['acc-1'] }); + + expect(toolset.fetchTools).toHaveBeenCalledWith({ accountIds: ['acc-1'] }); + }); + + it('uses executeConfig.accountIds as fallback', async () => { + const { toolset } = createMockToolSetInstance({ + executeConfig: { accountIds: ['default-acc'] }, + }); + + await toolset.openai(); + + expect(toolset.fetchTools).toHaveBeenCalledWith({ accountIds: ['default-acc'] }); + }); + + it('accountIds overrides executeConfig', async () => { + const { toolset } = createMockToolSetInstance({ + executeConfig: { accountIds: ['default-acc'] }, + }); + + await toolset.openai({ accountIds: ['override-acc'] }); + + expect(toolset.fetchTools).toHaveBeenCalledWith({ accountIds: ['override-acc'] }); + }); + + it('search_and_execute with executeConfig passes accountIds to getMetaTools', async () => { + const { toolset } = createMockToolSetInstance({ + executeConfig: { accountIds: ['meta-acc'] }, + }); + + await toolset.openai({ mode: 'search_and_execute' }); + + expect(toolset.getMetaTools).toHaveBeenCalledWith({ accountIds: ['meta-acc'] }); + }); +}); diff --git a/src/toolsets.test.ts b/src/toolsets.test.ts index 8021c70..2256adc 100644 --- a/src/toolsets.test.ts +++ b/src/toolsets.test.ts @@ -719,6 +719,7 @@ describe('StackOneToolSet', () => { baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'mixed', + search: {}, }); // Mock the semantic search endpoint @@ -763,6 +764,7 @@ describe('StackOneToolSet', () => { baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'mixed', + search: {}, }); // Mock semantic search to fail @@ -787,6 +789,7 @@ describe('StackOneToolSet', () => { baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'mixed', + search: {}, }); // Mock semantic search to fail @@ -806,6 +809,7 @@ describe('StackOneToolSet', () => { baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'mixed', + search: {}, }); const tools = await toolset.searchTools('list employees', { @@ -827,6 +831,7 @@ describe('StackOneToolSet', () => { baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'test-account', + search: {}, }); // test-account only has dummy_action which has a connector prefix "dummy" @@ -854,6 +859,7 @@ describe('StackOneToolSet', () => { baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'mixed', + search: {}, }); server.use( @@ -886,6 +892,7 @@ describe('StackOneToolSet', () => { baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'mixed', + search: {}, }); server.use( @@ -904,6 +911,7 @@ describe('StackOneToolSet', () => { const toolset = new StackOneToolSet({ baseUrl: TEST_BASE_URL, apiKey: 'test-key', + search: {}, }); const searchTool = toolset.getSearchTool(); @@ -915,6 +923,7 @@ describe('StackOneToolSet', () => { baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'mixed', + search: {}, }); server.use( @@ -947,6 +956,7 @@ describe('StackOneToolSet', () => { baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'mixed', + search: {}, }); // Create search tool with local mode diff --git a/src/toolsets.ts b/src/toolsets.ts index ee891ba..50f722f 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -125,6 +125,15 @@ interface MultipleAccountsConfig { */ type AccountConfig = SimplifyDeep>; +/** + * Execution configuration for the StackOneToolSet constructor. + * Controls default account scoping for tool execution in meta tools. + */ +export interface ExecuteToolsConfig { + /** Account IDs to scope tool discovery and execution. */ + accountIds?: string[]; +} + /** * Base configuration for StackOne toolset (without account options) */ @@ -135,13 +144,19 @@ interface StackOneToolSetBaseConfig extends BaseToolSetConfig { * Search configuration. Controls default search behavior for `searchTools()`, * `getSearchTool()`, and `searchActionNames()`. * - * - Omit or pass `undefined` → search enabled with defaults (`method: 'auto'`) + * - Omit or pass `undefined` → search disabled (`null`) * - Pass `null` → search disabled + * - Pass `{}` or `{ method: 'auto' }` → search enabled with defaults * - Pass `{ method, topK, minSimilarity }` → search enabled with custom defaults * * Per-call options always override these defaults. */ search?: SearchConfig | null; + /** + * Execution configuration. Controls default account scoping for tool execution. + * Pass `{ accountIds: ['acc-1'] }` to scope meta tools to specific accounts. + */ + execute?: ExecuteToolsConfig; } /** @@ -262,6 +277,7 @@ export class StackOneToolSet { private headers: Record; private rpcClient?: RpcClient; private readonly searchConfig: SearchConfig | null; + private readonly executeConfig: ExecuteToolsConfig | undefined; /** * Account ID for StackOne API @@ -318,8 +334,9 @@ export class StackOneToolSet { this.accountId = accountId; this.accountIds = config?.accountIds ?? []; - // Resolve search config: undefined → defaults, null → disabled, object → custom - this.searchConfig = config?.search === null ? null : { method: 'auto', ...config?.search }; + // Resolve search config: undefined/null → disabled, object → custom with defaults + this.searchConfig = config?.search != null ? { method: 'auto', ...config.search } : null; + this.executeConfig = config?.execute; // Set Authentication headers if provided if (this.authentication) { @@ -467,6 +484,43 @@ export class StackOneToolSet { return new Tools([searchTool, executeTool]); } + /** + * Get tools in OpenAI function calling format. + * + * @param options - Options + * @param options.mode - Tool mode. + * `undefined` (default): fetch all tools and convert to OpenAI format. + * `"search_and_execute"`: return two meta tools (tool_search + tool_execute) + * that let the LLM discover and execute tools on-demand. + * @param options.accountIds - Account IDs to scope tools. Overrides the `execute` + * config from the constructor. + * @returns List of tool definitions in OpenAI function format. + * + * @example + * ```typescript + * // All tools + * const toolset = new StackOneToolSet(); + * const tools = await toolset.openai(); + * + * // Meta tools for agent-driven discovery + * const toolset = new StackOneToolSet({ search: {} }); + * const tools = await toolset.openai({ mode: 'search_and_execute' }); + * ``` + */ + async openai(options?: { + mode?: 'search_and_execute'; + accountIds?: string[]; + }): Promise> { + const effectiveAccountIds = options?.accountIds ?? this.executeConfig?.accountIds; + + if (options?.mode === 'search_and_execute') { + return this.getMetaTools({ accountIds: effectiveAccountIds }).toOpenAI(); + } + + const tools = await this.fetchTools({ accountIds: effectiveAccountIds }); + return tools.toOpenAI(); + } + /** * Search for and fetch tools using semantic or local search. * From ba9b99f27231532bfeee091f333ddbdfb678bbe0 Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Fri, 13 Mar 2026 11:21:40 +0000 Subject: [PATCH 09/14] Fix CI --- src/meta-tools.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/meta-tools.test.ts b/src/meta-tools.test.ts index ab4a7d6..ebf04fb 100644 --- a/src/meta-tools.test.ts +++ b/src/meta-tools.test.ts @@ -304,7 +304,10 @@ describe('StackOneToolSet.openai()', () => { const toolset = { fetchTools, getMetaTools, - async openai(opts?: { mode?: 'search_and_execute'; accountIds?: string[] }): Promise { + async openai(opts?: { + mode?: 'search_and_execute'; + accountIds?: string[]; + }): Promise { const effectiveAccountIds = opts?.accountIds ?? executeConfig?.accountIds; if (opts?.mode === 'search_and_execute') { From 0c5ba5fdc09444a27919d3f2f31e27e2dec758f8 Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Tue, 24 Mar 2026 16:42:40 +0000 Subject: [PATCH 10/14] Adopt the latest API changes --- examples/meta-tools.ts | 38 +++--- examples/search-tools.ts | 4 +- src/index.ts | 1 - src/meta-tools.test.ts | 37 +++--- src/meta-tools.ts | 191 --------------------------- src/semantic-search.test.ts | 35 ++--- src/semantic-search.ts | 48 +++---- src/toolsets.test.ts | 23 +--- src/toolsets.ts | 257 ++++++++++++++++++++++++++++++------ 9 files changed, 290 insertions(+), 344 deletions(-) delete mode 100644 src/meta-tools.ts diff --git a/examples/meta-tools.ts b/examples/meta-tools.ts index f3007bd..1530c00 100644 --- a/examples/meta-tools.ts +++ b/examples/meta-tools.ts @@ -1,5 +1,5 @@ /** - * This example demonstrates the meta tools pattern (tool_search + tool_execute) + * This example demonstrates the search and execute tools pattern (tool_search + tool_execute) * for LLM-driven tool discovery and execution. * * Instead of loading all tools upfront, the LLM autonomously searches for @@ -32,25 +32,25 @@ if (!process.env.OPENAI_API_KEY) { const accountId = process.env.STACKONE_ACCOUNT_ID; /** - * Example 1: Meta tools with Vercel AI SDK + * Example 1: Search and execute with Vercel AI SDK * * The LLM receives only tool_search and tool_execute — two small tool definitions * regardless of how many tools exist. It searches for what it needs and executes. */ -const metaToolsWithAISDK = async (): Promise => { - console.log('Example 1: Meta tools with Vercel AI SDK\n'); +const toolsWithAISDK = async (): Promise => { + console.log('Example 1: Search and execute with Vercel AI SDK\n'); const toolset = new StackOneToolSet({ search: { method: 'semantic', topK: 3 }, ...(accountId ? { accountId } : {}), }); - // Get meta tools — returns a Tools collection with tool_search + tool_execute + // Get search and execute tools — returns a Tools collection with tool_search + tool_execute const accountIds = accountId ? [accountId] : []; - const metaTools = toolset.getMetaTools({ accountIds }); + const tools = toolset.getTools({ accountIds }); console.log( - `Meta tools: ${metaTools + `Search and execute: ${tools .toArray() .map((t) => t.name) .join(', ')}`, @@ -59,8 +59,8 @@ const metaToolsWithAISDK = async (): Promise => { // Pass to the LLM — it will search for calendly tools, then execute const { text, steps } = await generateText({ - model: openai('gpt-4o'), - tools: await metaTools.toAISDK(), + model: openai('gpt-5.4'), + tools: await tools.toAISDK(), prompt: 'List my upcoming Calendly events for the next week.', stopWhen: stepCountIs(10), }); @@ -77,12 +77,12 @@ const metaToolsWithAISDK = async (): Promise => { }; /** - * Example 2: Meta tools with OpenAI Chat Completions + * Example 2: Search and execute with OpenAI Chat Completions * - * Same pattern, different framework. The meta tools convert to any format. + * Same pattern, different framework. The search and execute tools convert to any format. */ -const metaToolsWithOpenAI = async (): Promise => { - console.log('\nExample 2: Meta tools with OpenAI Chat Completions\n'); +const toolsWithOpenAI = async (): Promise => { + console.log('\nExample 2: Search and execute with OpenAI Chat Completions\n'); const toolset = new StackOneToolSet({ search: { method: 'semantic', topK: 3 }, @@ -90,8 +90,8 @@ const metaToolsWithOpenAI = async (): Promise => { }); const accountIds = accountId ? [accountId] : []; - const metaTools = toolset.getMetaTools({ accountIds }); - const openaiTools = metaTools.toOpenAI(); + const tools = toolset.getTools({ accountIds }); + const openaiTools = tools.toOpenAI(); const client = new OpenAI(); const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ @@ -110,7 +110,7 @@ const metaToolsWithOpenAI = async (): Promise => { const maxIterations = 10; for (let i = 0; i < maxIterations; i++) { const response = await client.chat.completions.create({ - model: 'gpt-4o', + model: 'gpt-5.4', messages, tools: openaiTools, tool_choice: 'auto', @@ -134,7 +134,7 @@ const metaToolsWithOpenAI = async (): Promise => { console.log(`LLM called: ${toolCall.function.name}(${toolCall.function.arguments})`); - const tool = metaTools.getTool(toolCall.function.name); + const tool = tools.getTool(toolCall.function.name); if (!tool) { messages.push({ role: 'tool', @@ -157,8 +157,8 @@ const metaToolsWithOpenAI = async (): Promise => { // Main execution const main = async (): Promise => { try { - await metaToolsWithAISDK(); - await metaToolsWithOpenAI(); + await toolsWithAISDK(); + await toolsWithOpenAI(); } catch (error) { console.error('Error running examples:', error); } diff --git a/examples/search-tools.ts b/examples/search-tools.ts index 39509e8..0f15b57 100644 --- a/examples/search-tools.ts +++ b/examples/search-tools.ts @@ -91,13 +91,13 @@ const searchActionNames = async (): Promise => { console.log('Search results:'); for (const result of results) { console.log( - ` - ${result.actionName} (${result.connectorKey}): score=${result.similarityScore.toFixed(2)}`, + ` - ${result.id}: score=${result.similarityScore.toFixed(2)}`, ); } // Then fetch specific tools based on the results if (results.length > 0) { - const topActions = results.filter((r) => r.similarityScore > 0.7).map((r) => r.actionName); + const topActions = results.filter((r) => r.similarityScore > 0.7).map((r) => r.id); console.log(`\nFetching tools for top actions: ${topActions.join(', ')}`); const tools = await toolset.fetchTools({ actions: topActions }); diff --git a/src/index.ts b/src/index.ts index 119ff30..da37d4c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,6 @@ export { BaseTool, StackOneTool, Tools } from './tool'; export { createFeedbackTool } from './feedback'; -export { type MetaToolsOptions } from './meta-tools'; export { StackOneError } from './utils/error-stackone'; export { StackOneAPIError } from './utils/error-stackone-api'; diff --git a/src/meta-tools.test.ts b/src/meta-tools.test.ts index ebf04fb..0695c5a 100644 --- a/src/meta-tools.test.ts +++ b/src/meta-tools.test.ts @@ -1,4 +1,4 @@ -import { createExecuteTool, createSearchTool } from './meta-tools'; +import { createExecuteTool, createSearchTool } from './toolsets'; import { BaseTool, Tools } from './tool'; import type { ToolParameters } from './types'; import { StackOneAPIError } from './utils/error-stackone-api'; @@ -6,7 +6,11 @@ import { StackOneAPIError } from './utils/error-stackone-api'; // --- Helpers --- function createMockToolset(options?: { searchResults?: BaseTool[]; fetchResults?: BaseTool[] }): { - toolset: { searchTools: ReturnType; fetchTools: ReturnType }; + toolset: { + searchTools: ReturnType; + fetchTools: ReturnType; + getSearchConfig: ReturnType; + }; } { const mockTool = new BaseTool( 'test_tool', @@ -28,6 +32,7 @@ function createMockToolset(options?: { searchResults?: BaseTool[]; fetchResults? toolset: { searchTools: vi.fn().mockResolvedValue(tools), fetchTools: vi.fn().mockResolvedValue(fetchTools), + getSearchConfig: vi.fn().mockReturnValue({ method: 'auto' }), }, }; } @@ -69,17 +74,13 @@ describe('createSearchTool', () => { expect(tools[0].parameters).toHaveProperty('id'); }); - it('passes connector and accountIds from options', async () => { + it('passes accountIds to searchTools', async () => { const { toolset } = createMockToolset(); - const tool = createSearchTool(toolset as never, { - connector: 'bamboohr', - accountIds: ['acc-1'], - }); + const tool = createSearchTool(toolset as never, ['acc-1']); await tool.execute({ query: 'test' }); const callOpts = toolset.searchTools.mock.calls[0][1]; - expect(callOpts.connector).toBe('bamboohr'); expect(callOpts.accountIds).toEqual(['acc-1']); }); @@ -238,7 +239,7 @@ describe('createExecuteTool', () => { mockTarget.execute = vi.fn().mockResolvedValue({ ok: true }); const { toolset } = createMockToolset({ fetchResults: [mockTarget] }); - const tool = createExecuteTool(toolset as never, { accountIds: ['acc-1'] }); + const tool = createExecuteTool(toolset as never, ['acc-1']); await tool.execute({ tool_name: 'test_tool' }); @@ -270,7 +271,7 @@ describe('StackOneToolSet.openai()', () => { }): { toolset: { fetchTools: ReturnType; - getMetaTools: ReturnType; + buildTools: ReturnType; openai: (opts?: { mode?: 'search_and_execute'; accountIds?: string[] }) => Promise; }; } { @@ -294,16 +295,16 @@ describe('StackOneToolSet.openai()', () => { { type: 'object', properties: { tool_name: { type: 'string' } } } satisfies ToolParameters, { kind: 'local', identifier: 'meta:execute' }, ); - const metaTools = new Tools([metaSearchTool, metaExecuteTool]); + const builtTools = new Tools([metaSearchTool, metaExecuteTool]); const fetchTools = vi.fn().mockResolvedValue(tools); - const getMetaTools = vi.fn().mockReturnValue(metaTools); + const buildTools = vi.fn().mockReturnValue(builtTools); const executeConfig = options?.executeConfig; const toolset = { fetchTools, - getMetaTools, + buildTools, async openai(opts?: { mode?: 'search_and_execute'; accountIds?: string[]; @@ -311,7 +312,7 @@ describe('StackOneToolSet.openai()', () => { const effectiveAccountIds = opts?.accountIds ?? executeConfig?.accountIds; if (opts?.mode === 'search_and_execute') { - return getMetaTools({ accountIds: effectiveAccountIds }).toOpenAI(); + return buildTools(effectiveAccountIds).toOpenAI(); } const fetchedTools = await fetchTools({ accountIds: effectiveAccountIds }); @@ -333,12 +334,12 @@ describe('StackOneToolSet.openai()', () => { expect(result[0]).toHaveProperty('type', 'function'); }); - it('search_and_execute returns meta tools', async () => { + it('search_and_execute returns search and execute tools', async () => { const { toolset } = createMockToolSetInstance(); const result = await toolset.openai({ mode: 'search_and_execute' }); - expect(toolset.getMetaTools).toHaveBeenCalledOnce(); + expect(toolset.buildTools).toHaveBeenCalledOnce(); expect(toolset.fetchTools).not.toHaveBeenCalled(); expect(result).toHaveLength(2); }); @@ -371,13 +372,13 @@ describe('StackOneToolSet.openai()', () => { expect(toolset.fetchTools).toHaveBeenCalledWith({ accountIds: ['override-acc'] }); }); - it('search_and_execute with executeConfig passes accountIds to getMetaTools', async () => { + it('search_and_execute with executeConfig passes accountIds to buildTools', async () => { const { toolset } = createMockToolSetInstance({ executeConfig: { accountIds: ['meta-acc'] }, }); await toolset.openai({ mode: 'search_and_execute' }); - expect(toolset.getMetaTools).toHaveBeenCalledWith({ accountIds: ['meta-acc'] }); + expect(toolset.buildTools).toHaveBeenCalledWith(['meta-acc']); }); }); diff --git a/src/meta-tools.ts b/src/meta-tools.ts deleted file mode 100644 index e1c65cd..0000000 --- a/src/meta-tools.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { z } from 'zod/v4'; -import { BaseTool } from './tool'; -import type { ExecuteOptions, JsonObject, LocalExecuteConfig, ToolParameters } from './types'; -import { StackOneAPIError } from './utils/error-stackone-api'; - -import type { SearchMode, StackOneToolSet } from './toolsets'; - -/** - * Options for getMetaTools(). - */ -export interface MetaToolsOptions { - /** Account IDs to scope tool discovery and execution */ - accountIds?: string[]; - /** Search mode for tool discovery */ - search?: SearchMode; - /** Optional connector filter (e.g. 'bamboohr') */ - connector?: string; - /** Maximum number of search results. Defaults to 5. */ - topK?: number; - /** Minimum similarity score threshold 0-1 */ - minSimilarity?: number; -} - -const localConfig = (id: string): LocalExecuteConfig => ({ - kind: 'local', - identifier: `meta:${id}`, -}); - -// --- tool_search --- - -const searchInputSchema = z.object({ - query: z - .string() - .transform((v) => v.trim()) - .refine((v) => v.length > 0, { message: 'query must be a non-empty string' }), - connector: z.string().optional(), - top_k: z.number().int().min(1).max(50).optional(), -}); - -const searchParameters = { - type: 'object', - properties: { - query: { - type: 'string', - description: - 'Natural language description of what you need (e.g. "create an employee", "list time off requests")', - }, - connector: { - type: 'string', - description: 'Optional connector filter (e.g. "bamboohr", "hibob")', - }, - top_k: { - type: 'integer', - description: 'Max results to return (1-50, default 5)', - minimum: 1, - maximum: 50, - }, - }, - required: ['query'], -} as const satisfies ToolParameters; - -export function createSearchTool( - toolset: StackOneToolSet, - options: MetaToolsOptions = {}, -): BaseTool { - const tool = new BaseTool( - 'tool_search', - 'Search for available tools by describing what you need. Returns matching tool names, descriptions, and parameter schemas. Use the returned parameter schemas to know exactly what to pass when calling tool_execute.', - searchParameters, - localConfig('search'), - ); - - tool.execute = async (inputParams?: JsonObject | string): Promise => { - try { - const raw = typeof inputParams === 'string' ? JSON.parse(inputParams) : inputParams || {}; - const parsed = searchInputSchema.parse(raw); - - const results = await toolset.searchTools(parsed.query, { - connector: parsed.connector ?? options.connector, - topK: parsed.top_k ?? options.topK, - minSimilarity: options.minSimilarity, - search: options.search, - accountIds: options.accountIds, - }); - - return { - tools: results.toArray().map((t) => ({ - name: t.name, - description: t.description, - parameters: t.parameters.properties as unknown as JsonObject, - })), - total: results.length, - query: parsed.query, - }; - } catch (error) { - if (error instanceof StackOneAPIError) { - return { error: error.message, status_code: error.statusCode }; - } - if (error instanceof SyntaxError || error instanceof z.ZodError) { - return { - error: `Invalid input: ${error instanceof z.ZodError ? error.issues.map((i) => i.message).join(', ') : error.message}`, - }; - } - throw error; - } - }; - - return tool; -} - -// --- tool_execute --- - -const executeInputSchema = z.object({ - tool_name: z - .string() - .transform((v) => v.trim()) - .refine((v) => v.length > 0, { message: 'tool_name must be a non-empty string' }), - parameters: z.record(z.string(), z.unknown()).optional().default({}), -}); - -const executeParameters = { - type: 'object', - properties: { - tool_name: { - type: 'string', - description: 'Exact tool name from tool_search results', - }, - parameters: { - type: 'object', - description: 'Parameters for the tool. Pass an empty object {} if no parameters are needed.', - }, - }, - required: ['tool_name'], -} as const satisfies ToolParameters; - -export function createExecuteTool( - toolset: StackOneToolSet, - options: MetaToolsOptions = {}, -): BaseTool { - let cachedTools: Awaited> | null = null; - - const tool = new BaseTool( - 'tool_execute', - 'Execute a tool by name with the given parameters. Use tool_search first to find available tools. The parameters field must match the parameter schema returned by tool_search. Pass parameters as a nested object matching the schema structure.', - executeParameters, - localConfig('execute'), - ); - - tool.execute = async ( - inputParams?: JsonObject | string, - executeOptions?: ExecuteOptions, - ): Promise => { - let toolName = 'unknown'; - try { - const raw = typeof inputParams === 'string' ? JSON.parse(inputParams) : inputParams || {}; - const parsed = executeInputSchema.parse(raw); - toolName = parsed.tool_name; - - if (!cachedTools) { - cachedTools = await toolset.fetchTools({ accountIds: options.accountIds }); - } - const target = cachedTools.getTool(parsed.tool_name); - - if (!target) { - return { - error: `Tool "${parsed.tool_name}" not found. Use tool_search to find available tools.`, - }; - } - - return await target.execute(parsed.parameters as JsonObject, executeOptions); - } catch (error) { - if (error instanceof StackOneAPIError) { - return { - error: error.message, - status_code: error.statusCode, - response_body: error.responseBody as JsonObject, - tool_name: toolName, - }; - } - if (error instanceof SyntaxError || error instanceof z.ZodError) { - return { - error: `Invalid input: ${error instanceof z.ZodError ? error.issues.map((i) => i.message).join(', ') : error.message}`, - tool_name: toolName, - }; - } - throw error; - } - }; - - return tool; -} diff --git a/src/semantic-search.test.ts b/src/semantic-search.test.ts index 4a12d32..25a62fc 100644 --- a/src/semantic-search.test.ts +++ b/src/semantic-search.test.ts @@ -32,19 +32,12 @@ describe('SemanticSearchClient', () => { return HttpResponse.json({ results: [ { - action_name: 'bamboohr_create_employee', - connector_key: 'bamboohr', + id: 'bamboohr_1.0.0_bamboohr_create_employee_global', similarity_score: 0.95, - label: 'Create Employee', - description: 'Create a new employee in BambooHR', - project_id: 'global', }, { - action_name: 'bamboohr_update_employee', - connector_key: 'bamboohr', + id: 'bamboohr_1.0.0_bamboohr_update_employee_global', similarity_score: 0.82, - label: 'Update Employee', - description: 'Update an existing employee', }, ], total_count: 2, @@ -61,13 +54,8 @@ describe('SemanticSearchClient', () => { }); expect(response.results).toHaveLength(2); - expect(response.results[0].actionName).toBe('bamboohr_create_employee'); - expect(response.results[0].connectorKey).toBe('bamboohr'); + expect(response.results[0].id).toBe('bamboohr_1.0.0_bamboohr_create_employee_global'); expect(response.results[0].similarityScore).toBe(0.95); - expect(response.results[0].label).toBe('Create Employee'); - expect(response.results[0].description).toBe('Create a new employee in BambooHR'); - expect(response.results[0].projectId).toBe('global'); - expect(response.results[1].projectId).toBe('global'); // default when not provided expect(response.totalCount).toBe(2); expect(response.query).toBe('create employee'); expect(response.connectorFilter).toBe('bamboohr'); @@ -173,24 +161,18 @@ describe('SemanticSearchClient', () => { }); describe('searchActionNames', () => { - test('returns just action names', async () => { + test('returns just action IDs', async () => { server.use( http.post(`${TEST_BASE_URL}/actions/search`, () => { return HttpResponse.json({ results: [ { - action_name: 'bamboohr_create_employee', - connector_key: 'bamboohr', + id: 'bamboohr_1.0.0_bamboohr_create_employee_global', similarity_score: 0.95, - label: 'Create Employee', - description: 'Create a new employee', }, { - action_name: 'hibob_create_employee', - connector_key: 'hibob', + id: 'hibob_1.0.0_hibob_create_employee_global', similarity_score: 0.88, - label: 'Create Employee', - description: 'Create a new employee in HiBob', }, ], total_count: 2, @@ -201,7 +183,10 @@ describe('SemanticSearchClient', () => { const client = createClient(); const names = await client.searchActionNames('create employee'); - expect(names).toEqual(['bamboohr_create_employee', 'hibob_create_employee']); + expect(names).toEqual([ + 'bamboohr_1.0.0_bamboohr_create_employee_global', + 'hibob_1.0.0_hibob_create_employee_global', + ]); }); }); }); diff --git a/src/semantic-search.ts b/src/semantic-search.ts index 7ada9f4..88c1292 100644 --- a/src/semantic-search.ts +++ b/src/semantic-search.ts @@ -13,18 +13,16 @@ * This is the primary method used when integrating with OpenAI, Anthropic, or AI SDK. * The internal flow is: * - * 1. Fetch ALL tools from linked accounts via MCP (uses accountIds to scope the request) - * 2. Extract available connectors from the fetched tools (e.g. {bamboohr, hibob}) - * 3. Search EACH connector in parallel via the semantic search API (/actions/search) - * 4. Collect results, sort by relevance score, apply topK if specified - * 5. Match semantic results back to the fetched tool definitions + * 1. Fetch tools from linked accounts via MCP to discover available connectors + * 2. Search EACH connector in parallel via the semantic search API (/actions/search) + * 3. The search API returns results with full `inputSchema` for each action + * 4. Build executable tools directly from search results (no match-back needed) + * 5. Deduplicate by actionId, sort by relevance score, apply topK * 6. Return Tools sorted by relevance score * * Key point: only the user's own connectors are searched — no wasted results - * from connectors the user doesn't have. Tools are fetched first, semantic - * search runs second, and only tools that exist in the user's linked - * accounts AND match the semantic query are returned. This prevents - * suggesting tools the user cannot execute. + * from connectors the user doesn't have. The search API returns `inputSchema` + * with each result, so tools can be built directly without a separate fetch. * * If the semantic API is unavailable, the SDK falls back to a local * BM25 + TF-IDF hybrid search over the fetched tools (unless @@ -34,10 +32,10 @@ * 2. `searchActionNames(query)` — Lightweight discovery * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * - * Queries the semantic API directly and returns action name metadata - * (name, connector, score, description) **without** fetching full tool - * definitions. This is useful for previewing results before committing - * to a full fetch. + * Queries the semantic API directly and returns action metadata + * (actionId, connector, score, description, inputSchema) **without** + * building full tool objects. Useful for previewing results before + * committing to a full fetch. * * When `accountIds` are provided, each connector is searched in * parallel (same as `searchTools`). Without `accountIds`, results @@ -69,12 +67,8 @@ export class SemanticSearchError extends StackOneError { * Single result from semantic search API. */ export interface SemanticSearchResult { - actionName: string; - connectorKey: string; + id: string; similarityScore: number; - label: string; - description: string; - projectId: string; } /** @@ -109,7 +103,7 @@ export interface SemanticSearchOptions { * const client = new SemanticSearchClient({ apiKey: 'sk-xxx' }); * const response = await client.search('create employee', { connector: 'bamboohr', topK: 5 }); * for (const result of response.results) { - * console.log(`${result.actionName}: ${result.similarityScore.toFixed(2)}`); + * console.log(`${result.actionId}: ${result.similarityScore.toFixed(2)}`); * } * ``` */ @@ -152,7 +146,7 @@ export class SemanticSearchClient { * ```typescript * const response = await client.search('onboard a new team member', { topK: 5 }); * for (const result of response.results) { - * console.log(`${result.actionName}: ${result.similarityScore.toFixed(2)}`); + * console.log(`${result.actionId}: ${result.similarityScore.toFixed(2)}`); * } * ``` */ @@ -195,12 +189,8 @@ export class SemanticSearchClient { const data = (await response.json()) as { results: Array<{ - action_name: string; - connector_key: string; + id: string; similarity_score: number; - label: string; - description: string; - project_id?: string; }>; total_count: number; query: string; @@ -210,12 +200,8 @@ export class SemanticSearchClient { return { results: data.results.map((r) => ({ - actionName: r.action_name, - connectorKey: r.connector_key, + id: r.id, similarityScore: r.similarity_score, - label: r.label, - description: r.description, - projectId: r.project_id ?? 'global', })), totalCount: data.total_count, query: data.query, @@ -255,6 +241,6 @@ export class SemanticSearchClient { */ async searchActionNames(query: string, options?: SemanticSearchOptions): Promise { const response = await this.search(query, options); - return response.results.map((r) => r.actionName); + return response.results.map((r) => r.id); } } diff --git a/src/toolsets.test.ts b/src/toolsets.test.ts index 2256adc..115b4be 100644 --- a/src/toolsets.test.ts +++ b/src/toolsets.test.ts @@ -731,18 +731,12 @@ describe('StackOneToolSet', () => { return HttpResponse.json({ results: [ { - action_name: 'hibob_list_employees', - connector_key: 'hibob', + id: 'hibob_1.0.0_hibob_list_employees_global', similarity_score: 0.95, - label: 'List Employees', - description: 'List employees from HiBob', }, { - action_name: 'bamboohr_list_employees', - connector_key: 'bamboohr', + id: 'bamboohr_1.0.0_bamboohr_list_employees_global', similarity_score: 0.88, - label: 'List Employees', - description: 'List employees from BambooHR', }, ], total_count: 2, @@ -867,11 +861,8 @@ describe('StackOneToolSet', () => { return HttpResponse.json({ results: [ { - action_name: 'hibob_1.0.0_hibob_list_employees_global', - connector_key: 'hibob', + id: 'hibob_1.0.0_hibob_list_employees_global', similarity_score: 0.95, - label: 'List Employees', - description: 'List employees', }, ], total_count: 1, @@ -883,8 +874,7 @@ describe('StackOneToolSet', () => { const results = await toolset.searchActionNames('list employees'); expect(results.length).toBeGreaterThan(0); - // Action name should be normalized (versioned prefix stripped) - expect(results[0].actionName).toBe('hibob_list_employees'); + expect(results[0].id).toBe('hibob_1.0.0_hibob_list_employees_global'); }); it('returns empty array when semantic search fails', async () => { @@ -931,11 +921,8 @@ describe('StackOneToolSet', () => { return HttpResponse.json({ results: [ { - action_name: 'hibob_list_employees', - connector_key: 'hibob', + id: 'hibob_1.0.0_hibob_list_employees_global', similarity_score: 0.95, - label: 'List Employees', - description: 'List employees', }, ], total_count: 1, diff --git a/src/toolsets.ts b/src/toolsets.ts index 50f722f..1976bf7 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -1,8 +1,8 @@ import { defu } from 'defu'; import type { MergeExclusive, SimplifyDeep } from 'type-fest'; +import { z } from 'zod/v4'; import { DEFAULT_BASE_URL } from './consts'; import { createFeedbackTool } from './feedback'; -import { type MetaToolsOptions, createExecuteTool, createSearchTool } from './meta-tools'; import { type StackOneHeaders, normalizeHeaders, stackOneHeadersSchema } from './headers'; import { ToolIndex } from './local-search'; import { createMCPClient } from './mcp-client'; @@ -17,11 +17,13 @@ import type { ExecuteOptions, JsonObject, JsonSchemaProperties, + LocalExecuteConfig, RpcExecuteConfig, SearchConfig, ToolParameters, } from './types'; import { StackOneError } from './utils/error-stackone'; +import { StackOneAPIError } from './utils/error-stackone-api'; import { normalizeActionName } from './utils/normalize'; /** @@ -127,7 +129,7 @@ type AccountConfig = SimplifyDeep v.trim()) + .refine((v) => v.length > 0, { message: 'query must be a non-empty string' }), + connector: z.string().optional(), + top_k: z.number().int().min(1).max(50).optional(), +}); + +const searchParameters = { + type: 'object', + properties: { + query: { + type: 'string', + description: + 'Natural language description of what you need (e.g. "create an employee", "list time off requests")', + }, + connector: { + type: 'string', + description: 'Optional connector filter (e.g. "bamboohr", "hibob")', + }, + top_k: { + type: 'integer', + description: 'Max results to return (1-50, default 5)', + minimum: 1, + maximum: 50, + }, + }, + required: ['query'], +} as const satisfies ToolParameters; + +const executeInputSchema = z.object({ + tool_name: z + .string() + .transform((v) => v.trim()) + .refine((v) => v.length > 0, { message: 'tool_name must be a non-empty string' }), + parameters: z.record(z.string(), z.unknown()).optional().default({}), +}); + +const executeParameters = { + type: 'object', + properties: { + tool_name: { + type: 'string', + description: 'Exact tool name from tool_search results', + }, + parameters: { + type: 'object', + description: 'Parameters for the tool. Pass an empty object {} if no parameters are needed.', + }, + }, + required: ['tool_name'], +} as const satisfies ToolParameters; + +const localConfig = (id: string): LocalExecuteConfig => ({ + kind: 'local', + identifier: `meta:${id}`, +}); + +/** @internal */ +export function createSearchTool(toolset: StackOneToolSet, accountIds?: string[]): BaseTool { + const tool = new BaseTool( + 'tool_search', + 'Search for available tools by describing what you need. Returns matching tool names, descriptions, and parameter schemas. Use the returned parameter schemas to know exactly what to pass when calling tool_execute.', + searchParameters, + localConfig('search'), + ); + + tool.execute = async (inputParams?: JsonObject | string): Promise => { + try { + const raw = typeof inputParams === 'string' ? JSON.parse(inputParams) : inputParams || {}; + const parsed = searchInputSchema.parse(raw); + + const searchConfig = toolset.getSearchConfig() ?? {}; + const results = await toolset.searchTools(parsed.query, { + connector: parsed.connector ?? searchConfig.connector, + topK: parsed.top_k ?? searchConfig.topK, + minSimilarity: searchConfig.minSimilarity, + search: searchConfig.method, + accountIds, + }); + + return { + tools: results.toArray().map((t) => ({ + name: t.name, + description: t.description, + parameters: t.parameters.properties as unknown as JsonObject, + })), + total: results.length, + query: parsed.query, + }; + } catch (error) { + if (error instanceof StackOneAPIError) { + return { error: error.message, status_code: error.statusCode }; + } + if (error instanceof SyntaxError || error instanceof z.ZodError) { + return { + error: `Invalid input: ${error instanceof z.ZodError ? error.issues.map((i) => i.message).join(', ') : error.message}`, + }; + } + throw error; + } + }; + + return tool; +} + +/** @internal */ +export function createExecuteTool(toolset: StackOneToolSet, accountIds?: string[]): BaseTool { + let cachedTools: Awaited> | null = null; + + const tool = new BaseTool( + 'tool_execute', + 'Execute a tool by name with the given parameters. Use tool_search first to find available tools. The parameters field must match the parameter schema returned by tool_search. Pass parameters as a nested object matching the schema structure.', + executeParameters, + localConfig('execute'), + ); + + tool.execute = async ( + inputParams?: JsonObject | string, + executeOptions?: ExecuteOptions, + ): Promise => { + let toolName = 'unknown'; + try { + const raw = typeof inputParams === 'string' ? JSON.parse(inputParams) : inputParams || {}; + const parsed = executeInputSchema.parse(raw); + toolName = parsed.tool_name; + + if (!cachedTools) { + cachedTools = await toolset.fetchTools({ accountIds }); + } + const target = cachedTools.getTool(parsed.tool_name); + + if (!target) { + return { + error: `Tool "${parsed.tool_name}" not found. Use tool_search to find available tools.`, + }; + } + + return await target.execute(parsed.parameters as JsonObject, executeOptions); + } catch (error) { + if (error instanceof StackOneAPIError) { + return { + error: error.message, + status_code: error.statusCode, + response_body: error.responseBody as JsonObject, + tool_name: toolName, + }; + } + if (error instanceof SyntaxError || error instanceof z.ZodError) { + return { + error: `Invalid input: ${error instanceof z.ZodError ? error.issues.map((i) => i.message).join(', ') : error.message}`, + tool_name: toolName, + }; + } + throw error; + } + }; + + return tool; +} + /** * Class for loading StackOne tools via MCP */ @@ -400,6 +566,13 @@ export class StackOneToolSet { return this.semanticSearchClient; } + /** + * Get the current search config. + */ + getSearchConfig(): SearchConfig | null { + return this.searchConfig; + } + /** * Extract the API key from authentication config. */ @@ -451,36 +624,30 @@ export class StackOneToolSet { } /** - * Get LLM-callable meta tools (tool_search + tool_execute) for agent-driven workflows. + * Get tool_search + tool_execute for agent-driven discovery. * - * Returns a Tools collection that can be passed directly to any LLM framework. - * The LLM uses tool_search to discover available tools, then tool_execute to run them. + * Returns a Tools collection with two tools that let the LLM + * discover and execute tools on-demand. * - * @param options - Options to scope search and execution (account IDs, search mode, etc.) + * @param options - Options to scope tool discovery * @returns Tools collection containing tool_search and tool_execute - * - * @example - * ```typescript - * const toolset = new StackOneToolSet({ accountIds: ['acc-123'] }); - * const metaTools = toolset.getMetaTools(); - * - * // Pass to any framework - * const result = await generateText({ - * model: openai('gpt-4o'), - * tools: await metaTools.toAISDK(), - * prompt: 'Create an employee in BambooHR', - * }); - * ``` */ - getMetaTools(options?: MetaToolsOptions): Tools { + getTools(options?: { accountIds?: string[] }): Tools { + return this.buildTools(options?.accountIds); + } + + /** + * Build tool_search + tool_execute tools scoped to this toolset. + */ + private buildTools(accountIds?: string[]): Tools { if (this.searchConfig === null) { throw new ToolSetConfigError( 'Search is disabled. Initialize StackOneToolSet with a search config to enable.', ); } - const searchTool = createSearchTool(this, options); - const executeTool = createExecuteTool(this, options); + const searchTool = createSearchTool(this, accountIds); + const executeTool = createExecuteTool(this, accountIds); return new Tools([searchTool, executeTool]); } @@ -490,7 +657,7 @@ export class StackOneToolSet { * @param options - Options * @param options.mode - Tool mode. * `undefined` (default): fetch all tools and convert to OpenAI format. - * `"search_and_execute"`: return two meta tools (tool_search + tool_execute) + * `"search_and_execute"`: return two tools (tool_search + tool_execute) * that let the LLM discover and execute tools on-demand. * @param options.accountIds - Account IDs to scope tools. Overrides the `execute` * config from the constructor. @@ -502,7 +669,7 @@ export class StackOneToolSet { * const toolset = new StackOneToolSet(); * const tools = await toolset.openai(); * - * // Meta tools for agent-driven discovery + * // Search and execute for agent-driven discovery * const toolset = new StackOneToolSet({ search: {} }); * const tools = await toolset.openai({ mode: 'search_and_execute' }); * ``` @@ -514,7 +681,7 @@ export class StackOneToolSet { const effectiveAccountIds = options?.accountIds ?? this.executeConfig?.accountIds; if (options?.mode === 'search_and_execute') { - return this.getMetaTools({ accountIds: effectiveAccountIds }).toOpenAI(); + return this.buildTools(effectiveAccountIds).toOpenAI(); } const tools = await this.fetchTools({ accountIds: effectiveAccountIds }); @@ -639,12 +806,28 @@ export class StackOneToolSet { return new Tools([]); } - // Match back to fetched tool definitions - const actionNames = new Set(topResults.map((r) => normalizeActionName(r.actionName))); - const matchedTools = allTools.toArray().filter((t) => actionNames.has(t.name)); + // 1. Parse composite IDs to MCP-format action names, deduplicate + const seenNames = new Set(); + const actionNames: string[] = []; + for (const result of topResults) { + const name = normalizeActionName(result.id); + if (seenNames.has(name)) { + continue; + } + seenNames.add(name); + actionNames.push(name); + } + + if (actionNames.length === 0) { + return new Tools([]); + } - // Sort matched tools by semantic search score order - const actionOrder = new Map(topResults.map((r, i) => [normalizeActionName(r.actionName), i])); + // 2. Use MCP tools (already fetched) — schemas come from the source of truth + // 3. Filter to only the tools search found, preserving search relevance order + const actionOrder = new Map(actionNames.map((name, i) => [name, i])); + const matchedTools = allTools + .toArray() + .filter((t) => seenNames.has(t.name)); matchedTools.sort( (a, b) => (actionOrder.get(a.name) ?? Number.POSITIVE_INFINITY) - @@ -680,13 +863,13 @@ export class StackOneToolSet { * // Lightweight: inspect results before fetching * const results = await toolset.searchActionNames('manage employees'); * for (const r of results) { - * console.log(`${r.actionName}: ${r.similarityScore.toFixed(2)}`); + * console.log(`${r.id}: ${r.similarityScore.toFixed(2)}`); * } * * // Then fetch specific high-scoring actions * const selected = results * .filter(r => r.similarityScore > 0.7) - * .map(r => r.actionName); + * .map(r => r.id); * const tools = await toolset.fetchTools({ actions: selected }); * ``` */ @@ -757,14 +940,10 @@ export class StackOneToolSet { allResults = response.results; } - // Sort by score, normalize action names + // Sort by score — action_id is already in MCP format, no normalization needed allResults.sort((a, b) => b.similarityScore - a.similarityScore); - const normalized = allResults.map((r) => ({ - ...r, - actionName: normalizeActionName(r.actionName), - })); - return effectiveTopK != null ? normalized.slice(0, effectiveTopK) : normalized; + return effectiveTopK != null ? allResults.slice(0, effectiveTopK) : allResults; } catch (error) { if (error instanceof SemanticSearchError) { return []; From 627e28c2034aab8e475b1c0dbead232e44b260a7 Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Tue, 24 Mar 2026 17:02:16 +0000 Subject: [PATCH 11/14] update the files --- examples/{meta-tools.ts => agent-tool-search.ts} | 2 +- src/{meta-tools.test.ts => agent-tools.test.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename examples/{meta-tools.ts => agent-tool-search.ts} (98%) rename src/{meta-tools.test.ts => agent-tools.test.ts} (100%) diff --git a/examples/meta-tools.ts b/examples/agent-tool-search.ts similarity index 98% rename from examples/meta-tools.ts rename to examples/agent-tool-search.ts index 1530c00..43758e4 100644 --- a/examples/meta-tools.ts +++ b/examples/agent-tool-search.ts @@ -8,7 +8,7 @@ * @example * ```bash * # Run with required environment variables: - * STACKONE_API_KEY=your-key OPENAI_API_KEY=your-key STACKONE_ACCOUNT_ID=your-account npx tsx examples/meta-tools.ts + * STACKONE_API_KEY=your-key OPENAI_API_KEY=your-key STACKONE_ACCOUNT_ID=your-account npx tsx examples/agent-tool-search.ts * ``` */ diff --git a/src/meta-tools.test.ts b/src/agent-tools.test.ts similarity index 100% rename from src/meta-tools.test.ts rename to src/agent-tools.test.ts From 7154eaeae3f0dda5ba614e10c4a1dd9ed0518028 Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Tue, 24 Mar 2026 17:06:12 +0000 Subject: [PATCH 12/14] Fix test --- src/toolsets.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/toolsets.ts b/src/toolsets.ts index 1976bf7..a006120 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -347,7 +347,7 @@ export function createSearchTool(toolset: StackOneToolSet, accountIds?: string[] const searchConfig = toolset.getSearchConfig() ?? {}; const results = await toolset.searchTools(parsed.query, { - connector: parsed.connector ?? searchConfig.connector, + connector: parsed.connector, topK: parsed.top_k ?? searchConfig.topK, minSimilarity: searchConfig.minSimilarity, search: searchConfig.method, From 94bdeb96dd6bf81f3943efaf3e61891bb17aa296 Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Tue, 24 Mar 2026 17:13:48 +0000 Subject: [PATCH 13/14] Fix lint issues --- examples/search-tools.ts | 4 +--- src/toolsets.ts | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/examples/search-tools.ts b/examples/search-tools.ts index 0f15b57..501222f 100644 --- a/examples/search-tools.ts +++ b/examples/search-tools.ts @@ -90,9 +90,7 @@ const searchActionNames = async (): Promise => { console.log('Search results:'); for (const result of results) { - console.log( - ` - ${result.id}: score=${result.similarityScore.toFixed(2)}`, - ); + console.log(` - ${result.id}: score=${result.similarityScore.toFixed(2)}`); } // Then fetch specific tools based on the results diff --git a/src/toolsets.ts b/src/toolsets.ts index a006120..528da93 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -825,9 +825,7 @@ export class StackOneToolSet { // 2. Use MCP tools (already fetched) — schemas come from the source of truth // 3. Filter to only the tools search found, preserving search relevance order const actionOrder = new Map(actionNames.map((name, i) => [name, i])); - const matchedTools = allTools - .toArray() - .filter((t) => seenNames.has(t.name)); + const matchedTools = allTools.toArray().filter((t) => seenNames.has(t.name)); matchedTools.sort( (a, b) => (actionOrder.get(a.name) ?? Number.POSITIVE_INFINITY) - From 160eca8bbf8017348fcb1721c118bb377b1ddaab Mon Sep 17 00:00:00 2001 From: Shashi-Stackone Date: Tue, 24 Mar 2026 17:29:45 +0000 Subject: [PATCH 14/14] update the doc strings --- src/semantic-search.ts | 24 +++++++++++------------- src/tool.ts | 2 +- src/toolsets.ts | 2 +- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/semantic-search.ts b/src/semantic-search.ts index 88c1292..2c861eb 100644 --- a/src/semantic-search.ts +++ b/src/semantic-search.ts @@ -13,16 +13,15 @@ * This is the primary method used when integrating with OpenAI, Anthropic, or AI SDK. * The internal flow is: * - * 1. Fetch tools from linked accounts via MCP to discover available connectors + * 1. Fetch tools from linked accounts via MCP (provides connectors and tool schemas) * 2. Search EACH connector in parallel via the semantic search API (/actions/search) - * 3. The search API returns results with full `inputSchema` for each action - * 4. Build executable tools directly from search results (no match-back needed) - * 5. Deduplicate by actionId, sort by relevance score, apply topK - * 6. Return Tools sorted by relevance score + * 3. Match search results to MCP tool definitions + * 4. Deduplicate, sort by relevance score, apply topK + * 5. Return Tools sorted by relevance score * * Key point: only the user's own connectors are searched — no wasted results - * from connectors the user doesn't have. The search API returns `inputSchema` - * with each result, so tools can be built directly without a separate fetch. + * from connectors the user doesn't have. Tool schemas come from MCP (source + * of truth), while the search API provides relevance ranking. * * If the semantic API is unavailable, the SDK falls back to a local * BM25 + TF-IDF hybrid search over the fetched tools (unless @@ -32,10 +31,9 @@ * 2. `searchActionNames(query)` — Lightweight discovery * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * - * Queries the semantic API directly and returns action metadata - * (actionId, connector, score, description, inputSchema) **without** - * building full tool objects. Useful for previewing results before - * committing to a full fetch. + * Queries the semantic API directly and returns action IDs with + * similarity scores, **without** building full tool objects. Useful + * for previewing results before committing to a full fetch. * * When `accountIds` are provided, each connector is searched in * parallel (same as `searchTools`). Without `accountIds`, results @@ -103,7 +101,7 @@ export interface SemanticSearchOptions { * const client = new SemanticSearchClient({ apiKey: 'sk-xxx' }); * const response = await client.search('create employee', { connector: 'bamboohr', topK: 5 }); * for (const result of response.results) { - * console.log(`${result.actionId}: ${result.similarityScore.toFixed(2)}`); + * console.log(`${result.id}: ${result.similarityScore.toFixed(2)}`); * } * ``` */ @@ -146,7 +144,7 @@ export class SemanticSearchClient { * ```typescript * const response = await client.search('onboard a new team member', { topK: 5 }); * for (const result of response.results) { - * console.log(`${result.actionId}: ${result.similarityScore.toFixed(2)}`); + * console.log(`${result.id}: ${result.similarityScore.toFixed(2)}`); * } * ``` */ diff --git a/src/tool.ts b/src/tool.ts index 9412d1d..ad1a215 100644 --- a/src/tool.ts +++ b/src/tool.ts @@ -145,7 +145,7 @@ export class BaseTool { async execute(inputParams?: JsonObject | string, options?: ExecuteOptions): Promise { try { if (!this.requestBuilder || this.executeConfig.kind !== 'http') { - // Non-HTTP tools provide their own execute override (e.g. RPC, local meta tools). + // Non-HTTP tools provide their own execute override (e.g. RPC, local tools). throw new StackOneError( 'BaseTool.execute is only available for HTTP-backed tools. Provide a custom execute implementation for non-HTTP tools.', ); diff --git a/src/toolsets.ts b/src/toolsets.ts index 528da93..d6c00fe 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -938,7 +938,7 @@ export class StackOneToolSet { allResults = response.results; } - // Sort by score — action_id is already in MCP format, no normalization needed + // Sort by score — return raw results (consumers can normalize the composite ID if needed) allResults.sort((a, b) => b.similarityScore - a.similarityScore); return effectiveTopK != null ? allResults.slice(0, effectiveTopK) : allResults;