diff --git a/README.md b/README.md index 2d97d40..fdc81cc 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,6 @@ bun add @stackone/ai zod import { StackOneToolSet } from '@stackone/ai'; const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone.com', accountId: 'your-account-id', }); @@ -97,7 +96,6 @@ import { OpenAI } from 'openai'; import { StackOneToolSet } from '@stackone/ai'; const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone.com', accountId: 'your-account-id', }); @@ -135,7 +133,6 @@ import OpenAI from 'openai'; import { StackOneToolSet } from '@stackone/ai'; const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone.com', accountId: 'your-account-id', }); @@ -167,7 +164,6 @@ import Anthropic from '@anthropic-ai/sdk'; import { StackOneToolSet } from '@stackone/ai'; const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone.com', accountId: 'your-account-id', }); @@ -206,7 +202,6 @@ import { generateText } from 'ai'; import { StackOneToolSet } from '@stackone/ai'; const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone.com', accountId: 'your-account-id', }); @@ -237,7 +232,6 @@ import { z } from 'zod'; import { StackOneToolSet } from '@stackone/ai'; const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone.com', accountId: 'your-account-id', }); @@ -285,7 +279,6 @@ import { query } from '@anthropic-ai/claude-agent-sdk'; import { StackOneToolSet } from '@stackone/ai'; const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone.com', accountId: 'your-account-id', }); @@ -355,77 +348,60 @@ This is especially useful when you want to: [View full example](examples/fetch-tools.ts) -### Utility Tools (Beta) +### Search Tool -Utility tools enable dynamic tool discovery and execution, allowing AI agents to search for relevant tools based on natural language queries without hardcoding tool names. - -> **Beta Feature**: Utility tools are currently in beta and the API may change in future versions. - -#### How Utility Tools Work - -Utility tools provide two core capabilities: - -1. **Tool Discovery** (`tool_search`): Search for tools using natural language queries -2. **Tool Execution** (`tool_execute`): Execute discovered tools dynamically +Search for tools using natural language queries. Works with both semantic (cloud) and local BM25+TF-IDF search. #### Basic Usage ```typescript import { StackOneToolSet } from '@stackone/ai'; -const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone.com', -}); -const tools = await toolset.fetchTools(); +// Get a callable search tool +const toolset = new StackOneToolSet({ accountId: 'your-account-id' }); +const searchTool = toolset.getSearchTool(); -// Get utility tools for dynamic discovery -const utilityTools = await tools.utilityTools(); +// Search for relevant tools — returns a Tools collection +const tools = await searchTool.search('manage employees', { topK: 5 }); -// Use with OpenAI -const openAITools = utilityTools.toOpenAI(); - -// Use with AI SDK -const aiSdkTools = await utilityTools.toAISDK(); +// Execute a discovered tool directly +const listTool = tools.getTool('bamboohr_list_employees'); +const result = await listTool.execute({ query: { limit: 10 } }); ``` -#### Example: Dynamic Tool Discovery with AI SDK +### Semantic Search + +Discover tools using natural language instead of exact names. Queries like "onboard new hire" resolve to the right actions even when the tool is called `bamboohr_create_employee`. ```typescript -import { generateText } from 'ai'; -import { openai } from '@ai-sdk/openai'; +import { StackOneToolSet } from '@stackone/ai'; -const { text } = await generateText({ - model: openai('gpt-5.1'), - tools: aiSdkTools, - prompt: 'Find tools for managing employees and create a time off request', - maxSteps: 3, // Allow multiple tool calls -}); +const toolset = new StackOneToolSet({ accountId: 'your-account-id' }); + +// Search by intent — returns Tools collection ready for any framework +const tools = await toolset.searchTools('manage employee records', { topK: 5 }); +const openAITools = tools.toOpenAI(); + +// Lightweight: inspect results without fetching full tool definitions +const results = await toolset.searchActionNames('time off requests', { topK: 5 }); ``` -#### Direct Usage Without AI +#### Search Modes + +Control which search backend `searchTools()` uses via the `search` option: ```typescript -// Step 1: Discover relevant tools -const filterTool = utilityTools.getTool('tool_search'); -const searchResult = await filterTool.execute({ - query: 'employee time off vacation', - limit: 5, - minScore: 0.3, // Minimum relevance score (0-1) -}); +// 'auto' (default) — tries semantic search first, falls back to local +const tools = await toolset.searchTools('manage employees', { search: 'auto' }); -// Step 2: Execute a discovered tool -const executeTool = utilityTools.getTool('tool_execute'); -const result = await executeTool.execute({ - toolName: 'bamboohr_create_time_off', - params: { - employeeId: 'emp_123', - startDate: '2024-01-15', - endDate: '2024-01-19', - }, -}); +// 'semantic' — semantic API only, throws if unavailable +const tools = await toolset.searchTools('manage employees', { search: 'semantic' }); + +// 'local' — local BM25+TF-IDF only, no semantic API call +const tools = await toolset.searchTools('manage employees', { search: 'local' }); ``` -[View full example](examples/utility-tools.ts) +Results are automatically scoped to connectors in your linked accounts. See [Search Tools Example](examples/search-tools.ts) for `SearchTool` (`getSearchTool`) integration, AI SDK, and agent loop patterns. ### Custom Base URL @@ -443,9 +419,7 @@ You can use the `dryRun` option to return the api arguments from a tool call wit import { StackOneToolSet } from '@stackone/ai'; // Initialize the toolset -const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone.com', -}); +const toolset = new StackOneToolSet(); const tools = await toolset.fetchTools(); const employeeTool = tools.getTool('bamboohr_list_employees'); @@ -492,9 +466,7 @@ The feedback tool is automatically available when using `StackOneToolSet`: ```typescript import { StackOneToolSet } from '@stackone/ai'; -const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone.com', -}); +const toolset = new StackOneToolSet(); const tools = await toolset.fetchTools(); // The feedback tool is automatically included diff --git a/examples/README.md b/examples/README.md index c859461..b2b00c5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -154,6 +154,25 @@ Shows how to implement human-in-the-loop workflows for validation. - **API Calls**: Conditional - **Key Features**: Manual approval workflows, UI integration patterns +### Semantic Search + +#### [`search-tools.ts`](./search-tools.ts) - Semantic Tool Search + +Demonstrates dynamic tool discovery using semantic search. Includes four examples: + +1. **Semantic search + AI SDK** — search for tools by natural language query, then use them with `generateText` +2. **SearchTool for agent loops** — reusable search tool for multi-step agent workflows +3. **Lightweight action name search** — search action names without fetching full tool definitions +4. **Local-only search** — BM25+TF-IDF search with no API call to the semantic search endpoint + +```bash +# Run without OpenAI (examples 2-4) +npx tsx examples/search-tools.ts + +# Run all 4 examples +OPENAI_API_KEY=your-key npx tsx examples/search-tools.ts +``` + ### OpenAPI Toolset Examples #### [`openapi-toolset.ts`](./openapi-toolset.ts) - OpenAPI Integration diff --git a/examples/ai-sdk-integration.test.ts b/examples/ai-sdk-integration.test.ts index 79239b8..93169b3 100644 --- a/examples/ai-sdk-integration.test.ts +++ b/examples/ai-sdk-integration.test.ts @@ -6,6 +6,7 @@ import { openai } from '@ai-sdk/openai'; import { generateText, stepCountIs } from 'ai'; +import { TEST_BASE_URL } from '../mocks/constants'; import { StackOneToolSet } from '../src'; describe('ai-sdk-integration example e2e', () => { @@ -21,7 +22,7 @@ describe('ai-sdk-integration example e2e', () => { it('should fetch tools, convert to AI SDK format, and generate text with tool calls', async () => { const toolset = new StackOneToolSet({ accountId: 'your-bamboohr-account-id', - baseUrl: 'https://api.stackone.com', + baseUrl: TEST_BASE_URL, }); // Fetch all tools for this account via MCP diff --git a/examples/ai-sdk-integration.ts b/examples/ai-sdk-integration.ts index c44aabc..bcb2dd8 100644 --- a/examples/ai-sdk-integration.ts +++ b/examples/ai-sdk-integration.ts @@ -22,15 +22,9 @@ if (!apiKey) { process.exit(1); } -// Replace with your actual account ID from StackOne dashboard -const accountId = 'your-bamboohr-account-id'; - const aiSdkIntegration = async (): Promise => { - // Initialize StackOne - const toolset = new StackOneToolSet({ - accountId, - baseUrl: process.env.STACKONE_BASE_URL ?? 'https://api.stackone.com', - }); + // Initialize StackOne — reads STACKONE_API_KEY and STACKONE_ACCOUNT_ID from env + const toolset = new StackOneToolSet(); // Fetch all tools for this account via MCP const tools = await toolset.fetchTools(); diff --git a/examples/anthropic-integration.ts b/examples/anthropic-integration.ts index 4003fc2..665037d 100644 --- a/examples/anthropic-integration.ts +++ b/examples/anthropic-integration.ts @@ -13,15 +13,9 @@ if (!apiKey) { process.exit(1); } -// Replace with your actual account ID from StackOne dashboard -const accountId = 'your-hris-account-id'; - const anthropicIntegration = async (): Promise => { - // Initialize StackOne - const toolset = new StackOneToolSet({ - accountId, - baseUrl: process.env.STACKONE_BASE_URL ?? 'https://api.stackone.com', - }); + // Initialize StackOne — reads STACKONE_API_KEY and STACKONE_ACCOUNT_ID from env + const toolset = new StackOneToolSet(); // Filter for any relevant tools const tools = await toolset.fetchTools({ diff --git a/examples/claude-agent-sdk-integration.test.ts b/examples/claude-agent-sdk-integration.test.ts index ab21408..8a3b7d1 100644 --- a/examples/claude-agent-sdk-integration.test.ts +++ b/examples/claude-agent-sdk-integration.test.ts @@ -11,6 +11,7 @@ import { tool, createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk'; import { z } from 'zod'; +import { TEST_BASE_URL } from '../mocks/constants'; import { StackOneToolSet } from '../src'; describe('claude-agent-sdk-integration example e2e', () => { @@ -25,7 +26,7 @@ describe('claude-agent-sdk-integration example e2e', () => { it('should fetch tools and create Claude Agent SDK tool wrapper', async () => { const toolset = new StackOneToolSet({ accountId: 'your-bamboohr-account-id', - baseUrl: 'https://api.stackone.com', + baseUrl: TEST_BASE_URL, }); // Fetch all tools for this account via MCP @@ -61,7 +62,7 @@ describe('claude-agent-sdk-integration example e2e', () => { it('should create MCP server with StackOne tools', async () => { const toolset = new StackOneToolSet({ accountId: 'your-bamboohr-account-id', - baseUrl: 'https://api.stackone.com', + baseUrl: TEST_BASE_URL, }); const tools = await toolset.fetchTools(); @@ -99,7 +100,7 @@ describe('claude-agent-sdk-integration example e2e', () => { it('should execute tool handler directly', async () => { const toolset = new StackOneToolSet({ accountId: 'your-bamboohr-account-id', - baseUrl: 'https://api.stackone.com', + baseUrl: TEST_BASE_URL, }); const tools = await toolset.fetchTools(); diff --git a/examples/claude-agent-sdk-integration.ts b/examples/claude-agent-sdk-integration.ts index 5e8fd22..0a61bde 100644 --- a/examples/claude-agent-sdk-integration.ts +++ b/examples/claude-agent-sdk-integration.ts @@ -19,15 +19,9 @@ if (!apiKey) { process.exit(1); } -// Replace with your actual account ID from StackOne dashboard -const accountId = 'your-hris-account-id'; - const claudeAgentSdkIntegration = async (): Promise => { - // Initialize StackOne - const toolset = new StackOneToolSet({ - accountId, - baseUrl: process.env.STACKONE_BASE_URL ?? 'https://api.stackone.com', - }); + // Initialize StackOne — reads STACKONE_API_KEY and STACKONE_ACCOUNT_ID from env + const toolset = new StackOneToolSet(); // Fetch tools from StackOne and convert to Claude Agent SDK format const tools = await toolset.fetchTools(); diff --git a/examples/fetch-tools-debug.ts b/examples/fetch-tools-debug.ts index 5195771..1d64406 100644 --- a/examples/fetch-tools-debug.ts +++ b/examples/fetch-tools-debug.ts @@ -1,5 +1,5 @@ /** - * Interactive CLI Demo + * Fetch Tools Debug CLI * * This example demonstrates how to build an interactive CLI tool using * @clack/prompts to dynamically discover and execute StackOne tools. @@ -11,7 +11,7 @@ * * Run with: * ```bash - * node --env-files=.env examples/interactive-cli.ts + * npx tsx examples/fetch-tools-debug.ts * ``` */ diff --git a/examples/fetch-tools.test.ts b/examples/fetch-tools.test.ts index 4df8798..9dfdf57 100644 --- a/examples/fetch-tools.test.ts +++ b/examples/fetch-tools.test.ts @@ -6,6 +6,7 @@ import { http, HttpResponse } from 'msw'; import { server } from '../mocks/node'; +import { TEST_BASE_URL } from '../mocks/constants'; import { StackOneToolSet } from '../src'; describe('fetch-tools example e2e', () => { @@ -20,7 +21,7 @@ describe('fetch-tools example e2e', () => { it('should fetch tools, filter by various criteria, and execute a tool', async () => { // Setup RPC handler for tool execution server.use( - http.post('https://api.stackone.com/actions/rpc', async ({ request }) => { + http.post(`${TEST_BASE_URL}/actions/rpc`, async ({ request }) => { const body: unknown = await request.json(); assert(typeof body === 'object' && body !== null); const { action } = body as Record; @@ -40,7 +41,7 @@ describe('fetch-tools example e2e', () => { ); const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone.com', + baseUrl: TEST_BASE_URL, }); // Example 1: Fetch all tools (without account filter) diff --git a/examples/fetch-tools.ts b/examples/fetch-tools.ts index 0f4bc98..b5caebe 100644 --- a/examples/fetch-tools.ts +++ b/examples/fetch-tools.ts @@ -1,7 +1,7 @@ /** * Example: fetch the latest StackOne tool catalog with filtering options. * - * Set `STACKONE_API_KEY` (and optionally `STACKONE_BASE_URL`) before running. + * Set `STACKONE_API_KEY` before running. * By default the script exits early in test environments where a real key is * not available. */ @@ -15,9 +15,7 @@ if (!apiKey) { process.exit(1); } -const toolset = new StackOneToolSet({ - baseUrl: process.env.STACKONE_BASE_URL ?? 'https://api.stackone.com', -}); +const toolset = new StackOneToolSet({}); // Example 1: Fetch all tools console.log('\n=== Example 1: Fetch all tools ==='); @@ -33,9 +31,9 @@ console.log(`Loaded ${toolsByAccounts.length} tools for specified accounts`); // Example 3: Filter by account IDs using options console.log('\n=== Example 3: Filter by account IDs (using options) ==='); const toolsByAccountsOption = await toolset.fetchTools({ - accountIds: ['account-789'], + accountIds: ['your-account-id'], }); -console.log(`Loaded ${toolsByAccountsOption.length} tools for account-789`); +console.log(`Loaded ${toolsByAccountsOption.length} tools for your-account-id`); // Example 4: Filter by providers console.log('\n=== Example 4: Filter by providers ==='); diff --git a/examples/openai-integration.test.ts b/examples/openai-integration.test.ts index 2eb93f9..52add2d 100644 --- a/examples/openai-integration.test.ts +++ b/examples/openai-integration.test.ts @@ -5,6 +5,7 @@ */ import OpenAI from 'openai'; +import { TEST_BASE_URL } from '../mocks/constants'; import { StackOneToolSet } from '../src'; describe('openai-integration example e2e', () => { @@ -20,7 +21,7 @@ describe('openai-integration example e2e', () => { it('should fetch tools, convert to OpenAI format, and create chat completion with tool calls', async () => { const toolset = new StackOneToolSet({ accountId: 'your-bamboohr-account-id', - baseUrl: 'https://api.stackone.com', + baseUrl: TEST_BASE_URL, }); // Fetch all tools for this account via MCP diff --git a/examples/openai-integration.ts b/examples/openai-integration.ts index ed054c9..5761a51 100644 --- a/examples/openai-integration.ts +++ b/examples/openai-integration.ts @@ -13,15 +13,9 @@ if (!apiKey) { process.exit(1); } -// Replace with your actual account ID from StackOne dashboard -const accountId = 'your-bamboohr-account-id'; - const openaiIntegration = async (): Promise => { - // Initialize StackOne - const toolset = new StackOneToolSet({ - accountId, - baseUrl: process.env.STACKONE_BASE_URL ?? 'https://api.stackone.com', - }); + // Initialize StackOne — reads STACKONE_API_KEY and STACKONE_ACCOUNT_ID from env + const toolset = new StackOneToolSet(); // Fetch all tools for this account via MCP const tools = await toolset.fetchTools(); diff --git a/examples/openai-responses-integration.test.ts b/examples/openai-responses-integration.test.ts index f0bc286..2fb5bd5 100644 --- a/examples/openai-responses-integration.test.ts +++ b/examples/openai-responses-integration.test.ts @@ -5,6 +5,7 @@ */ import OpenAI from 'openai'; +import { TEST_BASE_URL } from '../mocks/constants'; import { StackOneToolSet } from '../src'; describe('openai-responses-integration example e2e', () => { @@ -20,6 +21,7 @@ describe('openai-responses-integration example e2e', () => { it('should fetch tools, convert to OpenAI Responses format, and create response with tool calls', async () => { const toolset = new StackOneToolSet({ accountId: 'your-stackone-account-id', + baseUrl: TEST_BASE_URL, }); // Fetch tools via MCP with action filter diff --git a/examples/openai-responses-integration.ts b/examples/openai-responses-integration.ts index 08b5e4a..d4a08a2 100644 --- a/examples/openai-responses-integration.ts +++ b/examples/openai-responses-integration.ts @@ -13,12 +13,9 @@ if (!apiKey) { process.exit(1); } -// Replace with your actual account ID from StackOne dashboard -const accountId = 'your-stackone-account-id'; - const openaiResponsesIntegration = async (): Promise => { - // Initialize StackOne - const toolset = new StackOneToolSet({ accountId }); + // Initialize StackOne — reads STACKONE_API_KEY and STACKONE_ACCOUNT_ID from env + const toolset = new StackOneToolSet(); // Fetch tools via MCP const tools = await toolset.fetchTools({ diff --git a/examples/search-tools.ts b/examples/search-tools.ts new file mode 100644 index 0000000..e69cbac --- /dev/null +++ b/examples/search-tools.ts @@ -0,0 +1,170 @@ +/** + * This example demonstrates how to use semantic search for dynamic tool discovery. + * Semantic search allows AI agents to find relevant tools based on natural language queries + * using StackOne's search API with local BM25+TF-IDF fallback. + * + * Search config can be set at the constructor level via `{ search: SearchConfig }` and + * overridden per-call on `searchTools()`. Pass `{ search: null }` to disable search. + * SearchConfig: { method?: 'auto' | 'semantic' | 'local', topK?: number, minSimilarity?: number } + * + * @example + * ```bash + * # Run with required environment variables: + * STACKONE_API_KEY=your-key OPENAI_API_KEY=your-key npx tsx examples/search-tools.ts + * ``` + */ + +import process from 'node:process'; +import { openai } from '@ai-sdk/openai'; +import { StackOneToolSet } from '@stackone/ai'; +import { generateText, stepCountIs } from 'ai'; + +const apiKey = process.env.STACKONE_API_KEY; +if (!apiKey) { + console.error('STACKONE_API_KEY environment variable is required'); + process.exit(1); +} + +/** + * Example 1: Search for tools with semantic search and use with AI SDK + */ +const searchToolsWithAISDK = async (): Promise => { + console.log('Example 1: Semantic tool search with AI SDK\n'); + + // Configure search at the constructor level — applies to all searchTools() calls + const toolset = new StackOneToolSet({ search: { method: 'semantic', topK: 5 } }); + + // searchTools() inherits the constructor's search config + const tools = await toolset.searchTools('manage employee records and time off'); + + console.log(`Found ${tools.length} relevant tools`); + + // Convert to AI SDK format and use with generateText + const aiSdkTools = await tools.toAISDK(); + + const { text, toolCalls } = await generateText({ + model: openai('gpt-5.1'), + tools: aiSdkTools, + prompt: `List the first 5 employees from the HR system.`, + stopWhen: stepCountIs(3), + }); + + console.log('AI Response:', text); + console.log('\nTool calls made:', toolCalls?.map((call) => call.toolName).join(', ')); +}; + +/** + * Example 2: Using SearchTool for agent loops + */ +const searchToolWithAgentLoop = async (): Promise => { + console.log('\nExample 2: SearchTool for agent loops\n'); + + // Default constructor — search enabled with method: 'auto' + const toolset = new StackOneToolSet(); + + // Per-call options override constructor defaults when needed + const searchTool = toolset.getSearchTool({ search: 'auto' }); + + // In an agent loop, search for tools as needed + const queries = ['create a new employee', 'list job candidates', 'send a message to a channel']; + + for (const query of queries) { + const tools = await searchTool.search(query, { topK: 3 }); + const toolNames = tools.toArray().map((t) => t.name); + console.log(`Query: "${query}" -> Found: ${toolNames.join(', ') || '(none)'}`); + } +}; + +/** + * Example 3: Lightweight action name search + */ +const searchActionNames = async (): Promise => { + console.log('\nExample 3: Lightweight action name search\n'); + + const toolset = new StackOneToolSet(); + + // Search for action names without fetching full tool definitions + const results = await toolset.searchActionNames('manage employees', { + topK: 5, + }); + + console.log('Search results:'); + for (const result of results) { + console.log( + ` - ${result.actionName} (${result.connectorKey}): 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); + console.log(`\nFetching tools for top actions: ${topActions.join(', ')}`); + + const tools = await toolset.fetchTools({ actions: topActions }); + console.log(`Fetched ${tools.length} tools`); + } +}; + +/** + * Example 4: Local-only search (no API call) + */ +const localSearchOnly = async (): Promise => { + console.log('\nExample 4: Local-only BM25+TF-IDF search\n'); + + // Set search method at constructor level — all searchTools() calls use local search + const toolset = new StackOneToolSet({ search: { method: 'local', topK: 3 } }); + + // searchTools() inherits local search config from the constructor + const tools = await toolset.searchTools('create time off request'); + + console.log(`Found ${tools.length} tools using local search:`); + for (const tool of tools) { + console.log(` - ${tool.name}: ${tool.description}`); + } +}; + +/** + * Example 5: Constructor-level topK vs per-call override + */ +const topKConfig = async (): Promise => { + console.log('\nExample 5: topK at constructor vs per-call\n'); + + // Constructor-level topK — all calls default to returning 3 results + const toolset = new StackOneToolSet({ search: { topK: 3 } }); + + const query = 'manage employee records'; + console.log(`Constructor topK=3: searching for "${query}"`); + const toolsDefault = await toolset.searchTools(query); + console.log(` Got ${toolsDefault.length} tools (constructor default)`); + for (const tool of toolsDefault) { + console.log(` - ${tool.name}`); + } + + // Per-call override — this single call returns up to 10 results + console.log('\nPer-call topK=10: overriding constructor default'); + const toolsOverride = await toolset.searchTools(query, { topK: 10 }); + console.log(` Got ${toolsOverride.length} tools (per-call override)`); + for (const tool of toolsOverride) { + console.log(` - ${tool.name}`); + } +}; + +// Main execution +const main = async (): Promise => { + try { + if (process.env.OPENAI_API_KEY) { + await searchToolsWithAISDK(); + } else { + console.log('OPENAI_API_KEY not found, skipping AI SDK example\n'); + } + + await searchToolWithAgentLoop(); + await searchActionNames(); + await localSearchOnly(); + await topKConfig(); + } catch (error) { + console.error('Error running examples:', error); + } +}; + +await main(); diff --git a/examples/tanstack-ai-integration.test.ts b/examples/tanstack-ai-integration.test.ts index f8302e8..5f1b770 100644 --- a/examples/tanstack-ai-integration.test.ts +++ b/examples/tanstack-ai-integration.test.ts @@ -9,6 +9,7 @@ * StackOne tools. */ +import { TEST_BASE_URL } from '../mocks/constants'; import { StackOneToolSet } from '../src'; describe('tanstack-ai-integration example e2e', () => { @@ -24,7 +25,7 @@ describe('tanstack-ai-integration example e2e', () => { it('should fetch tools and convert to TanStack AI format', async () => { const toolset = new StackOneToolSet({ accountId: 'your-bamboohr-account-id', - baseUrl: 'https://api.stackone.com', + baseUrl: TEST_BASE_URL, }); // Fetch all tools for this account via MCP @@ -51,7 +52,7 @@ describe('tanstack-ai-integration example e2e', () => { it('should execute tool directly', async () => { const toolset = new StackOneToolSet({ accountId: 'your-bamboohr-account-id', - baseUrl: 'https://api.stackone.com', + baseUrl: TEST_BASE_URL, }); const tools = await toolset.fetchTools(); diff --git a/examples/tanstack-ai-integration.ts b/examples/tanstack-ai-integration.ts index 4fca445..0f06dcd 100644 --- a/examples/tanstack-ai-integration.ts +++ b/examples/tanstack-ai-integration.ts @@ -19,15 +19,9 @@ if (!apiKey) { process.exit(1); } -// Replace with your actual account ID from StackOne dashboard -const accountId = 'your-bamboohr-account-id'; - const tanstackAiIntegration = async (): Promise => { - // Initialize StackOne - const toolset = new StackOneToolSet({ - accountId, - baseUrl: process.env.STACKONE_BASE_URL ?? 'https://api.stackone.com', - }); + // Initialize StackOne — reads STACKONE_API_KEY and STACKONE_ACCOUNT_ID from env + const toolset = new StackOneToolSet(); // Fetch tools from StackOne const tools = await toolset.fetchTools(); diff --git a/examples/utility-tools.ts b/examples/utility-tools.ts deleted file mode 100644 index eed8c37..0000000 --- a/examples/utility-tools.ts +++ /dev/null @@ -1,280 +0,0 @@ -/** - * This example demonstrates how to use utility tools for dynamic tool discovery and execution. - * Utility tools allow AI agents to search for relevant tools based on natural language queries - * and execute them dynamically without hardcoding tool names. - * - * @beta Utility tools are in beta and may change in future versions - */ - -import process from 'node:process'; -import { openai } from '@ai-sdk/openai'; -import { type JsonObject, StackOneToolSet, Tools } from '@stackone/ai'; -import { generateText, stepCountIs } from 'ai'; - -const apiKey = process.env.STACKONE_API_KEY; -if (!apiKey) { - console.error('STACKONE_API_KEY environment variable is required'); - process.exit(1); -} - -// Replace with your actual account ID from StackOne dashboard -const accountId = 'your-bamboohr-account-id'; - -/** - * Example 1: Using utility tools with AI SDK for dynamic tool discovery - */ -const utilityToolsWithAISDK = async (): Promise => { - console.log('🔍 Example 1: Dynamic tool discovery with AI SDK\n'); - - // Initialize StackOne toolset - const toolset = new StackOneToolSet({ - accountId, - baseUrl: process.env.STACKONE_BASE_URL ?? 'https://api.stackone.com', - }); - - // Fetch all available tools via MCP - const allTools = await toolset.fetchTools(); - - // Get utility tools for dynamic discovery and execution - const utilityTools = await allTools.utilityTools(); - const aiSdkUtilityTools = await utilityTools.toAISDK(); - - // Use utility tools to dynamically find and execute relevant tools - const { text, toolCalls } = await generateText({ - model: openai('gpt-5.1'), - tools: aiSdkUtilityTools, - prompt: `I need to create a time off request for an employee. - First, find the right tool for this task, then use it to create a time off request - for employee ID "emp_123" from January 15, 2024 to January 19, 2024.`, - stopWhen: stepCountIs(3), // Allow multiple tool calls - }); - - console.log('AI Response:', text); - console.log('\nTool calls made:', toolCalls?.map((call) => call.toolName).join(', ')); -}; - -/** - * Example 2: Using utility tools with OpenAI for HR assistant - */ -const utilityToolsWithOpenAI = async (): Promise => { - console.log('\n🤖 Example 2: HR Assistant with OpenAI\n'); - - const { OpenAI } = await import('openai'); - const openaiClient = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY, - }); - - // Initialize StackOne toolset - const toolset = new StackOneToolSet({ - accountId, - baseUrl: process.env.STACKONE_BASE_URL ?? 'https://api.stackone.com', - }); - - // Fetch BambooHR tools via MCP - const bamboohrTools = await toolset.fetchTools({ - actions: ['bamboohr_*'], - }); - - // Get utility tools - const utilityTools = await bamboohrTools.utilityTools(); - const openAIUtilityTools = utilityTools.toOpenAI(); - - // Create an HR assistant that can discover and use tools dynamically - const response = await openaiClient.chat.completions.create({ - model: 'gpt-5.1', - messages: [ - { - role: 'system', - content: `You are an HR assistant with access to various HR tools. - Use the tool_search to find appropriate tools for user requests, - then use tool_execute to execute them.`, - }, - { - role: 'user', - content: - 'Can you help me find tools for managing employee records and then list current employees?', - }, - ], - tools: openAIUtilityTools, - tool_choice: 'auto', - }); - - console.log('Assistant response:', response.choices[0].message.content); - - // Handle tool calls if any - if (response.choices[0].message.tool_calls) { - console.log('\nTool calls:'); - for (const toolCall of response.choices[0].message.tool_calls) { - if (toolCall.type === 'function') { - console.log(`- ${toolCall.function.name}: ${toolCall.function.arguments}`); - } - } - } -}; - -/** - * Example 3: Direct usage of utility tools without AI - */ -const directUtilityToolUsage = async (): Promise => { - console.log('\n🛠️ Example 3: Direct utility tool usage\n'); - - // Initialize toolset - const toolset = new StackOneToolSet({ - accountId, - baseUrl: process.env.STACKONE_BASE_URL ?? 'https://api.stackone.com', - }); - - // Fetch all available tools via MCP - const allTools = await toolset.fetchTools(); - console.log(`Total available tools: ${allTools.length}`); - - // Get utility tools - const utilityTools = await allTools.utilityTools(); - - // Step 1: Search for relevant tools - const filterTool = utilityTools.getTool('tool_search'); - if (!filterTool) throw new Error('tool_search not found'); - const searchResult = await filterTool.execute({ - query: 'employee management create update list', - limit: 5, - minScore: 0.3, - }); - - console.log('Found relevant tools:'); - const foundTools = searchResult.tools as Array<{ - name: string; - description: string; - score: number; - }>; - for (const tool of foundTools) { - console.log(`- ${tool.name} (score: ${tool.score.toFixed(2)}): ${tool.description}`); - } - - // Step 2: Execute one of the found tools - if (foundTools.length > 0) { - const executeTool = utilityTools.getTool('tool_execute'); - if (!executeTool) throw new Error('tool_execute not found'); - const firstTool = foundTools[0]; - - console.log(`\nExecuting ${firstTool.name}...`); - - try { - // Prepare parameters based on the tool's schema - let params = {} satisfies JsonObject; - if (firstTool.name === 'bamboohr_list_employees') { - params = { limit: 5 }; - } else if (firstTool.name === 'bamboohr_create_employee') { - params = { - name: 'John Doe', - email: 'john.doe@example.com', - title: 'Software Engineer', - }; - } - - const result = await executeTool.execute({ - toolName: firstTool.name, - params, - }); - - console.log('Execution result:', JSON.stringify(result, null, 2)); - } catch (error) { - console.error('Execution failed:', error); - } - } -}; - -/** - * Example 4: Building a dynamic tool router - */ -const dynamicToolRouter = async (): Promise => { - console.log('\n🔄 Example 4: Dynamic tool router\n'); - - const toolset = new StackOneToolSet({ - accountId, - baseUrl: process.env.STACKONE_BASE_URL ?? 'https://api.stackone.com', - }); - - // Fetch tools from multiple integrations via MCP - const bamboohrTools = await toolset.fetchTools({ - actions: ['bamboohr_*'], - }); - const workdayTools = await toolset.fetchTools({ - actions: ['workday_*'], - }); - - // Combine tools - const combinedTools = new Tools([...bamboohrTools.toArray(), ...workdayTools.toArray()]); - - // Get utility tools for the combined set - const utilityTools = await combinedTools.utilityTools(); - - // Create a router function that finds and executes tools based on intent - const routeAndExecute = async (intent: string, params: JsonObject = {}) => { - const filterTool = utilityTools.getTool('tool_search'); - const executeTool = utilityTools.getTool('tool_execute'); - if (!filterTool || !executeTool) throw new Error('Utility tools not found'); - - // Find relevant tools - const searchResult = await filterTool.execute({ - query: intent, - limit: 1, - minScore: 0.5, - }); - - const tools = searchResult.tools; - if (!Array.isArray(tools) || tools.length === 0) { - return { error: 'No relevant tools found for the given intent' }; - } - - const selectedTool = tools[0] as { name: string; score: number }; - console.log(`Routing to: ${selectedTool.name} (score: ${selectedTool.score.toFixed(2)})`); - - // Execute the selected tool - return await executeTool.execute({ - toolName: selectedTool.name, - params, - }); - }; - - // Test the router with different intents - const intents = [ - { intent: 'I want to see all employees', params: { limit: 10 } }, - { - intent: 'Create a new job candidate', - params: { name: 'Jane Smith', email: 'jane@example.com' }, - }, - { intent: 'Find recruitment candidates', params: { status: 'active' } }, - ] as const satisfies { intent: string; params: JsonObject }[]; - - for (const { intent, params } of intents) { - console.log(`\nIntent: "${intent}"`); - const result = await routeAndExecute(intent, params); - console.log('Result:', JSON.stringify(result, null, 2)); - } -}; - -// Main execution -const main = async () => { - try { - // Run examples based on environment setup - if (process.env.OPENAI_API_KEY) { - await utilityToolsWithAISDK(); - await utilityToolsWithOpenAI(); - } else { - console.log('⚠️ OPENAI_API_KEY not found, skipping AI examples\n'); - } - - // These examples work without AI - await directUtilityToolUsage(); - await dynamicToolRouter(); - } catch (error) { - console.error('Error running examples:', error); - } -}; - -// Run if this file is executed directly -if (import.meta.main) { - await main(); -} - -export { utilityToolsWithAISDK, utilityToolsWithOpenAI, directUtilityToolUsage, dynamicToolRouter }; diff --git a/mocks/constants.ts b/mocks/constants.ts new file mode 100644 index 0000000..dc3b62f --- /dev/null +++ b/mocks/constants.ts @@ -0,0 +1 @@ +export const TEST_BASE_URL = 'http://localhost'; diff --git a/mocks/handlers.mcp.ts b/mocks/handlers.mcp.ts index d091ce9..538db9d 100644 --- a/mocks/handlers.mcp.ts +++ b/mocks/handlers.mcp.ts @@ -1,4 +1,5 @@ import { http } from 'msw'; +import { TEST_BASE_URL } from './constants'; import { accountMcpTools, createMcpApp, @@ -26,10 +27,7 @@ const defaultMcpApp = createMcpApp({ * MCP Protocol endpoint handlers (delegated to Hono app) */ export const mcpHandlers = [ - http.all('https://api.stackone.com/mcp', async ({ request }) => { - return defaultMcpApp.fetch(request); - }), - http.all('https://api.stackone-dev.com/mcp', async ({ request }) => { + http.all(`${TEST_BASE_URL}/mcp`, async ({ request }) => { return defaultMcpApp.fetch(request); }), ]; diff --git a/mocks/handlers.stackone-ai.ts b/mocks/handlers.stackone-ai.ts index 4fb90b6..ca38677 100644 --- a/mocks/handlers.stackone-ai.ts +++ b/mocks/handlers.stackone-ai.ts @@ -1,11 +1,12 @@ import { http, HttpResponse } from 'msw'; +import { TEST_BASE_URL } from './constants'; /** * StackOne AI and Tools endpoint handlers */ export const stackoneAiHandlers = [ // StackOne API spec endpoints - http.get('https://api.stackone.com/api/v1/:category/openapi.json', ({ params }) => { + http.get(`${TEST_BASE_URL}/api/v1/:category/openapi.json`, ({ params }) => { const { category } = params; if (category === 'hris') { @@ -20,7 +21,7 @@ export const stackoneAiHandlers = [ }), // StackOne AI tool feedback endpoint - http.post('https://api.stackone.com/ai/tool-feedback', async ({ request }) => { + http.post(`${TEST_BASE_URL}/ai/tool-feedback`, async ({ request }) => { await request.json(); // Validate request body is JSON return HttpResponse.json({ message: 'Feedback successfully stored', @@ -31,7 +32,7 @@ export const stackoneAiHandlers = [ }), // StackOne fetchTools endpoint for fetch-tools.ts example - http.get('https://api.stackone.com/ai/tools', () => { + http.get(`${TEST_BASE_URL}/ai/tools`, () => { return HttpResponse.json({ tools: [ { @@ -69,11 +70,11 @@ export const stackoneAiHandlers = [ }), // External OAS spec endpoint for openapi-toolset.ts example - http.get('https://api.eu1.stackone.com/oas/hris.json', () => { + http.get(`${TEST_BASE_URL}/oas/hris.json`, () => { return HttpResponse.json({ openapi: '3.0.0', info: { title: 'StackOne HRIS API', version: '1.0.0' }, - servers: [{ url: 'https://api.stackone.com' }], + servers: [{ url: TEST_BASE_URL }], paths: { '/hris/employees': { get: { diff --git a/mocks/handlers.stackone-rpc.ts b/mocks/handlers.stackone-rpc.ts index 259e6d9..804e9c2 100644 --- a/mocks/handlers.stackone-rpc.ts +++ b/mocks/handlers.stackone-rpc.ts @@ -1,10 +1,11 @@ import { http, HttpResponse } from 'msw'; +import { TEST_BASE_URL } from './constants'; /** * StackOne Actions RPC endpoint handlers */ export const stackoneRpcHandlers = [ - http.post('https://api.stackone.com/actions/rpc', async ({ request }) => { + http.post(`${TEST_BASE_URL}/actions/rpc`, async ({ request }) => { const authHeader = request.headers.get('Authorization'); const accountIdHeader = request.headers.get('x-account-id'); diff --git a/src/feedback.test.ts b/src/feedback.test.ts index 9496bea..54c4c35 100644 --- a/src/feedback.test.ts +++ b/src/feedback.test.ts @@ -1,7 +1,8 @@ import { http, HttpResponse } from 'msw'; import { server } from '../mocks/node'; -import { StackOneError } from './utils/error-stackone'; +import { TEST_BASE_URL } from '../mocks/constants'; import { createFeedbackTool } from './feedback'; +import { StackOneError } from './utils/error-stackone'; interface FeedbackResultItem { account_id: string; @@ -21,7 +22,7 @@ interface FeedbackResult { describe('tool_feedback', () => { describe('validation tests', () => { it('test_missing_required_fields', async () => { - const tool = createFeedbackTool(); + const tool = createFeedbackTool(undefined, undefined, TEST_BASE_URL); // Test missing account_id await expect( @@ -40,7 +41,7 @@ describe('tool_feedback', () => { }); it('test_empty_and_whitespace_validation', async () => { - const tool = createFeedbackTool(); + const tool = createFeedbackTool(undefined, undefined, TEST_BASE_URL); // Test empty feedback await expect( @@ -73,7 +74,7 @@ describe('tool_feedback', () => { }); it('test_multiple_account_ids_validation', async () => { - const tool = createFeedbackTool(); + const tool = createFeedbackTool(undefined, undefined, TEST_BASE_URL); // Test empty account ID list await expect( @@ -95,7 +96,7 @@ describe('tool_feedback', () => { }); it('test_json_string_input', async () => { - const tool = createFeedbackTool(); + const tool = createFeedbackTool(undefined, undefined, TEST_BASE_URL); const recordedRequests: Request[] = []; const listener = ({ request }: { request: Request }) => { recordedRequests.push(request); @@ -124,7 +125,7 @@ describe('tool_feedback', () => { describe('execution tests', () => { it('test_single_account_execution', async () => { - const tool = createFeedbackTool(); + const tool = createFeedbackTool(undefined, undefined, TEST_BASE_URL); const recordedRequests: Request[] = []; const listener = ({ request }: { request: Request }) => { recordedRequests.push(request); @@ -138,7 +139,7 @@ describe('tool_feedback', () => { }); expect(recordedRequests).toHaveLength(1); - expect(recordedRequests[0]?.url).toBe('https://api.stackone.com/ai/tool-feedback'); + expect(recordedRequests[0]?.url).toBe(`${TEST_BASE_URL}/ai/tool-feedback`); expect(recordedRequests[0]?.method).toBe('POST'); // TODO: Remove type assertion once createFeedbackTool returns properly typed result instead of JsonDict const feedbackResult = result as unknown as FeedbackResult; @@ -160,7 +161,7 @@ describe('tool_feedback', () => { }); it('test_call_method_interface', async () => { - const tool = createFeedbackTool(); + const tool = createFeedbackTool(undefined, undefined, TEST_BASE_URL); const recordedRequests: Request[] = []; const listener = ({ request }: { request: Request }) => { recordedRequests.push(request); @@ -185,11 +186,11 @@ describe('tool_feedback', () => { }); it('test_api_error_handling', async () => { - const tool = createFeedbackTool(); + const tool = createFeedbackTool(undefined, undefined, TEST_BASE_URL); // Override the default handler to return an error server.use( - http.post('https://api.stackone.com/ai/tool-feedback', () => { + http.post(`${TEST_BASE_URL}/ai/tool-feedback`, () => { return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 }); }), ); @@ -204,7 +205,7 @@ describe('tool_feedback', () => { }); it('test_multiple_account_ids_execution', async () => { - const tool = createFeedbackTool(); + const tool = createFeedbackTool(undefined, undefined, TEST_BASE_URL); // Test all accounts succeed const recordedRequests: Request[] = []; @@ -231,7 +232,7 @@ describe('tool_feedback', () => { // Test mixed success/error scenario let callCount = 0; server.use( - http.post('https://api.stackone.com/ai/tool-feedback', () => { + http.post(`${TEST_BASE_URL}/ai/tool-feedback`, () => { callCount++; if (callCount === 1) { return HttpResponse.json({ message: 'Success' }); @@ -273,7 +274,7 @@ describe('tool_feedback', () => { it('test_tool_integration', async () => { // Test tool properties - const tool = createFeedbackTool(); + const tool = createFeedbackTool(undefined, undefined, TEST_BASE_URL); expect(tool.name).toBe('tool_feedback'); expect(tool.description).toContain('Collects user feedback'); expect(tool.parameters).toBeDefined(); diff --git a/src/index.ts b/src/index.ts index e32aa7a..f890e59 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,15 +8,27 @@ export { StackOneError } from './utils/error-stackone'; export { StackOneAPIError } from './utils/error-stackone-api'; export { + SearchTool, StackOneToolSet, ToolSetConfigError, ToolSetError, ToolSetLoadError, type AuthenticationConfig, type BaseToolSetConfig, + type SearchMode, + type SearchToolsOptions, + type SearchActionNamesOptions, type StackOneToolSetConfig, } from './toolsets'; +export { + SemanticSearchClient, + SemanticSearchError, + type SemanticSearchOptions, + type SemanticSearchResponse, + type SemanticSearchResult, +} from './semantic-search'; + export type { AISDKToolDefinition, AISDKToolResult, @@ -25,5 +37,6 @@ export type { JsonObject, JsonValue, ParameterLocation, + SearchConfig, ToolDefinition, } from './types'; diff --git a/src/local-search.test.ts b/src/local-search.test.ts new file mode 100644 index 0000000..d66852b --- /dev/null +++ b/src/local-search.test.ts @@ -0,0 +1,176 @@ +import { ToolIndex } from './local-search'; +import { BaseTool } from './tool'; + +function createMockTools(): BaseTool[] { + return [ + new BaseTool( + 'bamboohr_create_employee', + 'Create a new employee record in the HRIS system', + { type: 'object', properties: {} }, + { + kind: 'http', + method: 'POST', + url: 'https://api.example.com/hris/employees', + bodyType: 'json', + params: [], + }, + ), + new BaseTool( + 'bamboohr_list_employees', + 'List all employees in the HRIS system', + { type: 'object', properties: {} }, + { + kind: 'http', + method: 'GET', + url: 'https://api.example.com/hris/employees', + bodyType: 'json', + params: [], + }, + ), + new BaseTool( + 'bamboohr_create_time_off', + 'Create a time off request for an employee', + { type: 'object', properties: {} }, + { + kind: 'http', + method: 'POST', + url: 'https://api.example.com/hris/time-off', + bodyType: 'json', + params: [], + }, + ), + new BaseTool( + 'workday_create_candidate', + 'Create a new candidate in the ATS', + { type: 'object', properties: {} }, + { + kind: 'http', + method: 'POST', + url: 'https://api.example.com/ats/candidates', + bodyType: 'json', + params: [], + }, + ), + new BaseTool( + 'workday_list_candidates', + 'List all candidates in the ATS', + { type: 'object', properties: {} }, + { + kind: 'http', + method: 'GET', + url: 'https://api.example.com/ats/candidates', + bodyType: 'json', + params: [], + }, + ), + new BaseTool( + 'salesforce_create_contact', + 'Create a new contact in the CRM', + { type: 'object', properties: {} }, + { + kind: 'http', + method: 'POST', + url: 'https://api.example.com/crm/contacts', + bodyType: 'json', + params: [], + }, + ), + ]; +} + +describe('ToolIndex', () => { + describe('search', () => { + it('should find relevant employee tools', async () => { + const tools = createMockTools(); + const index = new ToolIndex(tools); + const results = await index.search('manage employees'); + + const resultNames = results.map((r) => r.name); + expect(resultNames).toContain('bamboohr_create_employee'); + expect(resultNames).toContain('bamboohr_list_employees'); + }); + + it('should find time off tools', async () => { + const tools = createMockTools(); + const index = new ToolIndex(tools); + const results = await index.search('time off request vacation leave'); + + const resultNames = results.map((r) => r.name); + expect(resultNames).toContain('bamboohr_create_time_off'); + }); + + it('should respect limit parameter', async () => { + const tools = createMockTools(); + const index = new ToolIndex(tools); + const results = await index.search('create', 2); + + expect(results.length).toBeLessThanOrEqual(2); + }); + + it('should filter by minimum score', async () => { + const tools = createMockTools(); + const index = new ToolIndex(tools); + const results = await index.search('xyz123 nonexistent', 5, 0.8); + + expect(results).toHaveLength(0); + }); + + it('should return scores between 0 and 1', async () => { + const tools = createMockTools(); + const index = new ToolIndex(tools); + const results = await index.search('employee', 10); + + for (const result of results) { + expect(result.score).toBeGreaterThanOrEqual(0); + expect(result.score).toBeLessThanOrEqual(1); + } + }); + + it('should find candidate tools', async () => { + const tools = createMockTools(); + const index = new ToolIndex(tools); + const results = await index.search('candidates'); + + const resultNames = results.map((r) => r.name); + const hasCandidateTool = + resultNames.includes('workday_create_candidate') || + resultNames.includes('workday_list_candidates'); + expect(hasCandidateTool).toBe(true); + }); + + it('should handle empty query', async () => { + const tools = createMockTools(); + const index = new ToolIndex(tools); + const results = await index.search('', 5); + + expect(Array.isArray(results)).toBe(true); + }); + }); + + describe('custom alpha', () => { + it('should work with custom alpha value', async () => { + const tools = createMockTools(); + const index = new ToolIndex(tools, 0.7); + const results = await index.search('create candidate'); + + const resultNames = results.map((r) => r.name); + expect(resultNames).toContain('workday_create_candidate'); + }); + + it('should work with alpha=0 (TF-IDF only)', async () => { + const tools = createMockTools(); + const index = new ToolIndex(tools, 0); + const results = await index.search('employee'); + + expect(results.length).toBeGreaterThan(0); + }); + + it('should work with alpha=1 (BM25 only)', async () => { + const tools = createMockTools(); + const index = new ToolIndex(tools, 1); + const results = await index.search('employee'); + + expect(results.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/local-search.ts b/src/local-search.ts new file mode 100644 index 0000000..9c5e1ff --- /dev/null +++ b/src/local-search.ts @@ -0,0 +1,204 @@ +/** + * Local BM25 + TF-IDF hybrid keyword search for tool discovery. + * + * Provides offline tool search as a fallback when the semantic search API + * is unavailable, or when explicitly requested via `search: "local"`. + * + * Algorithm: + * - BM25 scoring via Orama library + * - TF-IDF cosine similarity via custom TfidfIndex + * - Hybrid fusion: `alpha * bm25 + (1 - alpha) * tfidf` + * - Default alpha = 0.2 (20% BM25, 80% TF-IDF) + */ + +import * as orama from '@orama/orama'; +import { DEFAULT_HYBRID_ALPHA } from './consts'; +import type { BaseTool } from './tool'; +import { TfidfIndex } from './utils/tfidf-index'; + +/** + * Result from local tool search + */ +interface ToolSearchResult { + name: string; + description: string; + score: number; +} + +type OramaDb = ReturnType; + +/** + * Clamp value to [0, 1] + */ +function clamp01(x: number): number { + return x < 0 ? 0 : x > 1 ? 1 : x; +} + +/** + * Initialize TF-IDF index for tool search + */ +function initializeTfidfIndex(tools: BaseTool[]): TfidfIndex { + const index = new TfidfIndex(); + const corpus = tools.map((tool) => { + const parts = tool.name.split('_'); + const integration = parts[0]; + + const actionTypes = ['create', 'update', 'delete', 'get', 'list', 'search']; + const actions = parts.filter((p) => actionTypes.includes(p)); + + const text = [ + `${tool.name} ${tool.name} ${tool.name}`, // boost name + `${integration} ${actions.join(' ')}`, + tool.description, + parts.join(' '), + ].join(' '); + + return { id: tool.name, text }; + }); + + index.build(corpus); + return index; +} + +/** + * Initialize Orama database with BM25 algorithm for tool search + * @see https://docs.orama.com/open-source/usage/create + * @see https://docs.orama.com/open-source/usage/search/bm25-algorithm/ + */ +async function initializeOramaDb(tools: BaseTool[]): Promise { + const oramaDb = orama.create({ + schema: { + name: 'string' as const, + description: 'string' as const, + integration: 'string' as const, + tags: 'string[]' as const, + }, + components: { + tokenizer: { + stemming: true, + }, + }, + }); + + for (const tool of tools) { + const parts = tool.name.split('_'); + const integration = parts[0]; + + const actionTypes = ['create', 'update', 'delete', 'get', 'list', 'search']; + const actions = parts.filter((p) => actionTypes.includes(p)); + + await orama.insert(oramaDb, { + name: tool.name, + description: tool.description, + integration: integration, + tags: [...parts, ...actions], + }); + } + + return oramaDb; +} + +/** + * Hybrid BM25 + TF-IDF tool search index. + * + * Provides local tool discovery without API calls. + * Used as a fallback when semantic search is unavailable. + */ +export class ToolIndex { + private tools: BaseTool[]; + private hybridAlpha: number; + private oramaDbPromise: Promise; + private tfidfIndex: TfidfIndex; + + /** + * Initialize tool index with hybrid search + * + * @param tools - List of tools to index + * @param hybridAlpha - Weight for BM25 in hybrid search (0-1). Default 0.2. + */ + constructor(tools: BaseTool[], hybridAlpha?: number) { + this.tools = tools; + const alpha = hybridAlpha ?? DEFAULT_HYBRID_ALPHA; + this.hybridAlpha = Math.max(0, Math.min(1, alpha)); + this.oramaDbPromise = initializeOramaDb(tools); + this.tfidfIndex = initializeTfidfIndex(tools); + } + + /** + * Search for relevant tools using hybrid BM25 + TF-IDF + * + * @param query - Natural language query + * @param limit - Maximum number of results (default 5) + * @param minScore - Minimum relevance score 0-1 (default 0.0) + * @returns List of search results sorted by relevance + */ + async search(query: string, limit = 5, minScore = 0.0): Promise { + if (this.tools.length === 0) { + return []; + } + + const fetchLimit = Math.max(50, limit); + const oramaDb = await this.oramaDbPromise; + + const [bm25Results, tfidfResults] = await Promise.all([ + orama.search(oramaDb, { + term: query, + limit: Math.max(50, limit), + } as Parameters[1]), + Promise.resolve(this.tfidfIndex.search(query, fetchLimit)), + ]); + + // Build score map for fusion + const scoreMap = new Map(); + + for (const hit of bm25Results.hits) { + const doc = hit.document as { name: string }; + scoreMap.set(doc.name, { + ...scoreMap.get(doc.name), + bm25: clamp01(hit.score), + }); + } + + for (const r of tfidfResults) { + scoreMap.set(r.id, { + ...scoreMap.get(r.id), + tfidf: clamp01(r.score), + }); + } + + // Fuse scores: hybrid_score = alpha * bm25 + (1 - alpha) * tfidf + const fused: Array<{ name: string; score: number }> = []; + for (const [name, scores] of scoreMap) { + const bm25 = scores.bm25 ?? 0; + const tfidf = scores.tfidf ?? 0; + const score = this.hybridAlpha * bm25 + (1 - this.hybridAlpha) * tfidf; + fused.push({ name, score }); + } + + fused.sort((a, b) => b.score - a.score); + + const results: ToolSearchResult[] = []; + for (const r of fused) { + if (r.score < minScore) { + continue; + } + + const tool = this.tools.find((t) => t.name === r.name); + if (!tool) { + continue; + } + + results.push({ + name: tool.name, + description: tool.description, + score: r.score, + }); + + if (results.length >= limit) { + break; + } + } + + return results; + } +} diff --git a/src/mcp-client.test.ts b/src/mcp-client.test.ts index 3836e5b..3092640 100644 --- a/src/mcp-client.test.ts +++ b/src/mcp-client.test.ts @@ -47,7 +47,7 @@ test('createMCPClient provides asyncDispose for cleanup', async () => { test('createMCPClient can connect and list tools from MCP server', async () => { await using mcpClient = await createMCPClient({ - baseUrl: 'https://api.stackone-dev.com/mcp', + baseUrl: 'http://localhost/mcp', headers: { Authorization: `Basic ${Buffer.from('test-key:').toString('base64')}`, 'x-account-id': 'test-account', diff --git a/src/rpc-client.test.ts b/src/rpc-client.test.ts index c6ce823..6d69f15 100644 --- a/src/rpc-client.test.ts +++ b/src/rpc-client.test.ts @@ -1,9 +1,11 @@ +import { TEST_BASE_URL } from '../mocks/constants'; import { RpcClient } from './rpc-client'; import { stackOneHeadersSchema } from './headers'; import { StackOneAPIError } from './utils/error-stackone-api'; test('should successfully execute an RPC action', async () => { const client = new RpcClient({ + serverURL: TEST_BASE_URL, security: { username: 'test-api-key' }, }); @@ -23,6 +25,7 @@ test('should successfully execute an RPC action', async () => { test('should send correct payload structure', async () => { const client = new RpcClient({ + serverURL: TEST_BASE_URL, security: { username: 'test-api-key' }, }); @@ -48,6 +51,7 @@ test('should send correct payload structure', async () => { test('should handle list actions with array data', async () => { const client = new RpcClient({ + serverURL: TEST_BASE_URL, security: { username: 'test-api-key' }, }); @@ -65,6 +69,7 @@ test('should handle list actions with array data', async () => { test('should throw StackOneAPIError on server error', async () => { const client = new RpcClient({ + serverURL: TEST_BASE_URL, security: { username: 'test-api-key' }, }); @@ -77,6 +82,7 @@ test('should throw StackOneAPIError on server error', async () => { test('should include request body in error for debugging', async () => { const client = new RpcClient({ + serverURL: TEST_BASE_URL, security: { username: 'test-api-key' }, }); @@ -93,6 +99,7 @@ test('should include request body in error for debugging', async () => { test('should work with only action parameter', async () => { const client = new RpcClient({ + serverURL: TEST_BASE_URL, security: { username: 'test-api-key' }, }); @@ -106,6 +113,7 @@ test('should work with only action parameter', async () => { test('should send x-account-id as HTTP header', async () => { const client = new RpcClient({ + serverURL: TEST_BASE_URL, security: { username: 'test-api-key' }, }); diff --git a/src/semantic-search.test.ts b/src/semantic-search.test.ts new file mode 100644 index 0000000..4a12d32 --- /dev/null +++ b/src/semantic-search.test.ts @@ -0,0 +1,207 @@ +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { SemanticSearchClient, SemanticSearchError } from './semantic-search'; + +const server = setupServer(); + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const TEST_API_KEY = 'test-api-key'; +const TEST_BASE_URL = 'http://localhost'; + +function createClient(options?: { baseUrl?: string; timeout?: number }): SemanticSearchClient { + return new SemanticSearchClient({ + apiKey: TEST_API_KEY, + baseUrl: options?.baseUrl ?? TEST_BASE_URL, + timeout: options?.timeout, + }); +} + +describe('SemanticSearchClient', () => { + describe('search', () => { + test('returns parsed search results', async () => { + server.use( + http.post(`${TEST_BASE_URL}/actions/search`, async ({ request }) => { + const body = (await request.json()) as Record; + expect(body.query).toBe('create employee'); + expect(body.connector).toBe('bamboohr'); + expect(body.top_k).toBe(5); + + return HttpResponse.json({ + results: [ + { + action_name: 'bamboohr_create_employee', + connector_key: 'bamboohr', + 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', + similarity_score: 0.82, + label: 'Update Employee', + description: 'Update an existing employee', + }, + ], + total_count: 2, + query: 'create employee', + connector_filter: 'bamboohr', + }); + }), + ); + + const client = createClient(); + const response = await client.search('create employee', { + connector: 'bamboohr', + topK: 5, + }); + + 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].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'); + }); + + test('sends correct auth header', async () => { + server.use( + http.post(`${TEST_BASE_URL}/actions/search`, ({ request }) => { + const auth = request.headers.get('Authorization'); + const expected = `Basic ${Buffer.from(`${TEST_API_KEY}:`).toString('base64')}`; + expect(auth).toBe(expected); + + return HttpResponse.json({ + results: [], + total_count: 0, + query: 'test', + }); + }), + ); + + const client = createClient(); + await client.search('test'); + }); + + test('sends optional parameters only when provided', async () => { + server.use( + http.post(`${TEST_BASE_URL}/actions/search`, async ({ request }) => { + const body = (await request.json()) as Record; + expect(body.query).toBe('test'); + expect(body).not.toHaveProperty('connector'); + expect(body).not.toHaveProperty('top_k'); + expect(body).not.toHaveProperty('project_id'); + expect(body).not.toHaveProperty('min_similarity'); + + return HttpResponse.json({ + results: [], + total_count: 0, + query: 'test', + }); + }), + ); + + const client = createClient(); + await client.search('test'); + }); + + test('sends min_similarity when provided', async () => { + server.use( + http.post(`${TEST_BASE_URL}/actions/search`, async ({ request }) => { + const body = (await request.json()) as Record; + expect(body.min_similarity).toBe(0.7); + + return HttpResponse.json({ + results: [], + total_count: 0, + query: 'test', + }); + }), + ); + + const client = createClient(); + await client.search('test', { minSimilarity: 0.7 }); + }); + + test('throws SemanticSearchError on HTTP error', async () => { + server.use( + http.post(`${TEST_BASE_URL}/actions/search`, () => { + return new HttpResponse('Internal Server Error', { status: 500 }); + }), + ); + + const client = createClient(); + await expect(client.search('test')).rejects.toThrow(SemanticSearchError); + await expect(client.search('test')).rejects.toThrow('API error: 500'); + }); + + test('throws SemanticSearchError on network error', async () => { + server.use( + http.post(`${TEST_BASE_URL}/actions/search`, () => { + return HttpResponse.error(); + }), + ); + + const client = createClient(); + await expect(client.search('test')).rejects.toThrow(SemanticSearchError); + }); + + test('strips trailing slashes from base URL', async () => { + server.use( + http.post(`${TEST_BASE_URL}/actions/search`, () => { + return HttpResponse.json({ + results: [], + total_count: 0, + query: 'test', + }); + }), + ); + + const client = createClient({ baseUrl: `${TEST_BASE_URL}///` }); + const response = await client.search('test'); + expect(response.totalCount).toBe(0); + }); + }); + + describe('searchActionNames', () => { + test('returns just action names', async () => { + server.use( + http.post(`${TEST_BASE_URL}/actions/search`, () => { + return HttpResponse.json({ + results: [ + { + action_name: 'bamboohr_create_employee', + connector_key: 'bamboohr', + similarity_score: 0.95, + label: 'Create Employee', + description: 'Create a new employee', + }, + { + action_name: 'hibob_create_employee', + connector_key: 'hibob', + similarity_score: 0.88, + label: 'Create Employee', + description: 'Create a new employee in HiBob', + }, + ], + total_count: 2, + query: 'create employee', + }); + }), + ); + + const client = createClient(); + const names = await client.searchActionNames('create employee'); + expect(names).toEqual(['bamboohr_create_employee', 'hibob_create_employee']); + }); + }); +}); diff --git a/src/semantic-search.ts b/src/semantic-search.ts new file mode 100644 index 0000000..7ada9f4 --- /dev/null +++ b/src/semantic-search.ts @@ -0,0 +1,260 @@ +/** + * Semantic search client for StackOne action search API. + * + * How Semantic Search Works + * ========================= + * + * The SDK provides three ways to discover tools using semantic search. + * Each path trades off between speed, filtering, and completeness. + * + * 1. `searchTools(query)` — Full tool discovery (recommended for agent frameworks) + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * + * 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 + * + * 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. + * + * If the semantic API is unavailable, the SDK falls back to a local + * BM25 + TF-IDF hybrid search over the fetched tools (unless + * `search: "semantic"` is specified). + * + * + * 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. + * + * When `accountIds` are provided, each connector is searched in + * parallel (same as `searchTools`). Without `accountIds`, results + * come from the full StackOne catalog. + * + * + * 3. `toolset.getSearchTool()` — Agent-loop callable + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * + * Returns a `SearchTool` instance that wraps `searchTools()`. + * Call it with a natural language query to get a `Tools` collection + * back. Designed for agent loops where the LLM decides what to search for. + */ + +import { DEFAULT_BASE_URL } from './consts'; +import { StackOneError } from './utils/error-stackone'; + +/** + * Raised when semantic search fails. + */ +export class SemanticSearchError extends StackOneError { + 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; + projectId: string; +} + +/** + * Response from /actions/search endpoint. + */ +export interface SemanticSearchResponse { + results: SemanticSearchResult[]; + totalCount: number; + query: string; + connectorFilter?: string; + projectFilter?: string; +} + +/** + * Options for semantic search + */ +export interface SemanticSearchOptions { + connector?: string; + topK?: number; + projectId?: string; + minSimilarity?: number; +} + +/** + * Client for StackOne semantic search API. + * + * This client provides access to the semantic search endpoint which uses + * enhanced embeddings 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 { + private readonly apiKey: string; + private readonly baseUrl: string; + private readonly timeout: number; + + constructor({ + apiKey, + baseUrl = DEFAULT_BASE_URL, + timeout = 30_000, + }: { + apiKey: string; + baseUrl?: string; + timeout?: number; + }) { + this.apiKey = apiKey; + this.baseUrl = baseUrl.replace(/\/+$/, ''); + this.timeout = timeout; + } + + /** + * Build the Basic auth header. + */ + private 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 - Search options (connector, topK, projectId, minSimilarity) + * @returns SemanticSearchResponse containing matching actions with similarity scores + * @throws SemanticSearchError if the API call fails + * + * @example + * ```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)}`); + * } + * ``` + */ + async search(query: string, options?: SemanticSearchOptions): 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; + } + if (options?.projectId) { + payload.project_id = options.projectId; + } + if (options?.minSimilarity != null) { + payload.min_similarity = options.minSimilarity; + } + + 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 { + results: Array<{ + action_name: string; + connector_key: string; + similarity_score: number; + label: string; + description: string; + project_id?: string; + }>; + total_count: number; + query: string; + connector_filter?: string; + project_filter?: string; + }; + + return { + results: data.results.map((r) => ({ + actionName: r.action_name, + connectorKey: r.connector_key, + similarityScore: r.similarity_score, + label: r.label, + description: r.description, + projectId: r.project_id ?? 'global', + })), + totalCount: data.total_count, + query: data.query, + connectorFilter: data.connector_filter, + projectFilter: data.project_filter, + }; + } catch (error) { + if (error instanceof SemanticSearchError) { + throw error; + } + if (error instanceof Error && error.name === 'AbortError') { + throw new SemanticSearchError(`Request timed out after ${this.timeout}ms`); + } + throw new SemanticSearchError( + `Search 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 - Search options (connector, topK, minSimilarity, projectId) + * @returns List of action names sorted by relevance + * + * @example + * ```typescript + * const actionNames = await client.searchActionNames('create employee', { + * connector: 'bamboohr', + * minSimilarity: 0.5, + * }); + * ``` + */ + async searchActionNames(query: string, options?: SemanticSearchOptions): Promise { + const response = await this.search(query, options); + return response.results.map((r) => r.actionName); + } +} diff --git a/src/tool.test.ts b/src/tool.test.ts index 5c0d1fc..7379716 100644 --- a/src/tool.test.ts +++ b/src/tool.test.ts @@ -1,32 +1,5 @@ import { jsonSchema } from 'ai'; -import { BaseTool, type ToolSearchResult, StackOneTool, Tools } from './tool'; -import type { JsonObject } from './types'; - -/** - * Type guard for ToolSearchResult array from execute result. - * Used to safely extract tools from tool_search response. - */ -function isToolSearchResults(value: unknown): value is ToolSearchResult[] { - return ( - Array.isArray(value) && - value.every( - (item) => - typeof item === 'object' && - item !== null && - 'name' in item && - 'description' in item && - 'score' in item, - ) - ); -} - -/** Extract tools from search result with type safety */ -function getSearchResults(result: JsonObject): ToolSearchResult[] { - if (!isToolSearchResults(result.tools)) { - throw new Error('Invalid tools response'); - } - return result.tools; -} +import { BaseTool, StackOneTool, Tools } from './tool'; import { type ExecuteConfig, type JSONSchema, @@ -804,566 +777,98 @@ describe('Tools', () => { }); }); -// Create mock tools for utility tools testing -const createMockTools = (): BaseTool[] => { - const tools: BaseTool[] = []; - - // HRIS tools - tools.push( - new BaseTool( - 'bamboohr_create_employee', - 'Create a new employee record in the HRIS system', - { - type: 'object', - properties: { - name: { type: 'string', description: 'Employee name' }, - email: { type: 'string', description: 'Employee email' }, - }, - required: ['name', 'email'], - }, - { - kind: 'http', - method: 'POST', - url: 'https://api.example.com/hris/employees', - bodyType: 'json', - params: [], - }, - ), - ); - - tools.push( - new BaseTool( +describe('BaseTool.connector', () => { + it('should extract connector prefix from tool name', () => { + const tool = new BaseTool( 'bamboohr_list_employees', - 'List all employees in the HRIS system', - { - type: 'object', - properties: { - limit: { type: 'number', description: 'Number of employees to return' }, - }, - }, - { - kind: 'http', - method: 'GET', - url: 'https://api.example.com/hris/employees', - bodyType: 'json', - params: [ - { - name: 'limit', - location: ParameterLocation.QUERY, - type: 'number', - }, - ], - }, - ), - ); - - tools.push( - new BaseTool( - 'bamboohr_create_time_off', - 'Create a time off request for an employee', - { - type: 'object', - properties: { - employeeId: { type: 'string', description: 'Employee ID' }, - startDate: { type: 'string', description: 'Start date of time off' }, - endDate: { type: 'string', description: 'End date of time off' }, - }, - required: ['employeeId', 'startDate', 'endDate'], - }, - { - kind: 'http', - method: 'POST', - url: 'https://api.example.com/hris/time-off', - bodyType: 'json', - params: [], - }, - ), - ); - - // ATS tools - tools.push( - new BaseTool( - 'workday_create_candidate', - 'Create a new candidate in the ATS', - { - type: 'object', - properties: { - name: { type: 'string', description: 'Candidate name' }, - email: { type: 'string', description: 'Candidate email' }, - }, - required: ['name', 'email'], - }, - { - kind: 'http', - method: 'POST', - url: 'https://api.example.com/ats/candidates', - bodyType: 'json', - params: [], - }, - ), - ); - - tools.push( - new BaseTool( - 'workday_list_candidates', - 'List all candidates in the ATS', - { - type: 'object', - properties: { - status: { type: 'string', description: 'Filter by candidate status' }, - }, - }, - { - kind: 'http', - method: 'GET', - url: 'https://api.example.com/ats/candidates', - bodyType: 'json', - params: [ - { - name: 'status', - location: ParameterLocation.QUERY, - type: 'string', - }, - ], - }, - ), - ); - - // CRM tools - tools.push( - new BaseTool( - 'salesforce_create_contact', - 'Create a new contact in the CRM', - { - type: 'object', - properties: { - name: { type: 'string', description: 'Contact name' }, - company: { type: 'string', description: 'Company name' }, - }, - required: ['name'], - }, - { - kind: 'http', - method: 'POST', - url: 'https://api.example.com/crm/contacts', - bodyType: 'json', - params: [], - }, - ), - ); - - return tools; -}; - -describe('Utility Tools', () => { - let tools: Tools; - let utilityTools: Tools; - - beforeEach(async () => { - const mockTools = createMockTools(); - tools = new Tools(mockTools); - utilityTools = await tools.utilityTools(); // default BM25 strategy - }); - - describe('utilityTools()', () => { - it('should return two utility tools', () => { - expect(utilityTools.length).toBe(2); - }); - - it('should include tool_search', () => { - const filterTool = utilityTools.getTool('tool_search'); - expect(filterTool).toBeDefined(); - expect(filterTool?.name).toBe('tool_search'); - }); - - it('should include tool_execute', () => { - const executeTool = utilityTools.getTool('tool_execute'); - expect(executeTool).toBeDefined(); - expect(executeTool?.name).toBe('tool_execute'); - }); - }); - - describe('tool_search', () => { - it('should find relevant BambooHR tools', async () => { - const filterTool = utilityTools.getTool('tool_search'); - assert(filterTool, 'filterTool should be defined'); - - const result = await filterTool.execute({ - query: 'manage employees in bamboohr', - limit: 5, - }); - - expect(result.tools).toBeDefined(); - expect(Array.isArray(result.tools)).toBe(true); - - const toolResults = getSearchResults(result); - const toolNames = toolResults.map((t) => t.name); - - expect(toolNames).toContain('bamboohr_create_employee'); - expect(toolNames).toContain('bamboohr_list_employees'); - }); - - it('should find time off related tools', async () => { - const filterTool = utilityTools.getTool('tool_search'); - assert(filterTool, 'filterTool should be defined'); - - const result = await filterTool.execute({ - query: 'time off request vacation leave', - limit: 3, - }); - - const toolResults = getSearchResults(result); - const toolNames = toolResults.map((t) => t.name); - - expect(toolNames).toContain('bamboohr_create_time_off'); - }); - - it('should respect limit parameter', async () => { - const filterTool = utilityTools.getTool('tool_search'); - assert(filterTool, 'filterTool should be defined'); - - const result = await filterTool.execute({ - query: 'create', - limit: 2, - }); - - const toolResults = getSearchResults(result); - expect(toolResults.length).toBeLessThanOrEqual(2); - }); - - it('should filter by minimum score', async () => { - const filterTool = utilityTools.getTool('tool_search'); - assert(filterTool, 'filterTool should be defined'); - - const result = await filterTool.execute({ - query: 'xyz123 nonexistent', - minScore: 0.8, - }); - - const toolResults = getSearchResults(result); - expect(toolResults.length).toBe(0); - }); - - it('should include tool configurations in results', async () => { - const filterTool = utilityTools.getTool('tool_search'); - assert(filterTool, 'filterTool should be defined'); - - const result = await filterTool.execute({ - query: 'create employee', - limit: 1, - }); - - const toolResults = getSearchResults(result); - expect(toolResults.length).toBeGreaterThan(0); - - const firstTool = toolResults[0]; - expect(firstTool).toHaveProperty('name'); - expect(firstTool).toHaveProperty('description'); - expect(firstTool).toHaveProperty('parameters'); - expect(firstTool).toHaveProperty('score'); - expect(typeof firstTool.score).toBe('number'); - }); - - it('should handle empty query', async () => { - const filterTool = utilityTools.getTool('tool_search'); - assert(filterTool, 'filterTool should be defined'); - - const result = await filterTool.execute({ - query: '', - limit: 5, - }); - - expect(result.tools).toBeDefined(); - expect(Array.isArray(result.tools)).toBe(true); - }); - - it('should handle string parameters', async () => { - const filterTool = utilityTools.getTool('tool_search'); - assert(filterTool, 'filterTool should be defined'); - - const result = await filterTool.execute( - JSON.stringify({ - query: 'candidates', - limit: 3, - }), - ); - - const toolResults = getSearchResults(result); - const toolNames = toolResults.map((t) => t.name); - - const hasCandidateTool = toolNames.some( - (name) => name === 'workday_create_candidate' || name === 'workday_list_candidates', - ); - expect(hasCandidateTool).toBe(true); - }); + 'List employees', + { type: 'object', properties: {} }, + { kind: 'http', method: 'GET', url: 'https://example.com', bodyType: 'json', params: [] }, + ); + expect(tool.connector).toBe('bamboohr'); }); - describe('tool_execute', () => { - it('should execute a tool by name', async () => { - const executeTool = utilityTools.getTool('tool_execute'); - assert(executeTool, 'executeTool should be defined'); - - const result = await executeTool.execute({ - toolName: 'bamboohr_list_employees', - params: { limit: 10 }, - }); - - expect(result).toEqual({ limit: 10 }); - }); - - it('should handle tools with required parameters', async () => { - const executeTool = utilityTools.getTool('tool_execute'); - assert(executeTool, 'executeTool should be defined'); - - const result = await executeTool.execute({ - toolName: 'bamboohr_create_employee', - params: { - name: 'John Doe', - email: 'john@example.com', - }, - }); - - expect(result).toEqual({ - name: 'John Doe', - email: 'john@example.com', - }); - }); - - it('should throw error for non-existent tool', async () => { - const executeTool = utilityTools.getTool('tool_execute'); - assert(executeTool, 'executeTool should be defined'); - - await expect( - executeTool.execute({ - toolName: 'nonexistent_tool', - params: {}, - }), - ).rejects.toThrow('Tool nonexistent_tool not found'); - }); - - it('should handle string parameters', async () => { - const executeTool = utilityTools.getTool('tool_execute'); - assert(executeTool, 'executeTool should be defined'); - - const result = await executeTool.execute( - JSON.stringify({ - toolName: 'salesforce_create_contact', - params: { - name: 'Jane Smith', - company: 'Acme Corp', - }, - }), - ); - - expect(result).toEqual({ - name: 'Jane Smith', - company: 'Acme Corp', - }); - }); - - it('should pass through execution options', async () => { - const executeTool = utilityTools.getTool('tool_execute'); - assert(executeTool, 'executeTool should be defined'); - - const result = await executeTool.execute({ - toolName: 'workday_list_candidates', - params: { status: 'active' }, - }); - - expect(result).toEqual({ status: 'active' }); - }); + it('should return the name itself for single-segment names', () => { + const tool = new BaseTool( + 'bamboohr', + 'BambooHR', + { type: 'object', properties: {} }, + { kind: 'http', method: 'GET', url: 'https://example.com', bodyType: 'json', params: [] }, + ); + expect(tool.connector).toBe('bamboohr'); }); - describe('Error handling', () => { - it('should wrap non-StackOneError in tool_search execute', async () => { - const filterTool = utilityTools.getTool('tool_search'); - assert(filterTool, 'filterTool should be defined'); - - // Pass invalid params type to trigger JSON.parse error on non-JSON string - await expect(filterTool.execute('not valid json')).rejects.toThrow('Error executing tool:'); - }); - - it('should wrap non-StackOneError in tool_execute execute', async () => { - const executeTool = utilityTools.getTool('tool_execute'); - assert(executeTool, 'executeTool should be defined'); - - // Pass invalid JSON string to trigger JSON.parse error - await expect(executeTool.execute('not valid json')).rejects.toThrow('Error executing tool:'); - }); - - it('should throw StackOneError for invalid params type in tool_search', async () => { - const filterTool = utilityTools.getTool('tool_search'); - assert(filterTool, 'filterTool should be defined'); - - // @ts-expect-error - intentionally passing invalid type - await expect(filterTool.execute(123)).rejects.toThrow('Invalid parameters type'); - }); - - it('should throw StackOneError for invalid params type in tool_execute', async () => { - const executeTool = utilityTools.getTool('tool_execute'); - assert(executeTool, 'executeTool should be defined'); - - // @ts-expect-error - intentionally passing invalid type - await expect(executeTool.execute(true)).rejects.toThrow('Invalid parameters type'); - }); + it('should return empty string for empty name', () => { + const tool = new BaseTool( + '', + 'Empty', + { type: 'object', properties: {} }, + { kind: 'http', method: 'GET', url: 'https://example.com', bodyType: 'json', params: [] }, + ); + expect(tool.connector).toBe(''); }); - describe('Integration: utility tools workflow', () => { - it('should discover and execute tools in sequence', async () => { - const filterTool = utilityTools.getTool('tool_search'); - const executeTool = utilityTools.getTool('tool_execute'); - assert(filterTool, 'filterTool should be defined'); - assert(executeTool, 'executeTool should be defined'); - - // Step 1: Discover relevant tools - const searchResult = await filterTool.execute({ - query: 'create new employee in HR system', - limit: 3, - }); - - const toolResults = getSearchResults(searchResult); - expect(toolResults.length).toBeGreaterThan(0); - - // Find the create employee tool - const createEmployeeTool = toolResults.find((t) => t.name === 'bamboohr_create_employee'); - assert(createEmployeeTool, 'createEmployeeTool should be defined'); - - // Step 2: Execute the discovered tool - const executeResult = await executeTool.execute({ - toolName: createEmployeeTool.name, - params: { - name: 'Alice Johnson', - email: 'alice@example.com', - }, - }); - - expect(executeResult).toEqual({ - name: 'Alice Johnson', - email: 'alice@example.com', - }); - }); + it('should return lowercase connector', () => { + const tool = new BaseTool( + 'BambooHR_create_employee', + 'Create employee', + { type: 'object', properties: {} }, + { kind: 'http', method: 'POST', url: 'https://example.com', bodyType: 'json', params: [] }, + ); + expect(tool.connector).toBe('bamboohr'); }); +}); - describe('OpenAI format', () => { - it('should convert utility tools to OpenAI format', () => { - const openAITools = utilityTools.toOpenAI(); - - expect(openAITools).toHaveLength(2); - - const filterTool = openAITools.find((t) => t.function.name === 'tool_search'); - expect(filterTool).toBeDefined(); - expect(filterTool?.function.parameters?.properties).toHaveProperty('query'); - expect(filterTool?.function.parameters?.properties).toHaveProperty('limit'); - expect(filterTool?.function.parameters?.properties).toHaveProperty('minScore'); - - const executeTool = openAITools.find((t) => t.function.name === 'tool_execute'); - expect(executeTool).toBeDefined(); - expect(executeTool?.function.parameters?.properties).toHaveProperty('toolName'); - expect(executeTool?.function.parameters?.properties).toHaveProperty('params'); - }); +describe('Tools.getConnectors', () => { + it('should return unique connector names from tool names', () => { + const tools = new Tools([ + new BaseTool( + 'bamboohr_create_employee', + 'Create employee', + { type: 'object', properties: {} }, + { kind: 'http', method: 'POST', url: 'https://example.com', bodyType: 'json', params: [] }, + ), + new BaseTool( + 'bamboohr_list_employees', + 'List employees', + { type: 'object', properties: {} }, + { kind: 'http', method: 'GET', url: 'https://example.com', bodyType: 'json', params: [] }, + ), + new BaseTool( + 'hibob_create_employee', + 'Create employee', + { type: 'object', properties: {} }, + { kind: 'http', method: 'POST', url: 'https://example.com', bodyType: 'json', params: [] }, + ), + new BaseTool( + 'slack_send_message', + 'Send message', + { type: 'object', properties: {} }, + { kind: 'http', method: 'POST', url: 'https://example.com', bodyType: 'json', params: [] }, + ), + ]); + + const connectors = tools.getConnectors(); + expect(connectors).toEqual(new Set(['bamboohr', 'hibob', 'slack'])); }); - describe('AI SDK format', () => { - it('should convert utility tools to AI SDK format', async () => { - const aiSdkTools = await utilityTools.toAISDK(); - - expect(aiSdkTools).toHaveProperty('tool_search'); - expect(aiSdkTools).toHaveProperty('tool_execute'); - - expect(typeof aiSdkTools.tool_search.execute).toBe('function'); - expect(typeof aiSdkTools.tool_execute.execute).toBe('function'); - }); - - it('should execute through AI SDK format', async () => { - const aiSdkTools = await utilityTools.toAISDK(); - - expect(aiSdkTools.tool_search.execute).toBeDefined(); - - const result = await aiSdkTools.tool_search.execute?.( - { query: 'workday candidates', limit: 2 }, - { toolCallId: 'test-call-1', messages: [] }, - ); - expect(result).toBeDefined(); - - const toolResults = (result as { tools: ToolSearchResult[] }).tools; - expect(Array.isArray(toolResults)).toBe(true); - - const toolNames = toolResults.map((t) => t.name); - expect(toolNames).toContain('workday_create_candidate'); - }); + it('should return empty set for empty tools', () => { + const tools = new Tools([]); + expect(tools.getConnectors()).toEqual(new Set()); }); -}); - -describe('Utility Tools - Hybrid Strategy', () => { - describe('Hybrid BM25 + TF-IDF search', () => { - it('should search using hybrid strategy with default alpha', async () => { - const tools = new Tools(createMockTools()); - const utilityTools = await tools.utilityTools(); - const searchTool = utilityTools.getTool('tool_search'); - assert(searchTool, 'searchTool should be defined'); - - const result = await searchTool.execute({ - query: 'manage employees', - limit: 5, - }); - expect(result.tools).toBeDefined(); - expect(Array.isArray(result.tools)).toBe(true); - const toolResults = getSearchResults(result); - expect(toolResults.length).toBeGreaterThan(0); - }); - - it('should search using hybrid strategy with custom alpha', async () => { - const tools = new Tools(createMockTools()); - const utilityTools = await tools.utilityTools(0.7); - const searchTool = utilityTools.getTool('tool_search'); - assert(searchTool, 'searchTool should be defined'); - - const result = await searchTool.execute({ - query: 'create candidate', - limit: 3, - }); - - const toolResults = getSearchResults(result); - const toolNames = toolResults.map((t) => t.name); - expect(toolNames).toContain('workday_create_candidate'); - }); - - it('should combine BM25 and TF-IDF scores', async () => { - const tools = new Tools(createMockTools()); - const utilityTools = await tools.utilityTools(0.5); - const searchTool = utilityTools.getTool('tool_search'); - assert(searchTool, 'searchTool should be defined'); - - const result = await searchTool.execute({ - query: 'employee', - limit: 10, - }); - - const toolResults = getSearchResults(result); - expect(toolResults.length).toBeGreaterThan(0); - - for (const tool of toolResults) { - expect(tool.score).toBeGreaterThanOrEqual(0); - expect(tool.score).toBeLessThanOrEqual(1); - } - }); - - it('should find relevant tools', async () => { - const tools = new Tools(createMockTools()); - const utilityTools = await tools.utilityTools(); - const searchTool = utilityTools.getTool('tool_search'); - assert(searchTool, 'searchTool should be defined'); - - const result = await searchTool.execute({ - query: 'time off vacation', - limit: 3, - }); - - const toolResults = getSearchResults(result); - const toolNames = toolResults.map((t) => t.name); - expect(toolNames).toContain('bamboohr_create_time_off'); - }); + it('should return lowercase connector names', () => { + const tools = new Tools([ + new BaseTool( + 'BambooHR_create_employee', + 'Create employee', + { type: 'object', properties: {} }, + { kind: 'http', method: 'POST', url: 'https://example.com', bodyType: 'json', params: [] }, + ), + ]); + + const connectors = tools.getConnectors(); + expect(connectors).toEqual(new Set(['bamboohr'])); }); }); diff --git a/src/tool.ts b/src/tool.ts index e7093b4..9412d1d 100644 --- a/src/tool.ts +++ b/src/tool.ts @@ -1,12 +1,10 @@ import { type JSONSchema7 as AISDKJSONSchema, jsonSchema } from 'ai'; import type { Tool as AnthropicTool } from '@anthropic-ai/sdk/resources'; import type { McpSdkServerConfigWithInstance } from '@anthropic-ai/claude-agent-sdk'; -import * as orama from '@orama/orama'; import type { ChatCompletionFunctionTool } from 'openai/resources/chat/completions'; import type { FunctionTool as OpenAIResponsesFunctionTool } from 'openai/resources/responses/responses'; import type { OverrideProperties } from 'type-fest'; import { peerDependencies } from '../package.json'; -import { DEFAULT_HYBRID_ALPHA } from './consts'; import { RequestBuilder } from './requestBuilder'; import type { AISDKToolDefinition, @@ -24,7 +22,6 @@ import type { } from './types'; import { StackOneError } from './utils/error-stackone'; -import { TfidfIndex } from './utils/tfidf-index'; import { tryImport } from './utils/try-import'; /** @@ -124,6 +121,16 @@ export class BaseTool { return { ...this.#headers }; } + /** + * Extract connector/provider prefix from the tool name. + * + * Tool names follow the format `{connector}_{action}_{entity}`, + * e.g. `"bamboohr_list_employees"` → `"bamboohr"`. + */ + get connector(): string { + return this.name.split('_')[0]?.toLowerCase() ?? ''; + } + /** * Control whether execution metadata should be exposed in AI SDK conversions. */ @@ -493,16 +500,28 @@ export class Tools implements Iterable { } /** - * 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). + * Get unique connector names from all tools. + * + * Extracts the connector/provider prefix from each tool name + * (the first segment before `_`). + * + * @returns Set of connector names (lowercase) + * + * @example + * ```typescript + * const tools = await toolset.fetchTools(); + * const connectors = tools.getConnectors(); + * // Set { 'bamboohr', 'hibob', 'slack', ... } + * ``` */ - 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; + getConnectors(): Set { + const connectors = new Set(); + for (const tool of this.tools) { + if (tool.connector) { + connectors.add(tool.connector); + } + } + return connectors; } /** @@ -543,299 +562,3 @@ export class Tools implements Iterable { this.tools.forEach(callback); } } - -/** - * Result from tool_search - */ -export interface ToolSearchResult { - name: string; - description: string; - parameters: ToolParameters; - score: number; -} - -type OramaDb = ReturnType; - -/** - * Initialize TF-IDF index for tool search - */ -function initializeTfidfIndex(tools: BaseTool[]): TfidfIndex { - const index = new TfidfIndex(); - const corpus = tools.map((tool) => { - // Extract integration from tool name (e.g., 'bamboohr_create_employee' -> 'bamboohr') - const parts = tool.name.split('_'); - const integration = parts[0]; - - // Extract action type - const actionTypes = ['create', 'update', 'delete', 'get', 'list', 'search']; - const actions = parts.filter((p) => actionTypes.includes(p)); - - // Build text corpus for TF-IDF (similar weighting strategy as in tool-calling-evals) - const text = [ - `${tool.name} ${tool.name} ${tool.name}`, // boost name - `${integration} ${actions.join(' ')}`, - tool.description, - parts.join(' '), - ].join(' '); - - return { id: tool.name, text }; - }); - - index.build(corpus); - return index; -} - -/** - * Initialize Orama database with BM25 algorithm for tool search - * Using Orama's BM25 scoring algorithm for relevance ranking - * @see https://docs.orama.com/open-source/usage/create - * @see https://docs.orama.com/open-source/usage/search/bm25-algorithm/ - */ -async function initializeOramaDb(tools: BaseTool[]): Promise { - // Create Orama database schema with BM25 scoring algorithm - // BM25 provides better relevance ranking for natural language queries - const oramaDb = orama.create({ - schema: { - name: 'string' as const, - description: 'string' as const, - integration: 'string' as const, - tags: 'string[]' as const, - }, - components: { - tokenizer: { - stemming: true, - }, - }, - }); - - // Index all tools - for (const tool of tools) { - // Extract integration from tool name (e.g., 'bamboohr_create_employee' -> 'bamboohr') - const parts = tool.name.split('_'); - const integration = parts[0]; - - // Extract action type - const actionTypes = ['create', 'update', 'delete', 'get', 'list', 'search']; - const actions = parts.filter((p) => actionTypes.includes(p)); - - await orama.insert(oramaDb, { - name: tool.name, - description: tool.description, - integration: integration, - tags: [...parts, ...actions], - }); - } - - return oramaDb; -} - -function toolSearch( - oramaDb: OramaDb, - tfidfIndex: TfidfIndex, - allTools: BaseTool[], - hybridAlpha = DEFAULT_HYBRID_ALPHA, -): BaseTool { - const name = 'tool_search' as const; - const description = - `Searches for relevant tools based on a natural language query using hybrid BM25 + TF-IDF search (alpha=${hybridAlpha}). This tool should be called 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., "tools for managing employees", "create time off request")', - }, - limit: { - type: 'number', - description: 'Maximum number of tools to return (default: 5)', - default: 5, - }, - minScore: { - type: 'number', - description: 'Minimum relevance score (0-1) for results (default: 0.3)', - default: 0.3, - }, - }, - required: ['query'], - } as const satisfies ToolParameters; - - const executeConfig = { - kind: 'local', - identifier: name, - description: 'local://get-relevant-tools', - } as const satisfies LocalExecuteConfig; - - const tool = new BaseTool(name, description, parameters, executeConfig); - tool.execute = async (inputParams?: JsonObject | string): Promise => { - try { - // Validate params is either undefined, string, or object - 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, - )}`, - ); - } - - // Convert string params to object - const params = typeof inputParams === 'string' ? JSON.parse(inputParams) : inputParams || {}; - const limit = params.limit || 5; - const minScore = params.minScore ?? 0.3; - const query = params.query || ''; - - // Hybrid: BM25 + TF-IDF fusion - const alpha = Math.max(0, Math.min(1, hybridAlpha)); - - // Get results from both algorithms - const [bm25Results, tfidfResults] = await Promise.all([ - orama.search(oramaDb, { - term: query, - limit: Math.max(50, limit), - } as Parameters[1]), - Promise.resolve(tfidfIndex.search(query, Math.max(50, limit))), - ]); - - // Build score map - const scoreMap = new Map(); - - for (const hit of bm25Results.hits) { - const doc = hit.document as { name: string }; - scoreMap.set(doc.name, { - ...scoreMap.get(doc.name), - bm25: clamp01(hit.score), - }); - } - - for (const r of tfidfResults) { - scoreMap.set(r.id, { - ...scoreMap.get(r.id), - tfidf: clamp01(r.score), - }); - } - - // Fuse scores - const fused: Array<{ name: string; score: number }> = []; - for (const [name, scores] of scoreMap) { - const bm25 = scores.bm25 ?? 0; - const tfidf = scores.tfidf ?? 0; - const score = alpha * bm25 + (1 - alpha) * tfidf; - fused.push({ name, score }); - } - - fused.sort((a, b) => b.score - a.score); - - const toolConfigs = fused - .filter((r) => r.score >= minScore) - .map((r) => { - const tool = allTools.find((t) => t.name === r.name); - if (!tool) return null; - - return { - name: tool.name, - description: tool.description, - parameters: tool.parameters, - score: r.score, - }; - }) - .filter((t): t is ToolSearchResult => t !== null) - .slice(0, limit); - - // Convert to JSON-serialisable format (removes undefined values) - return JSON.parse(JSON.stringify({ tools: toolConfigs })) 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; -} - -/** - * Clamp value to [0, 1] - */ -function clamp01(x: number): number { - return x < 0 ? 0 : x > 1 ? 1 : x; -} - -function toolExecute(tools: Tools): BaseTool { - const name = 'tool_execute' as const; - const description = - 'Executes a specific tool by name with the provided parameters. Use this after discovering tools with tool_search.' as const; - const parameters = { - type: 'object', - properties: { - toolName: { - type: 'string', - description: 'Name of the tool to execute', - }, - params: { - type: 'object', - description: 'Parameters to pass to the tool', - }, - }, - required: ['toolName', 'params'], - } as const satisfies ToolParameters; - - const executeConfig = { - kind: 'local', - identifier: name, - description: 'local://execute-tool', - } as const satisfies LocalExecuteConfig; - - // Create the tool instance - const tool = new BaseTool(name, description, parameters, executeConfig); - - // Override the execute method to handle tool execution - // receives tool name and parameters and executes the tool - tool.execute = async ( - inputParams?: JsonObject | string, - options?: ExecuteOptions, - ): Promise => { - try { - // Validate params is either undefined, string, or object - 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, - )}`, - ); - } - - // Convert string params to object - const params = typeof inputParams === 'string' ? JSON.parse(inputParams) : inputParams || {}; - - // Extract tool name and parameters - const { toolName, params: toolParams } = params; - - // Find the tool by name - const toolToExecute = tools.getTool(toolName); - if (!toolToExecute) { - throw new StackOneError(`Tool ${toolName} not found`); - } - - // Execute the tool with the provided parameters - return await toolToExecute.execute(toolParams, options); - } 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.test.ts b/src/toolsets.test.ts index 23aca6b..8021c70 100644 --- a/src/toolsets.test.ts +++ b/src/toolsets.test.ts @@ -7,10 +7,12 @@ * - Account filtering * - Provider and action filtering */ -import { http } from 'msw'; +import { http, HttpResponse } from 'msw'; import { type McpToolDefinition, createMcpApp } from '../mocks/mcp-server'; import { server } from '../mocks/node'; -import { StackOneToolSet, ToolSetConfigError } from './toolsets'; +import { TEST_BASE_URL } from '../mocks/constants'; +import { SemanticSearchError } from './semantic-search'; +import { SearchTool, StackOneToolSet, ToolSetConfigError } from './toolsets'; describe('StackOneToolSet', () => { beforeEach(() => { @@ -226,7 +228,7 @@ describe('StackOneToolSet', () => { describe('fetchTools (MCP integration)', () => { it('creates tools from MCP catalog and wires RPC execution', async () => { const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', + baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'test-account', }); @@ -255,7 +257,7 @@ describe('StackOneToolSet', () => { describe('account filtering', () => { it('supports setAccounts() for chaining', () => { const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', + baseUrl: TEST_BASE_URL, apiKey: 'test-key', }); @@ -266,7 +268,7 @@ describe('StackOneToolSet', () => { it('fetches tools without account filtering when no accountIds provided', async () => { const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', + baseUrl: TEST_BASE_URL, apiKey: 'test-key', }); @@ -281,7 +283,7 @@ describe('StackOneToolSet', () => { it('uses x-account-id header when fetching tools with accountIds', async () => { const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', + baseUrl: TEST_BASE_URL, apiKey: 'test-key', }); @@ -297,7 +299,7 @@ describe('StackOneToolSet', () => { it('uses setAccounts when no accountIds provided in fetchTools', async () => { const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', + baseUrl: TEST_BASE_URL, apiKey: 'test-key', }); @@ -320,7 +322,7 @@ describe('StackOneToolSet', () => { it('uses accountIds from constructor when no accountIds provided in fetchTools', async () => { const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', + baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountIds: ['acc1', 'acc2'], }); @@ -341,7 +343,7 @@ describe('StackOneToolSet', () => { it('setAccounts overrides constructor accountIds', async () => { const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', + baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountIds: ['acc1'], }); @@ -365,7 +367,7 @@ describe('StackOneToolSet', () => { it('overrides setAccounts when accountIds provided in fetchTools', async () => { const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', + baseUrl: TEST_BASE_URL, apiKey: 'test-key', }); @@ -386,7 +388,7 @@ describe('StackOneToolSet', () => { describe('tool execution', () => { it('should execute tool with dryRun option', async () => { const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', + baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'test-account', }); @@ -397,7 +399,7 @@ describe('StackOneToolSet', () => { const result = await tool.execute({ body: { name: 'test' } }, { dryRun: true }); - expect(result.url).toBe('https://api.stackone-dev.com/actions/rpc'); + expect(result.url).toBe(`${TEST_BASE_URL}/actions/rpc`); expect(result.method).toBe('POST'); expect(result.headers).toBeDefined(); expect(result.body).toBeDefined(); @@ -406,7 +408,7 @@ describe('StackOneToolSet', () => { it('should execute tool with path, query, and headers params', async () => { const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', + baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'test-account', }); @@ -435,7 +437,7 @@ describe('StackOneToolSet', () => { it('should execute tool with string parameters', async () => { const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', + baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'test-account', }); @@ -453,7 +455,7 @@ describe('StackOneToolSet', () => { it('should throw StackOneError for invalid parameter type', async () => { const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', + baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'test-account', }); @@ -468,7 +470,7 @@ describe('StackOneToolSet', () => { it('should wrap non-StackOneError in execute', async () => { const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', + baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'test-account', }); @@ -483,7 +485,7 @@ describe('StackOneToolSet', () => { it('should include extra params in rpcBody', async () => { const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', + baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'test-account', }); @@ -514,7 +516,7 @@ describe('StackOneToolSet', () => { describe('provider and action filtering', () => { it('filters tools by providers', async () => { const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', + baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'mixed', }); @@ -535,7 +537,7 @@ describe('StackOneToolSet', () => { it('filters tools by actions with exact match', async () => { const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', + baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'mixed', }); @@ -555,7 +557,7 @@ describe('StackOneToolSet', () => { it('filters tools by actions with glob pattern', async () => { const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', + baseUrl: TEST_BASE_URL, apiKey: 'test-key', accountId: 'mixed', }); @@ -623,13 +625,13 @@ describe('StackOneToolSet', () => { }, }); server.use( - http.all('https://api.stackone-dev.com/mcp', async ({ request }) => { + http.all(`${TEST_BASE_URL}/mcp`, async ({ request }) => { return testMcpApp.fetch(request); }), ); const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', + baseUrl: TEST_BASE_URL, apiKey: 'test-key', }); @@ -685,13 +687,13 @@ describe('StackOneToolSet', () => { }, }); server.use( - http.all('https://api.stackone-dev.com/mcp', async ({ request }) => { + http.all(`${TEST_BASE_URL}/mcp`, async ({ request }) => { return testMcpApp.fetch(request); }), ); const toolset = new StackOneToolSet({ - baseUrl: 'https://api.stackone-dev.com', + baseUrl: TEST_BASE_URL, apiKey: 'test-key', }); @@ -709,4 +711,250 @@ describe('StackOneToolSet', () => { expect(toolNames).toContain('tool_feedback'); }); }); + + describe('searchTools', () => { + it('returns tools from semantic search results', async () => { + // Set up MCP with mixed provider tools (hibob, bamboohr, workday) + const toolset = new StackOneToolSet({ + baseUrl: TEST_BASE_URL, + apiKey: 'test-key', + accountId: 'mixed', + }); + + // Mock the semantic search endpoint + server.use( + http.post(`${TEST_BASE_URL}/actions/search`, async ({ request }) => { + const body = (await request.json()) as Record; + expect(body.query).toBe('list employees'); + + return HttpResponse.json({ + results: [ + { + action_name: 'hibob_list_employees', + connector_key: 'hibob', + similarity_score: 0.95, + label: 'List Employees', + description: 'List employees from HiBob', + }, + { + action_name: 'bamboohr_list_employees', + connector_key: 'bamboohr', + similarity_score: 0.88, + label: 'List Employees', + description: 'List employees from BambooHR', + }, + ], + total_count: 2, + query: 'list employees', + connector_filter: body.connector, + }); + }), + ); + + const tools = await toolset.searchTools('list employees', { topK: 5 }); + const toolNames = tools.toArray().map((t) => t.name); + + expect(toolNames).toContain('hibob_list_employees'); + expect(toolNames).toContain('bamboohr_list_employees'); + }); + + it('falls back to local search in auto mode when semantic fails', async () => { + const toolset = new StackOneToolSet({ + baseUrl: TEST_BASE_URL, + apiKey: 'test-key', + accountId: 'mixed', + }); + + // Mock semantic search to fail + server.use( + http.post(`${TEST_BASE_URL}/actions/search`, () => { + return new HttpResponse('Service Unavailable', { status: 503 }); + }), + ); + + // Should fall back to local search without throwing + const tools = await toolset.searchTools('list employees', { + search: 'auto', + topK: 5, + }); + + // Local search should return some results from the mixed provider tools + expect(tools.length).toBeGreaterThan(0); + }); + + it('throws SemanticSearchError in semantic mode when API fails', async () => { + const toolset = new StackOneToolSet({ + baseUrl: TEST_BASE_URL, + apiKey: 'test-key', + accountId: 'mixed', + }); + + // Mock semantic search to fail + server.use( + http.post(`${TEST_BASE_URL}/actions/search`, () => { + return new HttpResponse('Internal Server Error', { status: 500 }); + }), + ); + + await expect(toolset.searchTools('list employees', { search: 'semantic' })).rejects.toThrow( + SemanticSearchError, + ); + }); + + it('uses local search mode directly', async () => { + const toolset = new StackOneToolSet({ + baseUrl: TEST_BASE_URL, + apiKey: 'test-key', + accountId: 'mixed', + }); + + const tools = await toolset.searchTools('list employees', { + search: 'local', + topK: 3, + }); + + // Local search should return results without calling semantic API + expect(tools.length).toBeGreaterThan(0); + const toolNames = tools.toArray().map((t) => t.name); + // Should find employee-related tools + const hasEmployeeTool = toolNames.some((name) => name.includes('employee')); + expect(hasEmployeeTool).toBe(true); + }); + + it('returns empty tools when no connectors available', async () => { + // Use default account (no mixed tools, just default tools without connectors) + const toolset = new StackOneToolSet({ + baseUrl: TEST_BASE_URL, + apiKey: 'test-key', + accountId: 'test-account', + }); + + // test-account only has dummy_action which has a connector prefix "dummy" + // but semantic search for "list employees" on dummy connector returns nothing useful + + server.use( + http.post(`${TEST_BASE_URL}/actions/search`, () => { + return HttpResponse.json({ + results: [], + total_count: 0, + query: 'list employees', + }); + }), + ); + + const tools = await toolset.searchTools('list employees'); + // No matching tools from semantic search + expect(tools.length).toBe(0); + }); + }); + + describe('searchActionNames', () => { + it('returns semantic search results with normalized action names', async () => { + const toolset = new StackOneToolSet({ + baseUrl: TEST_BASE_URL, + apiKey: 'test-key', + accountId: 'mixed', + }); + + server.use( + http.post(`${TEST_BASE_URL}/actions/search`, () => { + return HttpResponse.json({ + results: [ + { + action_name: 'hibob_1.0.0_hibob_list_employees_global', + connector_key: 'hibob', + similarity_score: 0.95, + label: 'List Employees', + description: 'List employees', + }, + ], + total_count: 1, + query: 'list employees', + }); + }), + ); + + 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'); + }); + + it('returns empty array when semantic search fails', async () => { + const toolset = new StackOneToolSet({ + baseUrl: TEST_BASE_URL, + apiKey: 'test-key', + accountId: 'mixed', + }); + + server.use( + http.post(`${TEST_BASE_URL}/actions/search`, () => { + return new HttpResponse('Internal Server Error', { status: 500 }); + }), + ); + + const results = await toolset.searchActionNames('list employees'); + expect(results).toEqual([]); + }); + }); + + describe('getSearchTool', () => { + it('returns a SearchTool instance', () => { + const toolset = new StackOneToolSet({ + baseUrl: TEST_BASE_URL, + apiKey: 'test-key', + }); + + const searchTool = toolset.getSearchTool(); + expect(searchTool).toBeInstanceOf(SearchTool); + }); + + it('SearchTool.search delegates to searchTools', async () => { + const toolset = new StackOneToolSet({ + baseUrl: TEST_BASE_URL, + apiKey: 'test-key', + accountId: 'mixed', + }); + + server.use( + http.post(`${TEST_BASE_URL}/actions/search`, () => { + return HttpResponse.json({ + results: [ + { + action_name: 'hibob_list_employees', + connector_key: 'hibob', + similarity_score: 0.95, + label: 'List Employees', + description: 'List employees', + }, + ], + total_count: 1, + query: 'list employees', + }); + }), + ); + + const searchTool = toolset.getSearchTool(); + const tools = await searchTool.search('list employees'); + const toolNames = tools.toArray().map((t) => t.name); + + expect(toolNames).toContain('hibob_list_employees'); + }); + + it('uses configured search mode', async () => { + const toolset = new StackOneToolSet({ + baseUrl: TEST_BASE_URL, + apiKey: 'test-key', + accountId: 'mixed', + }); + + // Create search tool with local mode + const searchTool = toolset.getSearchTool({ search: 'local' }); + + // Should not call semantic API at all + const tools = await searchTool.search('list employees', { topK: 3 }); + expect(tools.length).toBeGreaterThan(0); + }); + }); }); diff --git a/src/toolsets.ts b/src/toolsets.ts index 9405e89..18a1324 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -3,17 +3,25 @@ import type { MergeExclusive, SimplifyDeep } from 'type-fest'; import { DEFAULT_BASE_URL } from './consts'; import { createFeedbackTool } from './feedback'; import { type StackOneHeaders, normalizeHeaders, stackOneHeadersSchema } from './headers'; +import { ToolIndex } from './local-search'; import { createMCPClient } from './mcp-client'; import { type RpcActionResponse, RpcClient } from './rpc-client'; +import { + SemanticSearchClient, + SemanticSearchError, + type SemanticSearchResult, +} from './semantic-search'; import { BaseTool, Tools } from './tool'; import type { ExecuteOptions, JsonObject, JsonSchemaProperties, RpcExecuteConfig, + SearchConfig, ToolParameters, } from './types'; import { StackOneError } from './utils/error-stackone'; +import { normalizeActionName } from './utils/normalize'; /** * Converts RpcActionResponse to JsonObject in a type-safe manner. @@ -122,6 +130,17 @@ type AccountConfig = SimplifyDeep { + return this.toolset.searchTools(query, { + ...options, + search: options?.search ?? this.defaultConfig.method, + topK: options?.topK ?? this.defaultConfig.topK, + minSimilarity: options?.minSimilarity ?? this.defaultConfig.minSimilarity, + }); + } +} + /** * Class for loading StackOne tools via MCP */ @@ -163,6 +260,7 @@ export class StackOneToolSet { private authentication?: AuthenticationConfig; private headers: Record; private rpcClient?: RpcClient; + private readonly searchConfig: SearchConfig | null; /** * Account ID for StackOne API @@ -219,6 +317,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 }; + // Set Authentication headers if provided if (this.authentication) { // Only set auth headers if they don't already exist in custom headers @@ -255,6 +356,8 @@ export class StackOneToolSet { } } + private semanticSearchClient?: SemanticSearchClient; + /** * Set account IDs for filtering tools * @param accountIds Array of account IDs to filter tools by @@ -265,6 +368,352 @@ export class StackOneToolSet { return this; } + /** + * Get or lazily create the semantic search client. + */ + private getSemanticClient(): SemanticSearchClient { + if (!this.semanticSearchClient) { + const apiKey = this.getApiKey(); + this.semanticSearchClient = new SemanticSearchClient({ + apiKey, + baseUrl: this.baseUrl, + }); + } + return this.semanticSearchClient; + } + + /** + * Extract the API key from authentication config. + */ + private getApiKey(): string { + const credentials = this.authentication?.credentials ?? {}; + const apiKeyFromAuth = + this.authentication?.type === 'basic' + ? credentials.username + : this.authentication?.type === 'bearer' + ? credentials.token + : credentials.username; + + const apiKey = apiKeyFromAuth || process.env.STACKONE_API_KEY; + if (!apiKey) { + throw new ToolSetConfigError( + 'API key is required for semantic search. Provide apiKey in config or set STACKONE_API_KEY environment variable.', + ); + } + return apiKey; + } + + /** + * Get a callable search tool that returns Tools collections. + * + * Returns a SearchTool instance that wraps `searchTools()` for use in agent loops. + * + * @param options - Options including the default search mode + * @returns SearchTool instance + * + * @example + * ```typescript + * const toolset = new StackOneToolSet({ apiKey: 'sk-xxx' }); + * const searchTool = toolset.getSearchTool(); + * const tools = await searchTool.search('manage employee records', { accountIds: ['acc-123'] }); + * ``` + */ + getSearchTool(options?: { search?: SearchMode }): SearchTool { + if (this.searchConfig === null) { + throw new ToolSetConfigError( + 'Search is disabled. Initialize StackOneToolSet with a search config to enable.', + ); + } + + const config: SearchConfig = options?.search + ? { ...this.searchConfig, method: options.search } + : this.searchConfig; + + return new SearchTool(this, config); + } + + /** + * Search for and fetch tools using semantic or local search. + * + * This method discovers relevant tools based on natural language queries. + * + * @param query - Natural language description of needed functionality + * (e.g., "create employee", "send a message") + * @param options - Search options + * @returns Tools collection with matched tools from linked accounts + * @throws SemanticSearchError if the API call fails and search is "semantic" + * + * @example + * ```typescript + * // Semantic search (default with local fallback) + * const tools = await toolset.searchTools('manage employee records', { topK: 5 }); + * + * // Explicit semantic search + * const tools = await toolset.searchTools('manage employees', { search: 'semantic' }); + * + * // Local BM25+TF-IDF search + * const tools = await toolset.searchTools('manage employees', { search: 'local' }); + * + * // Filter by connector + * const tools = await toolset.searchTools('create time off request', { + * connector: 'bamboohr', + * search: 'semantic', + * }); + * ``` + */ + async searchTools(query: string, options?: SearchToolsOptions): Promise { + if (this.searchConfig === null) { + throw new ToolSetConfigError( + 'Search is disabled. Initialize StackOneToolSet with a search config to enable.', + ); + } + + const search = options?.search ?? this.searchConfig.method ?? 'auto'; + const topK = options?.topK ?? this.searchConfig.topK; + const minSimilarity = options?.minSimilarity ?? this.searchConfig.minSimilarity; + const mergedOptions = { ...options, search, topK, minSimilarity }; + + const allTools = await this.fetchTools({ accountIds: mergedOptions.accountIds }); + const availableConnectors = allTools.getConnectors(); + + if (availableConnectors.size === 0) { + return new Tools([]); + } + + // Local-only search — skip semantic API entirely + if (search === 'local') { + return this.localSearch(query, allTools, mergedOptions); + } + + try { + // Determine which connectors to search + let connectorsToSearch: Set; + if (mergedOptions.connector) { + const connectorLower = mergedOptions.connector.toLowerCase(); + connectorsToSearch = availableConnectors.has(connectorLower) + ? new Set([connectorLower]) + : new Set(); + if (connectorsToSearch.size === 0) { + return new Tools([]); + } + } else { + connectorsToSearch = availableConnectors; + } + + // Search each connector in parallel — in auto mode, treat missing + // API key as "semantic unavailable" and fall back to local search. + let client: SemanticSearchClient; + try { + client = this.getSemanticClient(); + } catch (error) { + if (search === 'auto' && error instanceof ToolSetConfigError) { + return this.localSearch(query, allTools, mergedOptions); + } + throw error; + } + const allResults: SemanticSearchResult[] = []; + let lastError: SemanticSearchError | undefined; + + const searchPromises = [...connectorsToSearch].map(async (connector) => { + try { + const response = await client.search(query, { + connector, + topK: mergedOptions.topK, + minSimilarity: mergedOptions.minSimilarity, + }); + return response.results; + } catch (error) { + if (error instanceof SemanticSearchError) { + lastError = error; + return []; + } + throw error; + } + }); + + const resultArrays = await Promise.all(searchPromises); + for (const results of resultArrays) { + allResults.push(...results); + } + + // If ALL connector searches failed, re-raise to trigger fallback + if (allResults.length === 0 && lastError) { + throw lastError; + } + + // Sort by score, apply topK + allResults.sort((a, b) => b.similarityScore - a.similarityScore); + const topResults = + mergedOptions.topK != null ? allResults.slice(0, mergedOptions.topK) : allResults; + + if (topResults.length === 0) { + 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)); + + // Sort matched tools by semantic search score order + const actionOrder = new Map(topResults.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) { + if (search === 'semantic') { + throw error; + } + + // Auto mode: silently fall back to local search + return this.localSearch(query, allTools, mergedOptions); + } + throw error; + } + } + + /** + * Search for action names without fetching tools. + * + * 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 + * + * @example + * ```typescript + * // 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)}`); + * } + * + * // Then fetch specific high-scoring actions + * const selected = results + * .filter(r => r.similarityScore > 0.7) + * .map(r => r.actionName); + * const tools = await toolset.fetchTools({ actions: selected }); + * ``` + */ + async searchActionNames( + query: string, + options?: SearchActionNamesOptions, + ): Promise { + if (this.searchConfig === null) { + throw new ToolSetConfigError( + 'Search is disabled. Initialize StackOneToolSet with a search config to enable.', + ); + } + + const effectiveTopK = options?.topK ?? this.searchConfig.topK; + const effectiveMinSimilarity = options?.minSimilarity ?? this.searchConfig.minSimilarity; + + // 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 []; + } + } + + try { + const client = this.getSemanticClient(); + let allResults: SemanticSearchResult[] = []; + + if (availableConnectors) { + // Parallel per-connector search (only user's connectors) + let connectorsToSearch: Set; + if (options?.connector) { + const connectorLower = options.connector.toLowerCase(); + connectorsToSearch = availableConnectors.has(connectorLower) + ? new Set([connectorLower]) + : new Set(); + } else { + connectorsToSearch = availableConnectors; + } + + const searchPromises = [...connectorsToSearch].map(async (connector) => { + try { + const response = await client.search(query, { + connector, + topK: effectiveTopK, + minSimilarity: effectiveMinSimilarity, + }); + return response.results; + } catch { + return []; + } + }); + + const resultArrays = await Promise.all(searchPromises); + for (const results of resultArrays) { + allResults.push(...results); + } + } else { + // No account filtering — single global search + const response = await client.search(query, { + connector: options?.connector, + topK: effectiveTopK, + minSimilarity: effectiveMinSimilarity, + }); + allResults = response.results; + } + + // Sort by score, normalize action names + 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; + } catch (error) { + if (error instanceof SemanticSearchError) { + return []; + } + throw error; + } + } + + /** + * Run local BM25+TF-IDF search over already-fetched tools. + */ + private async localSearch( + query: string, + allTools: Tools, + options?: Pick, + ): Promise { + const availableConnectors = allTools.getConnectors(); + if (availableConnectors.size === 0) { + return new Tools([]); + } + + const index = new ToolIndex(allTools.toArray()); + const results = await index.search(query, options?.topK ?? 5, options?.minSimilarity ?? 0.0); + + const matchedNames = results.map((r) => r.name); + const toolMap = new Map(allTools.toArray().map((t) => [t.name, t])); + const filterConnectors = options?.connector + ? new Set([options.connector.toLowerCase()]) + : availableConnectors; + + const matchedTools = matchedNames + .filter((name) => toolMap.has(name)) + .map((name) => toolMap.get(name)!) + .filter((tool) => tool.connector && filterConnectors.has(tool.connector)); + + return new Tools(options?.topK != null ? matchedTools.slice(0, options.topK) : matchedTools); + } + /** * Fetch tools from MCP with optional filtering * @param options Optional filtering options for account IDs, providers, and actions @@ -355,9 +804,7 @@ export class StackOneToolSet { if (options?.providers && options.providers.length > 0) { const providerSet = new Set(options.providers.map((p) => p.toLowerCase())); filteredTools = filteredTools.filter((tool) => { - // Extract provider from tool name (assuming format: provider_action) - const provider = tool.name.split('_')[0]?.toLowerCase(); - return provider && providerSet.has(provider); + return tool.connector && providerSet.has(tool.connector); }); } diff --git a/src/types.ts b/src/types.ts index 0190381..ba3ebf5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -205,6 +205,25 @@ export type AISDKToolResult = ToolSet & { /** * Options for toClaudeAgentSdk() method */ +/** + * Search configuration for the StackOneToolSet constructor. + * + * When provided as an object, sets default search options that flow through + * to `searchTools()`, `getSearchTool()`, and `searchActionNames()`. + * Per-call options override these defaults. + * + * When set to `null`, search is disabled entirely. + * When omitted (`undefined`), defaults to `{ method: 'auto' }`. + */ +export interface SearchConfig { + /** Search backend to use. Defaults to `'auto'`. */ + method?: 'auto' | 'semantic' | 'local'; + /** Maximum number of tools to return. */ + topK?: number; + /** Minimum similarity score threshold 0-1. */ + minSimilarity?: number; +} + export interface ClaudeAgentSdkOptions { /** * Name of the MCP server. Defaults to 'stackone-tools'. diff --git a/src/utils/error-stackone-api.test.ts b/src/utils/error-stackone-api.test.ts index cca55b9..437abd1 100644 --- a/src/utils/error-stackone-api.test.ts +++ b/src/utils/error-stackone-api.test.ts @@ -1,4 +1,5 @@ import { USER_AGENT } from '../consts'; +import { TEST_BASE_URL } from '../../mocks/constants'; import { StackOneAPIError } from './error-stackone-api'; describe('StackOneAPIError', () => { @@ -70,12 +71,12 @@ describe('StackOneAPIError', () => { it('should include endpoint URL when present in message', () => { const error = new StackOneAPIError( - 'Request failed for https://api.stackone.com/tools/execute', + `Request failed for ${TEST_BASE_URL}/tools/execute`, 404, {}, ); const result = error.toString(); - expect(result).toContain('Endpoint: https://api.stackone.com/tools/execute'); + expect(result).toContain(`Endpoint: ${TEST_BASE_URL}/tools/execute`); }); it('should format object requestBody as JSON', () => { diff --git a/src/utils/normalize.test.ts b/src/utils/normalize.test.ts new file mode 100644 index 0000000..0b45044 --- /dev/null +++ b/src/utils/normalize.test.ts @@ -0,0 +1,41 @@ +import { normalizeActionName } from './normalize'; + +describe('normalizeActionName', () => { + test('strips versioned API name to MCP format', () => { + expect(normalizeActionName('calendly_1.0.0_calendly_create_scheduling_link_global')).toBe( + 'calendly_create_scheduling_link', + ); + }); + + test('handles multi-digit version numbers', () => { + expect(normalizeActionName('bamboohr_2.10.3_bamboohr_list_employees_global')).toBe( + 'bamboohr_list_employees', + ); + }); + + test('returns input unchanged when no version pattern matches', () => { + expect(normalizeActionName('bamboohr_create_employee')).toBe('bamboohr_create_employee'); + }); + + test('returns input unchanged for empty string', () => { + expect(normalizeActionName('')).toBe(''); + }); + + test('returns input unchanged when missing _global suffix', () => { + expect(normalizeActionName('calendly_1.0.0_calendly_create_link')).toBe( + 'calendly_1.0.0_calendly_create_link', + ); + }); + + test('returns input unchanged for uppercase names', () => { + expect(normalizeActionName('Calendly_1.0.0_create_link_global')).toBe( + 'Calendly_1.0.0_create_link_global', + ); + }); + + test('handles connector with digits', () => { + expect(normalizeActionName('api2cart_1.0.0_api2cart_get_products_global')).toBe( + 'api2cart_get_products', + ); + }); +}); diff --git a/src/utils/normalize.ts b/src/utils/normalize.ts new file mode 100644 index 0000000..0eee302 --- /dev/null +++ b/src/utils/normalize.ts @@ -0,0 +1,33 @@ +/** + * Action name normalization utilities. + * + * The semantic search API returns versioned action names like: + * 'calendly_1.0.0_calendly_create_scheduling_link_global' + * + * MCP tools use simplified names: + * 'calendly_create_scheduling_link' + * + * This module bridges the two formats. + */ + +const VERSIONED_ACTION_RE = /^[a-z][a-z0-9]*_\d+(?:\.\d+)+_(.+)_global$/; + +/** + * Convert semantic search API action name to MCP tool name. + * + * @param actionName - The raw action name from the API + * @returns The normalized MCP-compatible tool name + * + * @example + * ```typescript + * normalizeActionName('calendly_1.0.0_calendly_create_scheduling_link_global'); + * // => 'calendly_create_scheduling_link' + * + * normalizeActionName('bamboohr_create_employee'); + * // => 'bamboohr_create_employee' (unchanged) + * ``` + */ +export function normalizeActionName(actionName: string): string { + const match = VERSIONED_ACTION_RE.exec(actionName); + return match ? match[1] : actionName; +}