diff --git a/examples/agent-tool-search.ts b/examples/agent-tool-search.ts new file mode 100644 index 0000000..43758e4 --- /dev/null +++ b/examples/agent-tool-search.ts @@ -0,0 +1,167 @@ +/** + * 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 + * 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/agent-tool-search.ts + * ``` + */ + +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) { + 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: 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 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 search and execute tools — returns a Tools collection with tool_search + tool_execute + const accountIds = accountId ? [accountId] : []; + const tools = toolset.getTools({ accountIds }); + + console.log( + `Search and execute: ${tools + .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-5.4'), + tools: await tools.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 args = (call as unknown as Record).args; + const argsStr = args ? JSON.stringify(args).slice(0, 100) : '{}'; + console.log(` - ${call.toolName}(${argsStr})`); + } + } +}; + +/** + * Example 2: Search and execute with OpenAI Chat Completions + * + * Same pattern, different framework. The search and execute tools convert to any format. + */ +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 }, + ...(accountId ? { accountId } : {}), + }); + + const accountIds = accountId ? [accountId] : []; + const tools = toolset.getTools({ accountIds }); + const openaiTools = tools.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 + const maxIterations = 10; + for (let i = 0; i < maxIterations; i++) { + const response = await client.chat.completions.create({ + model: 'gpt-5.4', + messages, + tools: openaiTools, + tool_choice: 'auto', + }); + + const choice = response.choices[0]; + + if (!choice.message.tool_calls?.length) { + console.log('Final response:', choice.message.content); + break; + } + + // Add assistant message with tool calls + messages.push(choice.message); + + // 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 = tools.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 toolsWithAISDK(); + await toolsWithOpenAI(); + } catch (error) { + console.error('Error running examples:', error); + } +}; + +await main(); diff --git a/examples/search-tools.ts b/examples/search-tools.ts index e69cbac..501222f 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', { @@ -90,14 +90,12 @@ const searchActionNames = async (): Promise => { console.log('Search results:'); for (const result of results) { - console.log( - ` - ${result.actionName} (${result.connectorKey}): score=${result.similarityScore.toFixed(2)}`, - ); + console.log(` - ${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/agent-tools.test.ts b/src/agent-tools.test.ts new file mode 100644 index 0000000..0695c5a --- /dev/null +++ b/src/agent-tools.test.ts @@ -0,0 +1,384 @@ +import { createExecuteTool, createSearchTool } from './toolsets'; +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; + getSearchConfig: 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), + getSearchConfig: vi.fn().mockReturnValue({ method: 'auto' }), + }, + }; +} + +// --- 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 accountIds to searchTools', async () => { + const { toolset } = createMockToolset(); + const tool = createSearchTool(toolset as never, ['acc-1']); + + await tool.execute({ query: 'test' }); + + const callOpts = toolset.searchTools.mock.calls[0][1]; + 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 executeMock = vi.fn().mockResolvedValue({ result: 'ok' }); + const mockTarget = new BaseTool( + 'test_tool', + 'Test', + { type: 'object', properties: {} }, + { kind: 'local', identifier: 'test:target' }, + ); + mockTarget.execute = executeMock; + + 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(executeMock).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, ['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 }); + }); +}); + +describe('StackOneToolSet.openai()', () => { + function createMockToolSetInstance(options?: { + executeConfig?: { accountIds?: string[] }; + searchConfig?: Record; + }): { + toolset: { + fetchTools: ReturnType; + buildTools: 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 builtTools = new Tools([metaSearchTool, metaExecuteTool]); + + const fetchTools = vi.fn().mockResolvedValue(tools); + const buildTools = vi.fn().mockReturnValue(builtTools); + + const executeConfig = options?.executeConfig; + + const toolset = { + fetchTools, + buildTools, + async openai(opts?: { + mode?: 'search_and_execute'; + accountIds?: string[]; + }): Promise { + const effectiveAccountIds = opts?.accountIds ?? executeConfig?.accountIds; + + if (opts?.mode === 'search_and_execute') { + return buildTools(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 search and execute tools', async () => { + const { toolset } = createMockToolSetInstance(); + + const result = await toolset.openai({ mode: 'search_and_execute' }); + + expect(toolset.buildTools).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 buildTools', async () => { + const { toolset } = createMockToolSetInstance({ + executeConfig: { accountIds: ['meta-acc'] }, + }); + + await toolset.openai({ mode: 'search_and_execute' }); + + expect(toolset.buildTools).toHaveBeenCalledWith(['meta-acc']); + }); +}); diff --git a/src/index.ts b/src/index.ts index f890e59..da37d4c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ export { ToolSetLoadError, type AuthenticationConfig, type BaseToolSetConfig, + type ExecuteToolsConfig, type SearchMode, type SearchToolsOptions, type SearchActionNamesOptions, 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..2c861eb 100644 --- a/src/semantic-search.ts +++ b/src/semantic-search.ts @@ -13,18 +13,15 @@ * 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 - * 6. Return Tools sorted by relevance score + * 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. 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. 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. 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 @@ -34,10 +31,9 @@ * 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 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 @@ -69,12 +65,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 +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.actionName}: ${result.similarityScore.toFixed(2)}`); + * console.log(`${result.id}: ${result.similarityScore.toFixed(2)}`); * } * ``` */ @@ -152,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.actionName}: ${result.similarityScore.toFixed(2)}`); + * console.log(`${result.id}: ${result.similarityScore.toFixed(2)}`); * } * ``` */ @@ -195,12 +187,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 +198,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 +239,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/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.test.ts b/src/toolsets.test.ts index 8021c70..115b4be 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 @@ -730,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, @@ -763,6 +758,7 @@ describe('StackOneToolSet', () => { baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'mixed', + search: {}, }); // Mock semantic search to fail @@ -787,6 +783,7 @@ describe('StackOneToolSet', () => { baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'mixed', + search: {}, }); // Mock semantic search to fail @@ -806,6 +803,7 @@ describe('StackOneToolSet', () => { baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'mixed', + search: {}, }); const tools = await toolset.searchTools('list employees', { @@ -827,6 +825,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 +853,7 @@ describe('StackOneToolSet', () => { baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'mixed', + search: {}, }); server.use( @@ -861,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, @@ -877,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 () => { @@ -886,6 +882,7 @@ describe('StackOneToolSet', () => { baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'mixed', + search: {}, }); server.use( @@ -904,6 +901,7 @@ describe('StackOneToolSet', () => { const toolset = new StackOneToolSet({ baseUrl: TEST_BASE_URL, apiKey: 'test-key', + search: {}, }); const searchTool = toolset.getSearchTool(); @@ -915,6 +913,7 @@ describe('StackOneToolSet', () => { baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'mixed', + search: {}, }); server.use( @@ -922,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, @@ -947,6 +943,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 18a1324..d6c00fe 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -1,5 +1,6 @@ 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 StackOneHeaders, normalizeHeaders, stackOneHeadersSchema } from './headers'; @@ -16,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'; /** @@ -124,6 +127,15 @@ interface MultipleAccountsConfig { */ type AccountConfig = SimplifyDeep>; +/** + * Execution configuration for the StackOneToolSet constructor. + * Controls default account scoping for tool execution in tools. + */ +export interface ExecuteToolsConfig { + /** Account IDs to scope tool discovery and execution. */ + accountIds?: string[]; +} + /** * Base configuration for StackOne toolset (without account options) */ @@ -134,13 +146,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 tools to specific accounts. + */ + execute?: ExecuteToolsConfig; } /** @@ -252,6 +270,170 @@ export class SearchTool { } } +// --- Internal tool_search + tool_execute --- + +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; + +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, + 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 */ @@ -261,6 +443,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 @@ -317,8 +500,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) { @@ -382,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. */ @@ -432,6 +623,71 @@ export class StackOneToolSet { return new SearchTool(this, config); } + /** + * Get tool_search + tool_execute for agent-driven discovery. + * + * Returns a Tools collection with two tools that let the LLM + * discover and execute tools on-demand. + * + * @param options - Options to scope tool discovery + * @returns Tools collection containing tool_search and tool_execute + */ + 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, accountIds); + const executeTool = createExecuteTool(this, accountIds); + 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 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(); + * + * // Search and execute 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.buildTools(effectiveAccountIds).toOpenAI(); + } + + const tools = await this.fetchTools({ accountIds: effectiveAccountIds }); + return tools.toOpenAI(); + } + /** * Search for and fetch tools using semantic or local search. * @@ -550,12 +806,26 @@ 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) - @@ -591,13 +861,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 }); * ``` */ @@ -668,14 +938,10 @@ export class StackOneToolSet { allResults = response.results; } - // Sort by score, normalize action names + // Sort by score — return raw results (consumers can normalize the composite ID if 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 [];