diff --git a/examples/ai-sdk-integration.test.ts b/examples/ai-sdk-integration.test.ts index 79239b8a..9fbc002f 100644 --- a/examples/ai-sdk-integration.test.ts +++ b/examples/ai-sdk-integration.test.ts @@ -1,11 +1,14 @@ /** * E2E test for ai-sdk-integration.ts example * - * Tests the complete flow of using StackOne tools with the AI SDK. + * Tests the complete flow of using StackOne tools with the AI SDK, + * including both fetchTools and semantic search paths. */ import { openai } from '@ai-sdk/openai'; import { generateText, stepCountIs } from 'ai'; +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/node'; import { StackOneToolSet } from '../src'; describe('ai-sdk-integration example e2e', () => { @@ -49,4 +52,63 @@ describe('ai-sdk-integration example e2e', () => { // The mocked OpenAI response includes 'Michael' in the text expect(text).toContain('Michael'); }); + + it('should discover tools via semantic search, convert to AI SDK format, and generate text', async () => { + // Mock semantic search to return bamboohr results + server.use( + http.post('https://api.stackone.com/actions/search', async ({ request }) => { + const body = (await request.json()) as { query: string }; + return HttpResponse.json({ + results: [ + { + action_name: 'bamboohr_1.0.0_bamboohr_get_employee_global', + connector_key: 'bamboohr', + similarity_score: 0.95, + label: 'Get Employee', + description: 'Get employee details from BambooHR', + }, + { + action_name: 'bamboohr_1.0.0_bamboohr_list_employees_global', + connector_key: 'bamboohr', + similarity_score: 0.88, + label: 'List Employees', + description: 'List all employees from BambooHR', + }, + ], + total_count: 2, + query: body.query, + }); + }), + ); + + const toolset = new StackOneToolSet({ + accountId: 'your-bamboohr-account-id', + baseUrl: 'https://api.stackone.com', + }); + + // Discover tools via semantic search + const tools = await toolset.searchTools('get employee details', { topK: 5 }); + expect(tools.length).toBeGreaterThan(0); + + // Convert to AI SDK tools + const aiSdkTools = await tools.toAISDK(); + expect(Object.keys(aiSdkTools).length).toBeGreaterThan(0); + expect(aiSdkTools).toHaveProperty('bamboohr_get_employee'); + + // Each tool should have execute function + for (const name of Object.keys(aiSdkTools)) { + expect(typeof aiSdkTools[name].execute).toBe('function'); + expect(aiSdkTools[name].inputSchema).toBeDefined(); + } + + // Use with generateText + const { text } = await generateText({ + model: openai('gpt-5'), + tools: aiSdkTools, + prompt: 'Get all details about employee with id: c28xIQaWQ6MzM5MzczMDA2NzMzMzkwNzIwNA', + stopWhen: stepCountIs(3), + }); + + expect(text).toContain('Michael'); + }); }); diff --git a/examples/ai-sdk-integration.ts b/examples/ai-sdk-integration.ts index c44aabc2..87c1daba 100644 --- a/examples/ai-sdk-integration.ts +++ b/examples/ai-sdk-integration.ts @@ -49,4 +49,33 @@ const aiSdkIntegration = async (): Promise => { assert(text.includes('Michael'), 'Expected employee name to be included in the response'); }; +/** + * Semantic search variant: discover tools via natural language, then use + * them with the AI SDK. This is the recommended approach when you don't + * know the exact tool names upfront. + */ +const aiSdkSemanticSearchIntegration = async (): Promise => { + const toolset = new StackOneToolSet({ + accountId, + baseUrl: process.env.STACKONE_BASE_URL ?? 'https://api.stackone.com', + }); + + // Discover relevant tools using natural language + const tools = await toolset.searchTools('get employee details', { topK: 5 }); + + // Convert to AI SDK tools + const aiSdkTools = await tools.toAISDK(); + + // Use with generateText — the AI SDK handles tool calls automatically + const { text } = await generateText({ + model: openai('gpt-5.1'), + tools: aiSdkTools, + prompt: 'Get all details about employee with id: c28xIQaWQ6MzM5MzczMDA2NzMzMzkwNzIwNA', + stopWhen: stepCountIs(3), + }); + + assert(text.includes('Michael'), 'Expected employee name to be included in the response'); +}; + await aiSdkIntegration(); +await aiSdkSemanticSearchIntegration(); diff --git a/examples/semantic-search.test.ts b/examples/semantic-search.test.ts new file mode 100644 index 00000000..3863e597 --- /dev/null +++ b/examples/semantic-search.test.ts @@ -0,0 +1,248 @@ +/** + * E2E test for semantic-search.ts example + * + * Tests the complete flow of semantic search using Calendly-themed tools: + * searchActionNames, searchTools, searchTools with connector, and utility + * tools with semantic search. + */ + +import { http, HttpResponse } from 'msw'; +import { server } from '../mocks/node'; +import { type SemanticSearchResult, StackOneToolSet } from '../src'; + +/** Helper to create a semantic search result */ +function semanticResult( + actionName: string, + connectorKey: string, + similarityScore: number, + description = `${actionName} description`, +): Record { + return { + action_name: actionName, + connector_key: connectorKey, + similarity_score: similarityScore, + label: actionName.replace(/_/g, ' '), + description, + }; +} + +/** Set up a mock handler for the semantic search API */ +function mockSemanticSearch(results: Record[]): void { + server.use( + http.post('https://api.stackone.com/actions/search', async ({ request }) => { + const body = (await request.json()) as { query: string; connector?: string }; + const filtered = body.connector + ? results.filter((r) => r.connector_key === body.connector) + : results; + return HttpResponse.json({ + results: filtered, + total_count: filtered.length, + query: body.query, + }); + }), + ); +} + +describe('semantic-search example e2e', () => { + const semanticResults = [ + semanticResult( + 'calendly_1.0.0_calendly_list_events_global', + 'calendly', + 0.95, + 'List scheduled events from Calendly', + ), + semanticResult( + 'calendly_1.0.0_calendly_cancel_event_global', + 'calendly', + 0.88, + 'Cancel a scheduled event in Calendly', + ), + semanticResult( + 'calendly_1.0.0_calendly_create_scheduling_link_global', + 'calendly', + 0.82, + 'Create a new scheduling link in Calendly', + ), + semanticResult( + 'calendly_1.0.0_calendly_get_event_global', + 'calendly', + 0.75, + 'Get details of a specific event from Calendly', + ), + ]; + + beforeEach(() => { + vi.stubEnv('STACKONE_API_KEY', 'test-key'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('searchActionNames returns action names and scores without fetching tools', async () => { + mockSemanticSearch(semanticResults); + + const toolset = new StackOneToolSet({ + baseUrl: 'https://api.stackone.com', + }); + + const results = await toolset.searchActionNames('get user schedule', { topK: 5 }); + + expect(results.length).toBeGreaterThan(0); + for (const r of results) { + expect(r.actionName).toBeDefined(); + expect(r.connectorKey).toBeDefined(); + expect(r.similarityScore).toBeGreaterThan(0); + } + }); + + it('searchActionNames filters to connectors available in linked accounts', async () => { + mockSemanticSearch(semanticResults); + + const toolset = new StackOneToolSet({ + baseUrl: 'https://api.stackone.com', + }); + + // With accountIds, results are filtered to connectors in those accounts + const results = await toolset.searchActionNames('cancel an event', { + accountIds: ['your-calendly-account-id'], + topK: 5, + }); + + expect(results.length).toBeGreaterThan(0); + // All results should be for calendly since that's the only connector in the account + for (const r of results) { + expect(r.connectorKey).toBe('calendly'); + } + }); + + it('searchTools returns a Tools collection with matched tools', async () => { + mockSemanticSearch(semanticResults); + + const toolset = new StackOneToolSet({ + baseUrl: 'https://api.stackone.com', + }); + + const tools = await toolset.searchTools('cancel an event', { + accountIds: ['your-calendly-account-id'], + topK: 5, + }); + + expect(tools.length).toBeGreaterThan(0); + + // Tools should be convertible to OpenAI format + const openaiTools = tools.toOpenAI(); + expect(openaiTools.length).toBeGreaterThan(0); + expect(openaiTools[0]).toHaveProperty('type', 'function'); + + // All matched tools should be calendly tools + for (const tool of tools.toArray()) { + expect(tool.name).toMatch(/^calendly_/); + } + }); + + it('searchTools with connector filter scopes to a specific provider', async () => { + mockSemanticSearch(semanticResults); + + const toolset = new StackOneToolSet({ + baseUrl: 'https://api.stackone.com', + }); + + const tools = await toolset.searchTools('book a meeting', { + connector: 'calendly', + accountIds: ['your-calendly-account-id'], + topK: 3, + }); + + expect(tools.length).toBeGreaterThan(0); + for (const tool of tools.toArray()) { + expect(tool.name.startsWith('calendly_')).toBe(true); + } + }); + + it('utility tools with semantic search creates semantic tool_search', async () => { + mockSemanticSearch(semanticResults); + + const toolset = new StackOneToolSet({ + baseUrl: 'https://api.stackone.com', + }); + + const allTools = await toolset.fetchTools({ + accountIds: ['your-calendly-account-id'], + }); + + const utility = await allTools.utilityTools({ + semanticClient: toolset.semanticClient, + }); + + // Should have tool_search and tool_execute + const searchTool = utility.getTool('tool_search'); + const executeTool = utility.getTool('tool_execute'); + expect(searchTool).toBeDefined(); + expect(executeTool).toBeDefined(); + + // tool_search with semantic should have a connector parameter + const schema = searchTool!.parameters; + expect(schema.properties).toHaveProperty('connector'); + + // Execute semantic tool_search + const result = await searchTool!.execute({ query: 'cancel an event or meeting', limit: 5 }); + const tools = (result as { tools?: Array<{ name: string; score: number }> }).tools; + expect(tools).toBeDefined(); + expect(tools!.length).toBeGreaterThan(0); + }); + + it('searchTools converts to AI SDK format with correct structure', async () => { + mockSemanticSearch(semanticResults); + + const toolset = new StackOneToolSet({ + baseUrl: 'https://api.stackone.com', + }); + + const tools = await toolset.searchTools('cancel an event', { + accountIds: ['your-calendly-account-id'], + topK: 5, + }); + + // Convert to AI SDK format + const aiSdkTools = await tools.toAISDK(); + + // Should contain calendly tools as keys + const toolNames = Object.keys(aiSdkTools); + expect(toolNames.length).toBeGreaterThan(0); + for (const name of toolNames) { + expect(name).toMatch(/^calendly_/); + } + + // Each tool should have the expected AI SDK structure + for (const name of toolNames) { + const tool = aiSdkTools[name]; + expect(tool.inputSchema).toBeDefined(); + expect(tool.description).toBeDefined(); + expect(typeof tool.execute).toBe('function'); + } + }); + + it('searchActionNames normalizes and deduplicates action names', async () => { + // Provide multiple API versions of the same action + const dupeResults = [ + semanticResult('calendly_1.0.0_calendly_list_events_global', 'calendly', 0.95), + semanticResult('calendly_2.0.0_calendly_list_events_global', 'calendly', 0.9), + semanticResult('calendly_1.0.0_calendly_cancel_event_global', 'calendly', 0.85), + ]; + mockSemanticSearch(dupeResults); + + const toolset = new StackOneToolSet({ + baseUrl: 'https://api.stackone.com', + }); + + const results = await toolset.searchActionNames('events', { topK: 10 }); + + // Should be deduplicated — only one "calendly_list_events" and one "calendly_cancel_event" + const actionNames = results.map((r: SemanticSearchResult) => r.actionName); + const uniqueNames = new Set(actionNames); + expect(uniqueNames.size).toBe(actionNames.length); + expect(actionNames).toContain('calendly_list_events'); + expect(actionNames).toContain('calendly_cancel_event'); + }); +}); diff --git a/examples/semantic-search.ts b/examples/semantic-search.ts new file mode 100644 index 00000000..d233772f --- /dev/null +++ b/examples/semantic-search.ts @@ -0,0 +1,459 @@ +/** + * Example demonstrating semantic search for AI-powered tool discovery. + * + * Semantic search understands natural language intent and synonyms, so queries like + * "book a meeting" or "cancel an event" resolve to the right StackOne actions — + * unlike keyword matching which requires exact tool names. + * + * This example uses a Calendly-linked account to demonstrate how semantic search + * discovers scheduling, event, and organization management tools from natural + * language queries. + * + * How Semantic Search Works (Overview) + * ===================================== + * + * The SDK provides three paths for semantic tool discovery, each with a different + * trade-off between speed, filtering, and completeness: + * + * 1. searchTools(query) — Full discovery (recommended for agent frameworks) + * + * This is the method you should use when integrating with OpenAI, AI SDK, + * or any other agent framework. It works in these steps: + * + * a) Fetch ALL tools from the user's linked accounts via MCP + * b) Extract the set of available connectors (e.g. {bamboohr, calendly}) + * c) Query the semantic search API with the natural language query + * d) Filter results to only connectors the user has access to + * e) Deduplicate across API versions (keep highest score per action) + * f) Match results back to the fetched tool definitions + * g) Return a Tools collection sorted by relevance score + * + * Key point: tools are fetched first, semantic search runs second, and only + * the intersection (tools the user has AND that match the query) is returned. + * If the semantic API is unavailable, the SDK falls back to local BM25+TF-IDF + * search automatically. + * + * 2. searchActionNames(query) — Lightweight preview + * + * Queries the semantic API directly and returns metadata (name, connector, + * score, description) without fetching full tool definitions. Useful for + * inspecting results before committing to a full fetch. When accountIds are + * provided, results are filtered to the user's available connectors. + * + * 3. utilityTools({ semanticClient }) — Agent-loop pattern + * + * Creates tool_search and tool_execute utility tools that agents can call + * inside an agentic loop. The agent searches, inspects, and executes tools + * dynamically. Note: utility tool search queries the full backend catalog + * (all connectors), not just the user's linked accounts. + * + * This example is runnable with the following command: + * ```bash + * pnpm tsx examples/semantic-search.ts + * ``` + * + * Prerequisites: + * - STACKONE_API_KEY environment variable set + * - STACKONE_ACCOUNT_ID environment variable set (required for examples that fetch tools) + * - At least one linked account in StackOne (this example uses Calendly) + * + * Note: searchActionNames() works with just STACKONE_API_KEY — no account ID needed. + */ + +import process from 'node:process'; +import { StackOneToolSet } from '@stackone/ai'; + +const apiKey = process.env.STACKONE_API_KEY; +if (!apiKey) { + console.error('STACKONE_API_KEY environment variable is required'); + process.exit(1); +} + +// Read account IDs from environment — supports comma-separated values +const accountIds = (process.env.STACKONE_ACCOUNT_ID ?? '') + .split(',') + .map((id) => id.trim()) + .filter(Boolean); + +/** + * Example 1: Lightweight search returning action names and scores without fetching tools. + * + * searchActionNames() queries the semantic search API directly — it does NOT + * need account IDs or MCP. When called without accountIds, results come from the + * full StackOne catalog. When called with accountIds, results are filtered to + * only connectors available in your linked accounts. + */ +const exampleSearchActionNames = async (): Promise => { + console.log('='.repeat(60)); + console.log('Example 1: searchActionNames() — lightweight discovery'); + console.log('='.repeat(60)); + console.log(); + + const toolset = new StackOneToolSet({ + baseUrl: process.env.STACKONE_BASE_URL ?? 'https://api.stackone.com', + }); + + const query = 'get user schedule'; + console.log(`Searching for: "${query}"`); + console.log(); + + const results = await toolset.searchActionNames(query, { topK: 5 }); + + console.log(`Top ${results.length} matches from the full catalog:`); + for (const r of results) { + console.log(` [${r.similarityScore.toFixed(2)}] ${r.actionName} (${r.connectorKey})`); + console.log(` ${r.description}`); + } + console.log(); + + // Show filtering effect when account_ids are available + if (accountIds.length > 0) { + console.log(`Now filtering to your linked accounts (${accountIds.join(', ')})...`); + const filtered = await toolset.searchActionNames(query, { accountIds, topK: 5 }); + console.log(`Filtered to ${filtered.length} matches (only your connectors):`); + for (const r of filtered) { + console.log(` [${r.similarityScore.toFixed(2)}] ${r.actionName} (${r.connectorKey})`); + } + } else { + console.log('Tip: Set STACKONE_ACCOUNT_ID to see results filtered to your linked connectors.'); + } + console.log(); +}; + +/** + * Example 2: Full tool discovery using semantic search. + * + * searchTools() is the recommended way to use semantic search. It: + * 1. Queries the semantic search API with your natural language query + * 2. Fetches tool definitions from your linked accounts via MCP + * 3. Matches semantic results to available tools + * 4. Returns a Tools collection ready for any framework (.toOpenAI(), .toAISDK(), etc.) + */ +const exampleSearchTools = async (): Promise => { + console.log('='.repeat(60)); + console.log('Example 2: searchTools() — full tool discovery'); + console.log('='.repeat(60)); + console.log(); + + const toolset = new StackOneToolSet({ + baseUrl: process.env.STACKONE_BASE_URL ?? 'https://api.stackone.com', + }); + + const query = 'cancel an event'; + console.log(`Step 1: Searching for "${query}" via semantic search...`); + console.log(); + + const tools = await toolset.searchTools(query, { accountIds, topK: 5 }); + + const connectors = new Set(tools.toArray().map((t) => t.name.split('_')[0])); + console.log( + `Found ${tools.length} tools from your linked account(s) (${[...connectors].sort().join(', ')}):`, + ); + for (const tool of tools.toArray()) { + console.log(` - ${tool.name}`); + console.log(` ${tool.description}`); + } + console.log(); + + // Show OpenAI conversion + console.log('Step 2a: Converting to OpenAI function-calling format...'); + const openaiTools = tools.toOpenAI(); + console.log(`Created ${openaiTools.length} OpenAI function definitions:`); + for (const fn of openaiTools) { + const func = fn.function; + const paramNames = Object.keys( + (func.parameters as Record>)?.properties ?? {}, + ); + console.log( + ` - ${func.name}(${paramNames.slice(0, 3).join(', ')}${paramNames.length > 3 ? '...' : ''})`, + ); + } + console.log(); + + // Show AI SDK conversion + console.log('Step 2b: Converting to Vercel AI SDK format...'); + const aiSdkTools = await tools.toAISDK(); + const aiSdkToolNames = Object.keys(aiSdkTools); + console.log(`Created ${aiSdkToolNames.length} AI SDK tool definitions:`); + for (const name of aiSdkToolNames) { + const tool = aiSdkTools[name]; + console.log(` - ${name} (executable: ${typeof tool.execute === 'function'})`); + } + console.log(); +}; + +/** + * Example 3: Semantic search filtered by connector. + * + * Use the connector parameter to scope results to a specific provider, + * for example when you know the user works with Calendly. + */ +const exampleSearchToolsWithConnector = async (): Promise => { + console.log('='.repeat(60)); + console.log('Example 3: searchTools() with connector filter'); + console.log('='.repeat(60)); + console.log(); + + const toolset = new StackOneToolSet({ + baseUrl: process.env.STACKONE_BASE_URL ?? 'https://api.stackone.com', + }); + + const query = 'book a meeting'; + const connector = 'calendly'; + console.log(`Searching for "${query}" filtered to connector="${connector}"...`); + console.log(); + + const tools = await toolset.searchTools(query, { + connector, + accountIds, + topK: 3, + }); + + console.log(`Found ${tools.length} ${connector} tools:`); + for (const tool of tools.toArray()) { + console.log(` - ${tool.name}`); + console.log(` ${tool.description}`); + } + console.log(); +}; + +/** + * Example 4: Using utility tools with semantic search for agent loops. + * + * When building agent loops (search -> select -> execute), pass + * semanticClient to utilityTools() to upgrade tool_search from + * local BM25+TF-IDF to cloud-based semantic search. + * + * Note: tool_search queries the full backend catalog (all connectors), + * not just the ones in your linked accounts. + */ +const exampleUtilityToolsSemantic = async (): Promise => { + console.log('='.repeat(60)); + console.log('Example 4: Utility tools with semantic search'); + console.log('='.repeat(60)); + console.log(); + + const toolset = new StackOneToolSet({ + baseUrl: process.env.STACKONE_BASE_URL ?? 'https://api.stackone.com', + }); + + console.log('Step 1: Fetching tools from your linked accounts via MCP...'); + const tools = await toolset.fetchTools({ accountIds }); + console.log(`Loaded ${tools.length} tools.`); + console.log(); + + console.log('Step 2: Creating utility tools with semantic search enabled...'); + console.log(' Passing semanticClient upgrades tool_search from local keyword'); + console.log(' matching (BM25+TF-IDF) to cloud-based semantic vector search.'); + const utility = await tools.utilityTools({ semanticClient: toolset.semanticClient }); + + const searchTool = utility.getTool('tool_search'); + if (searchTool) { + const searchQuery = 'cancel an event or meeting'; + console.log(); + console.log(`Step 3: Calling tool_search with query="${searchQuery}"...`); + console.log(' (This searches the full StackOne catalog, not just your linked tools)'); + console.log(); + const result = await searchTool.execute({ query: searchQuery, limit: 5 }); + const toolsData = + (result as { tools?: Array<{ name: string; description: string; score: number }> }).tools ?? + []; + console.log(`tool_search returned ${toolsData.length} results:`); + for (const toolInfo of toolsData) { + console.log(` [${toolInfo.score.toFixed(2)}] ${toolInfo.name}`); + console.log(` ${toolInfo.description}`); + } + } + console.log(); +}; + +/** + * Example 5: Complete agent loop using semantic search with OpenAI. + * + * Demonstrates the full pattern for building an AI agent that + * discovers tools via semantic search and executes them via OpenAI. + */ +const exampleOpenAIAgentLoop = async (): Promise => { + console.log('='.repeat(60)); + console.log('Example 5: OpenAI agent loop with semantic search'); + console.log('='.repeat(60)); + console.log(); + + let OpenAI: typeof import('openai').default; + try { + const mod = await import('openai'); + OpenAI = mod.default; + } catch { + console.log('Skipped: OpenAI library not installed. Install with: pnpm add openai'); + console.log(); + return; + } + + if (!process.env.OPENAI_API_KEY) { + console.log('Skipped: Set OPENAI_API_KEY to run this example.'); + console.log(); + return; + } + + const client = new OpenAI(); + const toolset = new StackOneToolSet({ + baseUrl: process.env.STACKONE_BASE_URL ?? 'https://api.stackone.com', + }); + + const query = 'list upcoming events'; + console.log(`Step 1: Discovering tools for "${query}" via semantic search...`); + const tools = await toolset.searchTools(query, { accountIds, topK: 3 }); + console.log(`Found ${tools.length} tools:`); + for (const tool of tools.toArray()) { + console.log(` - ${tool.name}`); + } + console.log(); + + console.log('Step 2: Sending tools to OpenAI as function definitions...'); + const openaiTools = tools.toOpenAI(); + + const response = await client.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ + { role: 'system', content: 'You are a helpful scheduling assistant.' }, + { role: 'user', content: 'Can you show me my upcoming events?' }, + ], + tools: openaiTools, + tool_choice: 'auto', + }); + + const message = response.choices[0]?.message; + if (message?.tool_calls) { + console.log('Step 3: OpenAI chose to call these tools:'); + for (const toolCall of message.tool_calls) { + if (toolCall.type !== 'function') continue; + console.log(` - ${toolCall.function.name}(${toolCall.function.arguments})`); + + const tool = tools.getTool(toolCall.function.name); + if (tool) { + const result = await tool.execute(JSON.parse(toolCall.function.arguments)); + console.log( + ` Response keys: ${typeof result === 'object' && result !== null ? Object.keys(result).join(', ') : typeof result}`, + ); + } + } + } else { + console.log(`OpenAI responded with text: ${message?.content}`); + } + console.log(); +}; + +/** + * Example 6: Using semantic search with the Vercel AI SDK. + * + * Demonstrates the full pattern: discover tools via semantic search, + * convert them to AI SDK format, and pass them to generateText(). + */ +const exampleSearchToolsAISDK = async (): Promise => { + console.log('='.repeat(60)); + console.log('Example 6: Semantic search with Vercel AI SDK'); + console.log('='.repeat(60)); + console.log(); + + let generateText: typeof import('ai').generateText; + let stepCountIs: typeof import('ai').stepCountIs; + let openai: typeof import('@ai-sdk/openai').openai; + try { + const aiMod = await import('ai'); + generateText = aiMod.generateText; + stepCountIs = aiMod.stepCountIs; + const openaiMod = await import('@ai-sdk/openai'); + openai = openaiMod.openai; + } catch { + console.log('Skipped: ai and @ai-sdk/openai libraries not installed.'); + console.log('Install with: pnpm add ai @ai-sdk/openai'); + console.log(); + return; + } + + if (!process.env.OPENAI_API_KEY) { + console.log('Skipped: Set OPENAI_API_KEY to run this example.'); + console.log(); + return; + } + + const toolset = new StackOneToolSet({ + baseUrl: process.env.STACKONE_BASE_URL ?? 'https://api.stackone.com', + }); + + const query = 'list upcoming events'; + console.log(`Step 1: Discovering tools for "${query}" via semantic search...`); + const tools = await toolset.searchTools(query, { accountIds, topK: 3 }); + console.log(`Found ${tools.length} tools:`); + for (const tool of tools.toArray()) { + console.log(` - ${tool.name}`); + } + console.log(); + + console.log('Step 2: Converting to AI SDK format...'); + const aiSdkTools = await tools.toAISDK(); + console.log(`Created ${Object.keys(aiSdkTools).length} AI SDK tool definitions`); + console.log(); + + console.log('Step 3: Running generateText() with AI SDK...'); + const { text } = await generateText({ + model: openai('gpt-4o-mini'), + tools: aiSdkTools, + prompt: 'Can you show me my upcoming events?', + stopWhen: stepCountIs(3), + }); + + console.log(`AI response: ${text}`); + console.log(); +}; + +// Main execution +const main = async (): Promise => { + console.log(); + console.log('############################################################'); + console.log('# StackOne AI SDK — Semantic Search Examples #'); + console.log('############################################################'); + console.log(); + + // --- Examples that work without account IDs --- + await exampleSearchActionNames(); + + // --- Examples that require account IDs (MCP needs x-account-id) --- + if (accountIds.length === 0) { + console.log('='.repeat(60)); + console.log('Remaining examples require STACKONE_ACCOUNT_ID'); + console.log('='.repeat(60)); + console.log(); + console.log('Set STACKONE_ACCOUNT_ID (comma-separated for multiple) to run'); + console.log('examples that fetch full tool definitions from your linked accounts:'); + console.log(' - searchTools() with natural language queries'); + console.log(' - searchTools() with connector filter'); + console.log(' - Utility tools with semantic search'); + console.log(' - OpenAI agent loop'); + return; + } + + await exampleSearchTools(); + await exampleSearchToolsWithConnector(); + await exampleUtilityToolsSemantic(); + await exampleOpenAIAgentLoop(); + await exampleSearchToolsAISDK(); + + console.log('############################################################'); + console.log('# All examples completed! #'); + console.log('############################################################'); +}; + +// Run if this file is executed directly +if (import.meta.main) { + await main(); +} + +export { + exampleSearchActionNames, + exampleSearchTools, + exampleSearchToolsWithConnector, + exampleUtilityToolsSemantic, + exampleOpenAIAgentLoop, + exampleSearchToolsAISDK, +}; diff --git a/mocks/handlers.mcp.ts b/mocks/handlers.mcp.ts index d091ce9a..f352092f 100644 --- a/mocks/handlers.mcp.ts +++ b/mocks/handlers.mcp.ts @@ -4,6 +4,7 @@ import { createMcpApp, defaultMcpTools, exampleBamboohrTools, + exampleCalendlyTools, mixedProviderTools, } from './mcp-server'; @@ -19,6 +20,7 @@ const defaultMcpApp = createMcpApp({ // For examples testing 'your-bamboohr-account-id': exampleBamboohrTools, 'your-stackone-account-id': exampleBamboohrTools, + 'your-calendly-account-id': exampleCalendlyTools, }, }); diff --git a/mocks/mcp-server.ts b/mocks/mcp-server.ts index 24a53258..40198c95 100644 --- a/mocks/mcp-server.ts +++ b/mocks/mcp-server.ts @@ -229,6 +229,66 @@ export const exampleBamboohrTools = [ }, ] as const satisfies McpToolDefinition[]; +/** Tools for the semantic search example tests (Calendly-themed) */ +export const exampleCalendlyTools = [ + { + name: 'calendly_list_events', + description: 'List scheduled events from Calendly', + inputSchema: { + type: 'object', + properties: { + status: { type: 'string', description: 'Filter by event status (active, canceled)' }, + limit: { type: 'number', description: 'Limit the number of results' }, + }, + }, + }, + { + name: 'calendly_create_scheduling_link', + description: 'Create a new scheduling link in Calendly', + inputSchema: { + type: 'object', + properties: { + event_type: { type: 'string', description: 'The event type UUID' }, + max_event_count: { type: 'number', description: 'Maximum number of events' }, + }, + required: ['event_type'], + }, + }, + { + name: 'calendly_cancel_event', + description: 'Cancel a scheduled event in Calendly', + inputSchema: { + type: 'object', + properties: { + event_id: { type: 'string', description: 'The event UUID to cancel' }, + reason: { type: 'string', description: 'Cancellation reason' }, + }, + required: ['event_id'], + }, + }, + { + name: 'calendly_get_event', + description: 'Get details of a specific event from Calendly', + inputSchema: { + type: 'object', + properties: { + event_id: { type: 'string', description: 'The event UUID' }, + }, + required: ['event_id'], + }, + }, + { + name: 'calendly_list_event_types', + description: 'List available event types from Calendly', + inputSchema: { + type: 'object', + properties: { + active: { type: 'boolean', description: 'Filter by active status' }, + }, + }, + }, +] as const satisfies McpToolDefinition[]; + export const mixedProviderTools = [ { name: 'hibob_list_employees', diff --git a/src/index.ts b/src/index.ts index e32aa7a7..3f5efe6a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ export { BaseTool, StackOneTool, Tools } from './tool'; export { createFeedbackTool } from './feedback'; +export { SemanticSearchClient, SemanticSearchError, normalizeActionName } from './semantic-search'; export { StackOneError } from './utils/error-stackone'; export { StackOneAPIError } from './utils/error-stackone-api'; @@ -14,6 +15,8 @@ export { ToolSetLoadError, type AuthenticationConfig, type BaseToolSetConfig, + type SearchActionNamesOptions, + type SearchToolsOptions, type StackOneToolSetConfig, } from './toolsets'; @@ -27,3 +30,9 @@ export type { ParameterLocation, ToolDefinition, } from './types'; + +export type { + SemanticSearchClientConfig, + SemanticSearchResponse, + SemanticSearchResult, +} from './semantic-search'; diff --git a/src/semantic-search.test.ts b/src/semantic-search.test.ts new file mode 100644 index 00000000..f8065926 --- /dev/null +++ b/src/semantic-search.test.ts @@ -0,0 +1,941 @@ +/** + * Tests for semantic search client and integration. + * Covers: SemanticSearchClient, normalizeActionName, StackOneToolSet integration, + * utility tools with semantic search, connector helpers, and deduplication. + */ + +import { http, HttpResponse } from 'msw'; +import { type McpToolDefinition, createMcpApp } from '../mocks/mcp-server'; +import { server } from '../mocks/node'; +import { normalizeActionName, SemanticSearchClient, SemanticSearchError } from './semantic-search'; +import { StackOneToolSet } from './toolsets'; +import { BaseTool, Tools } from './tool'; + +// ─── Helpers ────────────────────────────────────────────────────────────── + +const BASE_URL = 'https://api.stackone.com'; + +function semanticResult(overrides: { + action_name: string; + connector_key: string; + similarity_score: number; + label?: string; + description?: string; +}): Record { + return { + action_name: overrides.action_name, + connector_key: overrides.connector_key, + similarity_score: overrides.similarity_score, + label: overrides.label ?? 'Label', + description: overrides.description ?? 'Description', + }; +} + +function mockSemanticSearch( + results: Record[], + options?: { query?: string; status?: number }, +): void { + server.use( + http.post(`${BASE_URL}/actions/search`, async ({ request }) => { + if (options?.status && options.status !== 200) { + return HttpResponse.json({ error: 'Error' }, { status: options.status }); + } + const body = (await request.json()) as Record; + return HttpResponse.json({ + results, + total_count: results.length, + query: body.query ?? options?.query ?? '', + }); + }), + ); +} + +function setupMcpTools(tools: McpToolDefinition[]): void { + const app = createMcpApp({ + accountTools: { default: tools, 'acc-123': tools }, + }); + + server.use( + http.all(`${BASE_URL}/mcp`, async ({ request }) => { + const res = await app.request(new Request(request.url, request), undefined, {}); + return new HttpResponse(res.body, { + status: res.status, + headers: Object.fromEntries(res.headers.entries()), + }); + }), + ); +} + +const bamboohrTool: McpToolDefinition = { + name: 'bamboohr_create_employee', + description: 'Creates a new employee in BambooHR', + inputSchema: { type: 'object', properties: {} }, +}; + +const hibobTool: McpToolDefinition = { + name: 'hibob_create_employee', + description: 'Creates a new employee in HiBob', + inputSchema: { type: 'object', properties: {} }, +}; + +const bamboohrListTool: McpToolDefinition = { + name: 'bamboohr_list_employees', + description: 'Lists all employees in BambooHR', + inputSchema: { type: 'object', properties: {} }, +}; + +const workdayTool: McpToolDefinition = { + name: 'workday_create_worker', + description: 'Creates a new worker in Workday', + inputSchema: { type: 'object', properties: {} }, +}; + +// ─── SemanticSearchClient ────────────────────────────────────────────────── + +describe('SemanticSearchClient', () => { + describe('initialization', () => { + it('should initialize with defaults', () => { + const client = new SemanticSearchClient({ apiKey: 'test-key' }); + + expect(client.apiKey).toBe('test-key'); + expect(client.baseUrl).toBe('https://api.stackone.com'); + expect(client.timeout).toBe(30_000); + }); + + it('should initialize with custom base URL', () => { + const client = new SemanticSearchClient({ + apiKey: 'test-key', + baseUrl: 'https://custom.api.com/', + }); + + expect(client.baseUrl).toBe('https://custom.api.com'); + }); + + it('should initialize with custom timeout', () => { + const client = new SemanticSearchClient({ + apiKey: 'test-key', + timeout: 5_000, + }); + + expect(client.timeout).toBe(5_000); + }); + }); + + describe('_buildAuthHeader', () => { + it('should build correct Basic auth header', () => { + const client = new SemanticSearchClient({ apiKey: 'test-key' }); + const header = client._buildAuthHeader(); + + // "test-key:" encoded in base64 = dGVzdC1rZXk6 + expect(header).toBe('Basic dGVzdC1rZXk6'); + }); + }); + + describe('search', () => { + it('should return parsed results on success', async () => { + mockSemanticSearch([ + semanticResult({ + action_name: 'bamboohr_create_employee', + connector_key: 'bamboohr', + similarity_score: 0.92, + label: 'Create Employee', + description: 'Creates a new employee', + }), + ]); + + const client = new SemanticSearchClient({ apiKey: 'test-key' }); + const response = await client.search('create employee', { topK: 5 }); + + expect(response.results).toHaveLength(1); + expect(response.results[0].actionName).toBe('bamboohr_create_employee'); + expect(response.results[0].connectorKey).toBe('bamboohr'); + expect(response.results[0].similarityScore).toBe(0.92); + expect(response.totalCount).toBe(1); + }); + + it('should send connector filter in payload', async () => { + const recordedRequests: Request[] = []; + server.use( + http.post(`${BASE_URL}/actions/search`, async ({ request }) => { + recordedRequests.push(request.clone()); + const body = (await request.json()) as Record; + return HttpResponse.json({ + results: [], + total_count: 0, + query: body.query, + }); + }), + ); + + const client = new SemanticSearchClient({ apiKey: 'test-key' }); + await client.search('create employee', { connector: 'bamboohr', topK: 10 }); + + expect(recordedRequests).toHaveLength(1); + const body = (await recordedRequests[0].json()) as Record; + expect(body).toEqual({ + query: 'create employee', + connector: 'bamboohr', + top_k: 10, + }); + }); + + it('should throw SemanticSearchError on HTTP error', async () => { + mockSemanticSearch([], { status: 401 }); + + const client = new SemanticSearchClient({ apiKey: 'invalid-key' }); + await expect(client.search('create employee')).rejects.toThrow(SemanticSearchError); + await expect(client.search('create employee')).rejects.toThrow(/API error: 401/); + }); + + it('should throw SemanticSearchError on network error', async () => { + server.use( + http.post(`${BASE_URL}/actions/search`, () => { + return HttpResponse.error(); + }), + ); + + const client = new SemanticSearchClient({ apiKey: 'test-key' }); + await expect(client.search('create employee')).rejects.toThrow(SemanticSearchError); + }); + }); + + describe('searchActionNames', () => { + it('should return action names filtered by min score', async () => { + mockSemanticSearch([ + semanticResult({ + action_name: 'bamboohr_create_employee', + connector_key: 'bamboohr', + similarity_score: 0.92, + }), + semanticResult({ + action_name: 'hibob_create_employee', + connector_key: 'hibob', + similarity_score: 0.45, + }), + ]); + + const client = new SemanticSearchClient({ apiKey: 'test-key' }); + + // Without min_score filter + const allNames = await client.searchActionNames('create employee'); + expect(allNames).toHaveLength(2); + expect(allNames).toContain('bamboohr_create_employee'); + expect(allNames).toContain('hibob_create_employee'); + + // With min_score filter + const filteredNames = await client.searchActionNames('create employee', { + minScore: 0.5, + }); + expect(filteredNames).toHaveLength(1); + expect(filteredNames).toContain('bamboohr_create_employee'); + }); + }); +}); + +// ─── normalizeActionName ─────────────────────────────────────────────────── + +describe('normalizeActionName', () => { + it('should normalize versioned API names to MCP format', () => { + expect(normalizeActionName('calendly_1.0.0_calendly_create_scheduling_link_global')).toBe( + 'calendly_create_scheduling_link', + ); + }); + + it('should handle multi-segment semver', () => { + expect(normalizeActionName('breathehr_1.0.1_breathehr_list_employees_global')).toBe( + 'breathehr_list_employees', + ); + }); + + it('should pass through already-normalized names', () => { + expect(normalizeActionName('bamboohr_create_employee')).toBe('bamboohr_create_employee'); + }); + + it('should pass through non-matching names', () => { + expect(normalizeActionName('some_random_tool')).toBe('some_random_tool'); + }); + + it('should handle empty string', () => { + expect(normalizeActionName('')).toBe(''); + }); + + it('should normalize different versions to the same name', () => { + const v1 = normalizeActionName('breathehr_1.0.0_breathehr_list_employees_global'); + const v2 = normalizeActionName('breathehr_1.0.1_breathehr_list_employees_global'); + expect(v1).toBe(v2); + expect(v1).toBe('breathehr_list_employees'); + }); +}); + +// ─── Tools.getConnectors ─────────────────────────────────────────────────── + +describe('Tools.getConnectors', () => { + it('should return unique connector names', () => { + const tools = new Tools([ + new BaseTool( + 'bamboohr_create_employee', + 'desc', + { type: 'object', properties: {} }, + { kind: 'local' }, + ), + new BaseTool( + 'bamboohr_list_employees', + 'desc', + { type: 'object', properties: {} }, + { kind: 'local' }, + ), + new BaseTool( + 'hibob_create_employee', + 'desc', + { type: 'object', properties: {} }, + { kind: 'local' }, + ), + new BaseTool( + 'slack_send_message', + 'desc', + { type: 'object', properties: {} }, + { kind: 'local' }, + ), + ]); + + const connectors = tools.getConnectors(); + expect(connectors).toEqual(new Set(['bamboohr', 'hibob', 'slack'])); + }); + + it('should return empty set for empty tools', () => { + const tools = new Tools([]); + expect(tools.getConnectors()).toEqual(new Set()); + }); +}); + +// ─── StackOneToolSet semantic search integration ─────────────────────────── + +describe('StackOneToolSet semantic search', () => { + beforeEach(() => { + vi.stubEnv('STACKONE_API_KEY', 'test-key'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe('semanticClient', () => { + it('should lazily initialize semantic client', () => { + const toolset = new StackOneToolSet({ apiKey: 'test-key' }); + const client = toolset.semanticClient; + + expect(client).toBeInstanceOf(SemanticSearchClient); + expect(client.apiKey).toBe('test-key'); + + // Same instance on second access + expect(toolset.semanticClient).toBe(client); + }); + }); + + describe('searchTools', () => { + it('should return tools filtered by available connectors', async () => { + // Set up MCP to return bamboohr and hibob tools + setupMcpTools([bamboohrTool, hibobTool, bamboohrListTool]); + + // Semantic search returns results including workday (user doesn't have) + mockSemanticSearch([ + semanticResult({ + action_name: 'bamboohr_1.0.0_bamboohr_create_employee_global', + connector_key: 'bamboohr', + similarity_score: 0.95, + }), + semanticResult({ + action_name: 'workday_1.0.0_workday_create_worker_global', + connector_key: 'workday', + similarity_score: 0.9, + }), + semanticResult({ + action_name: 'hibob_1.0.0_hibob_create_employee_global', + connector_key: 'hibob', + similarity_score: 0.85, + }), + ]); + + const toolset = new StackOneToolSet({ apiKey: 'test-key' }); + const tools = await toolset.searchTools('create employee', { topK: 5 }); + + // Should only return tools for available connectors + const toolNames = tools.map((t) => t.name); + expect(toolNames).toContain('bamboohr_create_employee'); + expect(toolNames).toContain('hibob_create_employee'); + expect(toolNames).not.toContain('workday_create_worker'); + + // Should be sorted by semantic score + expect(tools.toArray()[0].name).toBe('bamboohr_create_employee'); + expect(tools.toArray()[1].name).toBe('hibob_create_employee'); + }); + + it('should fallback to local search when semantic API fails', async () => { + setupMcpTools([bamboohrTool, bamboohrListTool, workdayTool]); + + // Semantic search fails + server.use( + http.post(`${BASE_URL}/actions/search`, () => { + return HttpResponse.json({ error: 'Unavailable' }, { status: 503 }); + }), + ); + + const toolset = new StackOneToolSet({ apiKey: 'test-key' }); + const tools = await toolset.searchTools('create employee', { + topK: 5, + fallbackToLocal: true, + }); + + // Should return results from local BM25+TF-IDF fallback + expect(tools.length).toBeGreaterThan(0); + const toolNames = tools.map((t) => t.name); + // Should only include tools for available connectors + for (const name of toolNames) { + const connector = name.split('_')[0]; + expect(['bamboohr', 'workday']).toContain(connector); + } + }); + + it('should respect fallback_to_local=false', async () => { + setupMcpTools([bamboohrTool]); + + server.use( + http.post(`${BASE_URL}/actions/search`, () => { + return HttpResponse.json({ error: 'Unavailable' }, { status: 503 }); + }), + ); + + const toolset = new StackOneToolSet({ apiKey: 'test-key' }); + await expect( + toolset.searchTools('create employee', { fallbackToLocal: false }), + ).rejects.toThrow(SemanticSearchError); + }); + + it('should fallback with connector filter', async () => { + setupMcpTools([bamboohrTool, bamboohrListTool, workdayTool]); + + server.use( + http.post(`${BASE_URL}/actions/search`, () => { + return HttpResponse.json({ error: 'Unavailable' }, { status: 503 }); + }), + ); + + const toolset = new StackOneToolSet({ apiKey: 'test-key' }); + const tools = await toolset.searchTools('create employee', { + connector: 'bamboohr', + fallbackToLocal: true, + }); + + expect(tools.length).toBeGreaterThan(0); + for (const tool of tools) { + expect(tool.name.split('_')[0]).toBe('bamboohr'); + } + }); + + it('should deduplicate versioned action names', async () => { + setupMcpTools([ + { + name: 'breathehr_list_employees', + description: 'Lists employees', + inputSchema: { type: 'object', properties: {} }, + }, + bamboohrTool, + ]); + + mockSemanticSearch([ + semanticResult({ + action_name: 'breathehr_1.0.0_breathehr_list_employees_global', + connector_key: 'breathehr', + similarity_score: 0.95, + }), + semanticResult({ + action_name: 'breathehr_1.0.1_breathehr_list_employees_global', + connector_key: 'breathehr', + similarity_score: 0.9, + }), + semanticResult({ + action_name: 'bamboohr_1.0.0_bamboohr_create_employee_global', + connector_key: 'bamboohr', + similarity_score: 0.85, + }), + ]); + + const toolset = new StackOneToolSet({ apiKey: 'test-key' }); + const tools = await toolset.searchTools('list employees', { topK: 5 }); + + const toolNames = tools.map((t) => t.name); + // Should deduplicate: both breathehr versions -> breathehr_list_employees + expect(toolNames.filter((n) => n === 'breathehr_list_employees')).toHaveLength(1); + expect(toolNames).toContain('bamboohr_create_employee'); + expect(tools.length).toBe(2); + }); + }); + + describe('searchActionNames', () => { + it('should return normalized action names', async () => { + mockSemanticSearch([ + semanticResult({ + action_name: 'bamboohr_1.0.0_bamboohr_create_employee_global', + connector_key: 'bamboohr', + similarity_score: 0.92, + }), + semanticResult({ + action_name: 'hibob_1.0.0_hibob_create_employee_global', + connector_key: 'hibob', + similarity_score: 0.45, + }), + ]); + + const toolset = new StackOneToolSet({ apiKey: 'test-key' }); + const results = await toolset.searchActionNames('create employee', { + minScore: 0.5, + }); + + expect(results).toHaveLength(1); + expect(results[0].actionName).toBe('bamboohr_create_employee'); + }); + + it('should filter by available connectors when accountIds provided', async () => { + // Set up MCP to return only bamboohr and hibob tools + setupMcpTools([bamboohrTool, hibobTool]); + + // Use a smarter mock that respects the connector parameter + server.use( + http.post(`${BASE_URL}/actions/search`, async ({ request }) => { + const body = (await request.json()) as Record; + const connector = body.connector as string | undefined; + + const allResults = [ + semanticResult({ + action_name: 'bamboohr_1.0.0_bamboohr_create_employee_global', + connector_key: 'bamboohr', + similarity_score: 0.95, + }), + semanticResult({ + action_name: 'workday_1.0.0_workday_create_worker_global', + connector_key: 'workday', + similarity_score: 0.9, + }), + semanticResult({ + action_name: 'hibob_1.0.0_hibob_create_employee_global', + connector_key: 'hibob', + similarity_score: 0.85, + }), + ]; + + // Filter by connector if provided (mimics real API behavior) + const results = connector + ? allResults.filter((r) => (r as Record).connector_key === connector) + : allResults; + + return HttpResponse.json({ + results, + total_count: results.length, + query: body.query, + }); + }), + ); + + const toolset = new StackOneToolSet({ apiKey: 'test-key' }); + const results = await toolset.searchActionNames('create employee', { + accountIds: ['acc-123'], + topK: 10, + }); + + // workday should be filtered out (not in linked accounts) + const actionNames = results.map((r) => r.actionName); + expect(actionNames).toContain('bamboohr_create_employee'); + expect(actionNames).toContain('hibob_create_employee'); + expect(actionNames).not.toContain('workday_create_worker'); + }); + + it('should return empty array when semantic search fails', async () => { + server.use( + http.post(`${BASE_URL}/actions/search`, () => { + return HttpResponse.json({ error: 'Unavailable' }, { status: 503 }); + }), + ); + + const toolset = new StackOneToolSet({ apiKey: 'test-key' }); + const results = await toolset.searchActionNames('create employee'); + + expect(results).toEqual([]); + }); + + it('should deduplicate versioned action names', async () => { + mockSemanticSearch([ + semanticResult({ + action_name: 'breathehr_1.0.0_breathehr_list_employees_global', + connector_key: 'breathehr', + similarity_score: 0.95, + }), + semanticResult({ + action_name: 'breathehr_1.0.1_breathehr_list_employees_global', + connector_key: 'breathehr', + similarity_score: 0.9, + }), + ]); + + const toolset = new StackOneToolSet({ apiKey: 'test-key' }); + const results = await toolset.searchActionNames('list employees', { topK: 5 }); + + // Should deduplicate: only one result for breathehr_list_employees + expect(results).toHaveLength(1); + expect(results[0].actionName).toBe('breathehr_list_employees'); + expect(results[0].similarityScore).toBe(0.95); + }); + + it('should respect topK after filtering', async () => { + mockSemanticSearch( + Array.from({ length: 10 }, (_, i) => + semanticResult({ + action_name: `bamboohr_1.0.0_bamboohr_action_${i}_global`, + connector_key: 'bamboohr', + similarity_score: 0.9 - i * 0.05, + }), + ), + ); + + const toolset = new StackOneToolSet({ apiKey: 'test-key' }); + const results = await toolset.searchActionNames('test', { topK: 3 }); + + expect(results).toHaveLength(3); + expect(results[0].actionName).toBe('bamboohr_action_0'); + }); + }); +}); + +// ─── Semantic utility tool search ────────────────────────────────────────── + +describe('utilityTools with semanticClient', () => { + it('should create semantic tool_search when semanticClient provided', async () => { + const tools = new Tools([ + new BaseTool('test_tool', 'Test', { type: 'object', properties: {} }, { kind: 'local' }), + ]); + + const client = new SemanticSearchClient({ apiKey: 'test-key' }); + const utility = await tools.utilityTools({ semanticClient: client }); + + expect(utility.length).toBe(2); + const searchTool = utility.getTool('tool_search'); + expect(searchTool).toBeDefined(); + expect(searchTool!.description).toContain('semantic'); + }); + + it('should use local search when no semanticClient', async () => { + const tools = new Tools([ + new BaseTool('test_tool', 'Test', { type: 'object', properties: {} }, { kind: 'local' }), + ]); + + const utility = await tools.utilityTools(); + + expect(utility.length).toBe(2); + const searchTool = utility.getTool('tool_search'); + expect(searchTool).toBeDefined(); + expect(searchTool!.description).toContain('BM25'); + }); + + it('should preserve backward compatibility with number arg', async () => { + const tools = new Tools([ + new BaseTool('test_tool', 'Test', { type: 'object', properties: {} }, { kind: 'local' }), + ]); + + // Legacy number argument should still work + const utility = await tools.utilityTools(0.5); + + expect(utility.length).toBe(2); + const searchTool = utility.getTool('tool_search'); + expect(searchTool).toBeDefined(); + expect(searchTool!.description).toContain('alpha=0.5'); + }); + + it('should execute semantic tool_search and return results', async () => { + mockSemanticSearch([ + semanticResult({ + action_name: 'bamboohr_1.0.0_bamboohr_create_employee_global', + connector_key: 'bamboohr', + similarity_score: 0.92, + label: 'Create Employee', + description: 'Creates a new employee', + }), + ]); + + const tools = new Tools([ + new BaseTool('test_tool', 'Test', { type: 'object', properties: {} }, { kind: 'local' }), + ]); + + const client = new SemanticSearchClient({ apiKey: 'test-key' }); + const utility = await tools.utilityTools({ semanticClient: client }); + const searchTool = utility.getTool('tool_search'); + + const result = (await searchTool!.execute({ query: 'create employee', limit: 5 })) as { + tools: Array<{ name: string; score: number; connector: string }>; + }; + + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe('bamboohr_create_employee'); + expect(result.tools[0].score).toBe(0.92); + expect(result.tools[0].connector).toBe('bamboohr'); + }); + + it('should filter by minScore', async () => { + mockSemanticSearch([ + semanticResult({ + action_name: 'high_score_action', + connector_key: 'test', + similarity_score: 0.9, + }), + semanticResult({ + action_name: 'low_score_action', + connector_key: 'test', + similarity_score: 0.3, + }), + ]); + + const tools = new Tools([ + new BaseTool('test_tool', 'Test', { type: 'object', properties: {} }, { kind: 'local' }), + ]); + + const client = new SemanticSearchClient({ apiKey: 'test-key' }); + const utility = await tools.utilityTools({ semanticClient: client }); + const searchTool = utility.getTool('tool_search'); + + const result = (await searchTool!.execute({ + query: 'test', + limit: 10, + minScore: 0.5, + })) as { tools: Array<{ name: string }> }; + + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe('high_score_action'); + }); + + it('should pass connector filter to semantic API', async () => { + const recordedRequests: Request[] = []; + server.use( + http.post(`${BASE_URL}/actions/search`, async ({ request }) => { + recordedRequests.push(request.clone()); + return HttpResponse.json({ + results: [], + total_count: 0, + query: 'create employee', + }); + }), + ); + + const tools = new Tools([ + new BaseTool('test_tool', 'Test', { type: 'object', properties: {} }, { kind: 'local' }), + ]); + + const client = new SemanticSearchClient({ apiKey: 'test-key' }); + const utility = await tools.utilityTools({ semanticClient: client }); + const searchTool = utility.getTool('tool_search'); + + await searchTool!.execute({ query: 'create employee', connector: 'bamboohr' }); + + expect(recordedRequests).toHaveLength(1); + const body = (await recordedRequests[0].json()) as Record; + expect(body.connector).toBe('bamboohr'); + }); + + it('should have connector parameter in schema', async () => { + const tools = new Tools([ + new BaseTool('test_tool', 'Test', { type: 'object', properties: {} }, { kind: 'local' }), + ]); + + const client = new SemanticSearchClient({ apiKey: 'test-key' }); + const utility = await tools.utilityTools({ semanticClient: client }); + const searchTool = utility.getTool('tool_search'); + + expect(searchTool!.parameters.properties).toHaveProperty('query'); + expect(searchTool!.parameters.properties).toHaveProperty('limit'); + expect(searchTool!.parameters.properties).toHaveProperty('minScore'); + expect(searchTool!.parameters.properties).toHaveProperty('connector'); + }); + + it('should deduplicate versioned action names in utility tool', async () => { + mockSemanticSearch([ + semanticResult({ + action_name: 'breathehr_1.0.0_breathehr_list_employees_global', + connector_key: 'breathehr', + similarity_score: 0.95, + }), + semanticResult({ + action_name: 'breathehr_1.0.1_breathehr_list_employees_global', + connector_key: 'breathehr', + similarity_score: 0.9, + }), + ]); + + const tools = new Tools([ + new BaseTool('test_tool', 'Test', { type: 'object', properties: {} }, { kind: 'local' }), + ]); + + const client = new SemanticSearchClient({ apiKey: 'test-key' }); + const utility = await tools.utilityTools({ semanticClient: client }); + const searchTool = utility.getTool('tool_search'); + + const result = (await searchTool!.execute({ + query: 'list employees', + limit: 10, + })) as { tools: Array<{ name: string; score: number }> }; + + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe('breathehr_list_employees'); + expect(result.tools[0].score).toBe(0.95); + }); +}); + +// ─── Semantic search → AI SDK integration ────────────────────────────────── + +describe('Semantic search → AI SDK integration', () => { + beforeEach(() => { + vi.stubEnv('STACKONE_API_KEY', 'test-key'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe('searchTools → toAISDK', () => { + it('should convert semantic search results to AI SDK format', async () => { + setupMcpTools([bamboohrTool, hibobTool, bamboohrListTool]); + + mockSemanticSearch([ + semanticResult({ + action_name: 'bamboohr_1.0.0_bamboohr_create_employee_global', + connector_key: 'bamboohr', + similarity_score: 0.95, + description: 'Creates a new employee in BambooHR', + }), + semanticResult({ + action_name: 'hibob_1.0.0_hibob_create_employee_global', + connector_key: 'hibob', + similarity_score: 0.85, + description: 'Creates a new employee in HiBob', + }), + ]); + + const toolset = new StackOneToolSet({ apiKey: 'test-key' }); + const tools = await toolset.searchTools('create employee', { topK: 5 }); + + const aiSdkTools = await tools.toAISDK(); + + // Should contain matched tools + expect(aiSdkTools).toHaveProperty('bamboohr_create_employee'); + expect(aiSdkTools).toHaveProperty('hibob_create_employee'); + + // Each tool should have correct AI SDK structure + const bamboohrAiTool = aiSdkTools.bamboohr_create_employee; + expect(bamboohrAiTool.description).toBe('Creates a new employee in BambooHR'); + expect(bamboohrAiTool.inputSchema).toBeDefined(); + expect(typeof bamboohrAiTool.execute).toBe('function'); + + const hibobAiTool = aiSdkTools.hibob_create_employee; + expect(hibobAiTool.description).toBe('Creates a new employee in HiBob'); + expect(typeof hibobAiTool.execute).toBe('function'); + }); + + it('should produce executable AI SDK tools from semantic search', async () => { + setupMcpTools([bamboohrTool]); + + mockSemanticSearch([ + semanticResult({ + action_name: 'bamboohr_1.0.0_bamboohr_create_employee_global', + connector_key: 'bamboohr', + similarity_score: 0.95, + }), + ]); + + const toolset = new StackOneToolSet({ apiKey: 'test-key' }); + const tools = await toolset.searchTools('create employee', { topK: 5 }); + + const aiSdkTools = await tools.toAISDK(); + const tool = aiSdkTools.bamboohr_create_employee; + expect(tool.execute).toBeDefined(); + + // Execute through the AI SDK wrapper + const result = await tool.execute?.( + { name: 'test' }, + { toolCallId: 'test-call-id', messages: [] }, + ); + expect(result).toBeDefined(); + }); + + it('should support non-executable mode from semantic search', async () => { + setupMcpTools([bamboohrTool]); + + mockSemanticSearch([ + semanticResult({ + action_name: 'bamboohr_1.0.0_bamboohr_create_employee_global', + connector_key: 'bamboohr', + similarity_score: 0.95, + }), + ]); + + const toolset = new StackOneToolSet({ apiKey: 'test-key' }); + const tools = await toolset.searchTools('create employee'); + + const aiSdkTools = await tools.toAISDK({ executable: false }); + + expect(aiSdkTools.bamboohr_create_employee).toBeDefined(); + expect(aiSdkTools.bamboohr_create_employee.execute).toBeUndefined(); + }); + }); + + describe('utilityTools with semanticClient → toAISDK', () => { + it('should convert semantic utility tools to AI SDK format', async () => { + const tools = new Tools([ + new BaseTool('test_tool', 'Test', { type: 'object', properties: {} }, { kind: 'local' }), + ]); + + const client = new SemanticSearchClient({ apiKey: 'test-key' }); + const utility = await tools.utilityTools({ semanticClient: client }); + + const aiSdkTools = await utility.toAISDK(); + + expect(aiSdkTools).toHaveProperty('tool_search'); + expect(aiSdkTools).toHaveProperty('tool_execute'); + + // tool_search should have execute function + expect(typeof aiSdkTools.tool_search.execute).toBe('function'); + expect(aiSdkTools.tool_search.description).toContain('semantic'); + + // tool_execute should have execute function + expect(typeof aiSdkTools.tool_execute.execute).toBe('function'); + }); + + it('should execute semantic tool_search through AI SDK wrapper', async () => { + mockSemanticSearch([ + semanticResult({ + action_name: 'bamboohr_1.0.0_bamboohr_create_employee_global', + connector_key: 'bamboohr', + similarity_score: 0.92, + label: 'Create Employee', + description: 'Creates a new employee', + }), + ]); + + const tools = new Tools([ + new BaseTool('test_tool', 'Test', { type: 'object', properties: {} }, { kind: 'local' }), + ]); + + const client = new SemanticSearchClient({ apiKey: 'test-key' }); + const utility = await tools.utilityTools({ semanticClient: client }); + + const aiSdkTools = await utility.toAISDK(); + + // Execute tool_search through AI SDK format + const result = await aiSdkTools.tool_search.execute?.( + { query: 'create employee', limit: 5 }, + { toolCallId: 'test-call', messages: [] }, + ); + + expect(result).toBeDefined(); + const searchResult = result as { tools: Array<{ name: string; score: number }> }; + expect(searchResult.tools).toHaveLength(1); + expect(searchResult.tools[0].name).toBe('bamboohr_create_employee'); + expect(searchResult.tools[0].score).toBe(0.92); + }); + }); +}); diff --git a/src/semantic-search.ts b/src/semantic-search.ts new file mode 100644 index 00000000..2ed8e172 --- /dev/null +++ b/src/semantic-search.ts @@ -0,0 +1,220 @@ +/** + * Semantic search client for StackOne action search API. + * + * The SDK provides three ways to discover tools using semantic search: + * + * 1. `searchTools(query)` — Full tool discovery (recommended for agent frameworks) + * Fetches tools from linked accounts, queries semantic API, filters to available + * connectors, deduplicates, and returns matched Tool definitions sorted by relevance. + * + * 2. `searchActionNames(query)` — Lightweight discovery + * Queries the semantic API directly and returns action name metadata without + * fetching full tool definitions. Useful for previewing results. + * + * 3. `utilityTools({ semanticClient })` — Agent-loop search + execute + * Creates a `tool_search` utility tool that agents can call in a loop, + * using cloud-based semantic vectors instead of local BM25 + TF-IDF. + */ + +import { DEFAULT_BASE_URL } from './consts'; + +/** + * Regex to match versioned API action names and extract the MCP-format name. + * Example: "calendly_1.0.0_calendly_create_scheduling_link_global" -> "calendly_create_scheduling_link" + */ +const VERSIONED_ACTION_RE = /^[a-z][a-z0-9]*_\d+(?:\.\d+)+_(.+)_global$/; + +/** + * Normalize a versioned API action name to MCP format. + * Non-matching names pass through unchanged. + */ +export function normalizeActionName(actionName: string): string { + const match = VERSIONED_ACTION_RE.exec(actionName); + return match ? match[1] : actionName; +} + +/** + * Raised when semantic search fails + */ +export class SemanticSearchError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'SemanticSearchError'; + } +} + +/** + * Single result from semantic search API + */ +export interface SemanticSearchResult { + actionName: string; + connectorKey: string; + similarityScore: number; + label: string; + description: string; +} + +/** + * Response from /actions/search endpoint + */ +export interface SemanticSearchResponse { + results: SemanticSearchResult[]; + totalCount: number; + query: string; +} + +/** + * Raw API response shape (snake_case from the backend) + */ +interface RawSemanticSearchResult { + action_name: string; + connector_key: string; + similarity_score: number; + label: string; + description: string; +} + +interface RawSemanticSearchResponse { + results: RawSemanticSearchResult[]; + total_count: number; + query: string; +} + +/** + * Configuration for SemanticSearchClient + */ +export interface SemanticSearchClientConfig { + apiKey: string; + baseUrl?: string; + timeout?: number; +} + +/** + * Client for StackOne semantic search API. + * + * Uses enhanced embeddings on the backend for higher accuracy than local BM25+TF-IDF search. + * + * @example + * ```typescript + * 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)}`); + * } + * ``` + */ +export class SemanticSearchClient { + readonly apiKey: string; + readonly baseUrl: string; + readonly timeout: number; + + constructor(config: SemanticSearchClientConfig) { + this.apiKey = config.apiKey; + this.baseUrl = (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ''); + this.timeout = config.timeout ?? 30_000; + } + + /** + * Build the Basic auth header + */ + _buildAuthHeader(): string { + const token = Buffer.from(`${this.apiKey}:`).toString('base64'); + return `Basic ${token}`; + } + + /** + * Search for relevant actions using semantic search. + * + * @param query - Natural language query describing what tools/actions you need + * @param options - Optional search parameters + * @returns SemanticSearchResponse containing matching actions with similarity scores + * @throws SemanticSearchError if the API call fails + */ + async search( + query: string, + options?: { connector?: string; topK?: number }, + ): Promise { + const url = `${this.baseUrl}/actions/search`; + const headers: Record = { + Authorization: this._buildAuthHeader(), + 'Content-Type': 'application/json', + }; + + const payload: Record = { query }; + if (options?.topK != null) { + payload.top_k = options.topK; + } + if (options?.connector) { + payload.connector = options.connector; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(payload), + signal: controller.signal, + }); + + if (!response.ok) { + const text = await response.text(); + throw new SemanticSearchError(`API error: ${response.status} - ${text}`); + } + + const data = (await response.json()) as RawSemanticSearchResponse; + return parseSemanticSearchResponse(data); + } catch (error) { + if (error instanceof SemanticSearchError) { + throw error; + } + if (error instanceof DOMException && error.name === 'AbortError') { + throw new SemanticSearchError('Request timed out', { cause: error }); + } + throw new SemanticSearchError( + `Request failed: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } finally { + clearTimeout(timeoutId); + } + } + + /** + * Convenience method returning just action names. + * + * @param query - Natural language query + * @param options - Optional parameters including connector filter and min score + * @returns List of action names sorted by relevance + */ + async searchActionNames( + query: string, + options?: { connector?: string; topK?: number; minScore?: number }, + ): Promise { + const response = await this.search(query, { + connector: options?.connector, + topK: options?.topK, + }); + const minScore = options?.minScore ?? 0; + return response.results.filter((r) => r.similarityScore >= minScore).map((r) => r.actionName); + } +} + +/** + * Parse raw snake_case API response into camelCase interface + */ +function parseSemanticSearchResponse(raw: RawSemanticSearchResponse): SemanticSearchResponse { + return { + results: raw.results.map((r) => ({ + actionName: r.action_name, + connectorKey: r.connector_key, + similarityScore: r.similarity_score, + label: r.label, + description: r.description, + })), + totalCount: raw.total_count, + query: raw.query, + }; +} diff --git a/src/tool.ts b/src/tool.ts index e7093b40..1a74f4f8 100644 --- a/src/tool.ts +++ b/src/tool.ts @@ -8,6 +8,7 @@ import type { OverrideProperties } from 'type-fest'; import { peerDependencies } from '../package.json'; import { DEFAULT_HYBRID_ALPHA } from './consts'; import { RequestBuilder } from './requestBuilder'; +import { type SemanticSearchClient, normalizeActionName } from './semantic-search'; import type { AISDKToolDefinition, AISDKToolResult, @@ -492,17 +493,53 @@ export class Tools implements Iterable { return new Tools(this.tools.filter(predicate)); } + /** + * Get unique connector names from all tools. + * Extracts the connector prefix (first segment before '_') from each tool name. + * @returns Set of connector names (lowercase) + * @remarks The Python SDK also exposes a per-tool `.connector` property on each tool. + * This collection-level method covers the same use cases; a per-tool property can be + * added if needed. + */ + getConnectors(): Set { + const connectors = new Set(); + for (const tool of this.tools) { + // Skip internal/utility tools (e.g. tool_search, tool_execute, tool_feedback) + if (tool.name.startsWith('tool_')) continue; + const connector = tool.name.split('_')[0]?.toLowerCase(); + if (connector) { + connectors.add(connector); + } + } + return connectors; + } + /** * Return utility tools for tool discovery and execution * @beta This feature is in beta and may change in future versions - * @param hybridAlpha - Weight for BM25 in hybrid search (0-1). If not provided, uses DEFAULT_HYBRID_ALPHA (0.2). + * @param optionsOrHybridAlpha - Either a number (legacy hybridAlpha) or an options object */ - async utilityTools(hybridAlpha = DEFAULT_HYBRID_ALPHA): Promise { - const oramaDb = await initializeOramaDb(this.tools); - const tfidfIndex = initializeTfidfIndex(this.tools); - const baseTools = [toolSearch(oramaDb, tfidfIndex, this.tools, hybridAlpha), toolExecute(this)]; - const tools = new Tools(baseTools); - return tools; + async utilityTools( + optionsOrHybridAlpha?: number | { hybridAlpha?: number; semanticClient?: SemanticSearchClient }, + ): Promise { + const resolved = + typeof optionsOrHybridAlpha === 'number' + ? { hybridAlpha: optionsOrHybridAlpha, semanticClient: undefined } + : { + hybridAlpha: optionsOrHybridAlpha?.hybridAlpha ?? DEFAULT_HYBRID_ALPHA, + semanticClient: optionsOrHybridAlpha?.semanticClient, + }; + + const searchToolInstance = resolved.semanticClient + ? semanticToolSearch(resolved.semanticClient) + : await (async (): Promise => { + const oramaDb = await initializeOramaDb(this.tools); + const tfidfIndex = initializeTfidfIndex(this.tools); + return toolSearch(oramaDb, tfidfIndex, this.tools, resolved.hybridAlpha); + })(); + + const baseTools = [searchToolInstance, toolExecute(this)]; + return new Tools(baseTools); } /** @@ -839,3 +876,101 @@ function toolExecute(tools: Tools): BaseTool { }; return tool; } + +/** + * Create a semantic search variant of tool_search. + * Uses cloud semantic search API instead of local BM25+TF-IDF. + */ +function semanticToolSearch(semanticClient: SemanticSearchClient): BaseTool { + const name = 'tool_search' as const; + const description = + 'Searches for relevant tools based on a natural language query using semantic vector search. Call this first to discover available tools before executing them.' as const; + const parameters = { + type: 'object', + properties: { + query: { + type: 'string', + description: + 'Natural language query describing what tools you need (e.g., "onboard a new team member", "request vacation days")', + }, + limit: { + type: 'number', + description: 'Maximum number of tools to return (default: 5)', + default: 5, + }, + minScore: { + type: 'number', + description: 'Minimum similarity score (0-1) to filter results (default: 0.0)', + default: 0.0, + }, + connector: { + type: 'string', + description: "Optional: filter by connector/provider (e.g., 'bamboohr', 'slack')", + }, + }, + required: ['query'], + // Note: The Python SDK sets `nullable: true` on limit, minScore, and connector. + // Omitted here as LLMs can simply omit optional params and defaults handle it. + } as const satisfies ToolParameters; + + const executeConfig = { + kind: 'local', + identifier: name, + description: 'local://semantic-tool-search', + } as const satisfies LocalExecuteConfig; + + const tool = new BaseTool(name, description, parameters, executeConfig); + tool.execute = async (inputParams?: JsonObject | string): Promise => { + try { + if ( + inputParams !== undefined && + typeof inputParams !== 'string' && + typeof inputParams !== 'object' + ) { + throw new StackOneError( + `Invalid parameters type. Expected object or string, got ${typeof inputParams}. Parameters: ${JSON.stringify(inputParams)}`, + ); + } + + const params = typeof inputParams === 'string' ? JSON.parse(inputParams) : inputParams || {}; + const limit = (params.limit as number) || 5; + const minScore = (params.minScore as number) ?? 0; + const query = (params.query as string) || ''; + const connector = params.connector as string | undefined; + + const response = await semanticClient.search(query, { + connector, + topK: limit, + }); + + const seen = new Set(); + // Result shape intentionally omits `parameters` (unlike local tool_search) to match + // the Python SDK's create_semantic_tool_search. The semantic API doesn't return schemas. + const toolsData: Array> = []; + for (const r of response.results) { + if (r.similarityScore >= minScore) { + const normName = normalizeActionName(r.actionName); + if (!seen.has(normName)) { + seen.add(normName); + toolsData.push({ + name: normName, + description: r.description, + score: r.similarityScore, + connector: r.connectorKey, + }); + } + } + } + + return JSON.parse(JSON.stringify({ tools: toolsData.slice(0, limit) })) satisfies JsonObject; + } catch (error) { + if (error instanceof StackOneError) { + throw error; + } + throw new StackOneError( + `Error executing tool: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }; + return tool; +} diff --git a/src/toolsets.ts b/src/toolsets.ts index 9405e894..76563ce4 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -5,6 +5,13 @@ import { createFeedbackTool } from './feedback'; import { type StackOneHeaders, normalizeHeaders, stackOneHeadersSchema } from './headers'; import { createMCPClient } from './mcp-client'; import { type RpcActionResponse, RpcClient } from './rpc-client'; +import { + SemanticSearchClient, + SemanticSearchError, + type SemanticSearchResponse, + type SemanticSearchResult, + normalizeActionName, +} from './semantic-search'; import { BaseTool, Tools } from './tool'; import type { ExecuteOptions, @@ -130,6 +137,36 @@ interface StackOneToolSetBaseConfig extends BaseToolSetConfig { */ export type StackOneToolSetConfig = StackOneToolSetBaseConfig & Partial; +/** + * Options for searchTools() + */ +export interface SearchToolsOptions { + /** Optional provider/connector filter (e.g., "bamboohr", "slack") */ + connector?: string; + /** Maximum number of tools to return. If omitted, the backend decides how many results to return. */ + topK?: number; + /** Minimum similarity score threshold 0-1 (default: 0.0) */ + minScore?: number; + /** Optional account IDs (uses setAccounts() value if not provided) */ + accountIds?: string[]; + /** If true, fall back to local BM25+TF-IDF search on API failure (default: true) */ + fallbackToLocal?: boolean; +} + +/** + * Options for searchActionNames() + */ +export interface SearchActionNamesOptions { + /** Optional provider/connector filter (single connector) */ + connector?: string; + /** Optional account IDs to scope results to connectors available in those accounts */ + accountIds?: string[]; + /** Maximum number of results. If omitted, the backend decides how many results to return. */ + topK?: number; + /** Minimum similarity score threshold 0-1 (default: 0.0) */ + minScore?: number; +} + /** * Options for filtering tools when fetching from MCP */ @@ -163,6 +200,8 @@ export class StackOneToolSet { private authentication?: AuthenticationConfig; private headers: Record; private rpcClient?: RpcClient; + private apiKey?: string; + private _semanticClient?: SemanticSearchClient; /** * Account ID for StackOne API @@ -216,6 +255,7 @@ export class StackOneToolSet { this.authentication = authentication; this.headers = configHeaders; this.rpcClient = config?.rpcClient; + this.apiKey = apiKey; this.accountId = accountId; this.accountIds = config?.accountIds ?? []; @@ -265,6 +305,276 @@ export class StackOneToolSet { return this; } + /** + * Lazy initialization of semantic search client. + * Configured with the toolset's API key and base URL. + */ + get semanticClient(): SemanticSearchClient { + if (!this._semanticClient) { + if (!this.apiKey) { + throw new ToolSetConfigError( + 'API key is required for semantic search. Set STACKONE_API_KEY environment variable or pass apiKey in config.', + ); + } + this._semanticClient = new SemanticSearchClient({ + apiKey: this.apiKey, + baseUrl: this.baseUrl, + }); + } + return this._semanticClient; + } + + /** + * Search for and fetch tools using semantic search. + * + * Uses the StackOne semantic search API to find relevant tools based on natural + * language queries. Optimizes results by filtering to only connectors available + * in linked accounts. + * + * @param query - Natural language description of needed functionality + * @param options - Search options + * @returns Tools collection with semantically matched tools from linked accounts + */ + async searchTools(query: string, options?: SearchToolsOptions): Promise { + const topK = options?.topK; + const minScore = options?.minScore ?? 0; + const connector = options?.connector; + const fallbackToLocal = options?.fallbackToLocal ?? true; + const accountIds = options?.accountIds; + + let allTools: Tools | undefined; + try { + // Step 1: Fetch all tools to get available connectors from linked accounts + allTools = await this.fetchTools({ accountIds }); + const availableConnectors = allTools.getConnectors(); + + if (availableConnectors.size === 0) { + return new Tools([]); + } + + // Step 2: Query semantic search API + // topK is intentionally omitted here (matching Python SDK) to let the backend + // return its default set; client-side filtering + per-connector fallback handle sizing. + const response = await this.semanticClient.search(query, { connector }); + + // Step 3: Filter results to only available connectors and min_score + let filteredResults = response.results.filter( + (r) => + availableConnectors.has(r.connectorKey.toLowerCase()) && r.similarityScore >= minScore, + ); + + // Step 3b: If not enough results, make per-connector calls for missing connectors + if (!connector && (topK == null || filteredResults.length < topK)) { + const foundConnectors = new Set(filteredResults.map((r) => r.connectorKey.toLowerCase())); + const missingConnectors = new Set( + [...availableConnectors].filter((c) => !foundConnectors.has(c)), + ); + + for (const missing of missingConnectors) { + if (topK != null && filteredResults.length >= topK) break; + try { + const extra = await this.semanticClient.search(query, { + connector: missing, + topK, + }); + for (const r of extra.results) { + if ( + r.similarityScore >= minScore && + !filteredResults.some((fr) => fr.actionName === r.actionName) + ) { + filteredResults.push(r); + if (topK != null && filteredResults.length >= topK) break; + } + } + } catch (error) { + if (error instanceof SemanticSearchError) continue; + throw error; + } + } + + // Re-sort by score after merging results from multiple calls + filteredResults.sort((a, b) => b.similarityScore - a.similarityScore); + } + + // Deduplicate by normalized MCP name (keep highest score first, already sorted) + const seenNames = new Set(); + const deduped: SemanticSearchResult[] = []; + for (const r of filteredResults) { + const norm = normalizeActionName(r.actionName); + if (!seenNames.has(norm)) { + seenNames.add(norm); + deduped.push(r); + } + } + const finalResults = topK != null ? deduped.slice(0, topK) : deduped; + + if (finalResults.length === 0) { + return new Tools([]); + } + + // Step 4: Get matching tools from already-fetched tools + const actionNames = new Set(finalResults.map((r) => normalizeActionName(r.actionName))); + const matchedTools = allTools.toArray().filter((t) => actionNames.has(t.name)); + + // Sort matched tools by semantic search score order + const actionOrder = new Map( + finalResults.map((r, i) => [normalizeActionName(r.actionName), i]), + ); + matchedTools.sort( + (a, b) => + (actionOrder.get(a.name) ?? Number.POSITIVE_INFINITY) - + (actionOrder.get(b.name) ?? Number.POSITIVE_INFINITY), + ); + + return new Tools(matchedTools); + } catch (error) { + if (!(error instanceof SemanticSearchError)) throw error; + if (!fallbackToLocal) throw error; + + // Fallback to local BM25+TF-IDF search + // Note: The Python SDK logs a warning here via logger.warning(). A similar + // logging mechanism can be added when a logging strategy is established. + // allTools may not be defined if fetchTools failed before semantic search + if (!allTools) { + throw error; + } + + const availableConnectors = allTools.getConnectors(); + const utility = await allTools.utilityTools(); + const searchTool = utility.getTool('tool_search'); + + if (searchTool) { + const fallbackLimit = topK != null ? topK * 3 : 100; // Over-fetch to account for connector filtering + const result = await searchTool.execute({ + query, + limit: fallbackLimit, + minScore, + }); + const matchedNames: string[] = ( + (result as { tools?: Array<{ name: string }> }).tools ?? [] + ).map((t) => t.name); + + // Filter by available connectors and preserve relevance order + const toolMap = new Map(allTools.toArray().map((t) => [t.name, t])); + const filterConnectors = connector + ? new Set([connector.toLowerCase()]) + : availableConnectors; + const matched = matchedNames + .filter( + (name) => + toolMap.has(name) && filterConnectors.has(name.split('_')[0]?.toLowerCase() ?? ''), + ) + .map((name) => toolMap.get(name)!); + return new Tools(topK != null ? matched.slice(0, topK) : matched); + } + + return allTools; + } + } + + /** + * Search for action names without fetching full tool definitions. + * + * Useful when you need to inspect search results before fetching, + * or when building custom filtering logic. + * + * @param query - Natural language description of needed functionality + * @param options - Search options + * @returns List of SemanticSearchResult with action names, scores, and metadata + */ + async searchActionNames( + query: string, + options?: SearchActionNamesOptions, + ): Promise { + const topK = options?.topK; + const minScore = options?.minScore ?? 0; + const connector = options?.connector; + + // Resolve available connectors from account_ids + let availableConnectors: Set | undefined; + const effectiveAccountIds = options?.accountIds ?? this.accountIds; + if (effectiveAccountIds.length > 0) { + const allTools = await this.fetchTools({ accountIds: effectiveAccountIds }); + availableConnectors = allTools.getConnectors(); + if (availableConnectors.size === 0) { + return []; + } + } + + let response: SemanticSearchResponse; + try { + response = await this.semanticClient.search(query, { + connector, + topK: availableConnectors ? undefined : topK, + }); + } catch (error) { + // Note: The Python SDK logs a warning here. Silent return matches current + // SDK conventions; add logging when a strategy is established. + if (error instanceof SemanticSearchError) { + return []; + } + throw error; + } + + // Filter by min_score + let results = response.results.filter((r) => r.similarityScore >= minScore); + + // Filter by available connectors if resolved from accounts + if (availableConnectors) { + const connectorSet = new Set([...availableConnectors].map((c) => c.toLowerCase())); + results = results.filter((r) => connectorSet.has(r.connectorKey.toLowerCase())); + + // If not enough results, make per-connector calls for missing connectors + if (!connector && (topK == null || results.length < topK)) { + const foundConnectors = new Set(results.map((r) => r.connectorKey.toLowerCase())); + const missingConnectors = [...connectorSet].filter((c) => !foundConnectors.has(c)); + + for (const missing of missingConnectors) { + if (topK != null && results.length >= topK) break; + try { + const extra = await this.semanticClient.search(query, { + connector: missing, + topK, + }); + for (const r of extra.results) { + if ( + r.similarityScore >= minScore && + !results.some((er) => er.actionName === r.actionName) + ) { + results.push(r); + if (topK != null && results.length >= topK) break; + } + } + } catch (error) { + if (error instanceof SemanticSearchError) continue; + throw error; + } + } + + // Re-sort by score after merging + results.sort((a, b) => b.similarityScore - a.similarityScore); + } + } + + // Normalize and deduplicate by MCP name (keep highest score first) + const seen = new Set(); + const normalized: SemanticSearchResult[] = []; + for (const r of results) { + const normName = normalizeActionName(r.actionName); + if (!seen.has(normName)) { + seen.add(normName); + normalized.push({ + actionName: normName, + connectorKey: r.connectorKey, + similarityScore: r.similarityScore, + label: r.label, + description: r.description, + }); + } + } + return topK != null ? normalized.slice(0, topK) : normalized; + } + /** * Fetch tools from MCP with optional filtering * @param options Optional filtering options for account IDs, providers, and actions