diff --git a/examples/search-tools.ts b/examples/search-tools.ts index 501222f..bb6846a 100644 --- a/examples/search-tools.ts +++ b/examples/search-tools.ts @@ -1,168 +1,49 @@ /** - * 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 tool patterns: callable wrapper and config overrides. * - * 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 } + * For full agent execution, see agent-tool-search.ts. * - * @example - * ```bash - * # Run with required environment variables: - * STACKONE_API_KEY=your-key OPENAI_API_KEY=your-key npx tsx examples/search-tools.ts - * ``` + * Run with: + * STACKONE_API_KEY=xxx STACKONE_ACCOUNT_ID=xxx 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; +const accountId = process.env.STACKONE_ACCOUNT_ID; + if (!apiKey) { - console.error('STACKONE_API_KEY environment variable is required'); + console.error('Set STACKONE_API_KEY to run this example.'); + process.exit(1); +} +if (!accountId) { + console.error('Set STACKONE_ACCOUNT_ID to run this example.'); 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'); - - // Enable search with default method: 'auto' - const toolset = new StackOneToolSet({ search: {} }); - - // Per-call options override constructor defaults when needed - const searchTool = toolset.getSearchTool({ search: 'auto' }); - - // 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: {} }); - - // 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.id}: score=${result.similarityScore.toFixed(2)}`); - } - - // Then fetch specific tools based on the results - if (results.length > 0) { - const topActions = results.filter((r) => r.similarityScore > 0.7).map((r) => r.id); - 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 1: getSearchTool() callable --- +console.log('=== getSearchTool() callable ===\n'); -/** - * Example 5: Constructor-level topK vs per-call override - */ -const topKConfig = async (): Promise => { - console.log('\nExample 5: topK at constructor vs per-call\n'); +const toolset = new StackOneToolSet({ apiKey, accountId, search: {} }); +const searchTool = toolset.getSearchTool(); - // Constructor-level topK — all calls default to returning 3 results - const toolset = new StackOneToolSet({ search: { topK: 3 } }); +const queries = ['cancel an event', 'list employees', 'send a message']; +for (const query of queries) { + const tools = await searchTool.search(query, { topK: 3 }); + const names = tools.toArray().map((t) => t.name); + console.log(` "${query}" -> ${names.join(', ') || '(none)'}`); +} - 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}`); - } +// --- Example 2: Constructor topK vs per-call override --- +console.log('\n=== Constructor topK vs per-call override ===\n'); - // 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}`); - } -}; +const toolset3 = new StackOneToolSet({ apiKey, accountId, search: { topK: 3 } }); -// 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'); - } +const query = 'manage employee records'; - await searchToolWithAgentLoop(); - await searchActionNames(); - await localSearchOnly(); - await topKConfig(); - } catch (error) { - console.error('Error running examples:', error); - } -}; +const tools3 = await toolset3.searchTools(query); +console.log(`Constructor topK=3: got ${tools3.length} tools`); -await main(); +const toolsOverride = await toolset3.searchTools(query, { topK: 10 }); +console.log(`Per-call topK=10 (overrides constructor 3): got ${toolsOverride.length} tools`); diff --git a/examples/workday-integration.ts b/examples/workday-integration.ts new file mode 100644 index 0000000..9648825 --- /dev/null +++ b/examples/workday-integration.ts @@ -0,0 +1,90 @@ +/** + * Workday integration: timeout and account scoping for slow providers. + * + * Workday can take 10-15s to respond. This example shows how to configure + * timeout for slow providers. + * + * Prerequisites: + * - STACKONE_API_KEY + * - STACKONE_ACCOUNT_ID (a Workday-connected account) + * - OPENAI_API_KEY + * + * Run with: + * STACKONE_API_KEY=xxx OPENAI_API_KEY=xxx STACKONE_ACCOUNT_ID=xxx npx tsx examples/workday-integration.ts + */ + +import process from 'node:process'; +import { StackOneToolSet } from '@stackone/ai'; +import OpenAI from 'openai'; + +const apiKey = process.env.STACKONE_API_KEY ?? ''; +const accountId = process.env.STACKONE_ACCOUNT_ID ?? ''; + +if (!apiKey) { + console.error('Set STACKONE_API_KEY to run this example.'); + process.exit(1); +} +if (!accountId) { + console.error('Set STACKONE_ACCOUNT_ID to run this example.'); + process.exit(1); +} +if (!process.env.OPENAI_API_KEY) { + console.error('Set OPENAI_API_KEY to run this example.'); + process.exit(1); +} + +// Timeout for slow providers (Workday can take 10-15s) +const toolset = new StackOneToolSet({ + apiKey, + accountId, + search: { method: 'auto', topK: 5 }, + timeout: 120_000, +}); + +const client = new OpenAI(); + +async function runAgent( + messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[], + tools: OpenAI.Chat.Completions.ChatCompletionTool[], + maxSteps = 10, +): Promise { + for (let i = 0; i < maxSteps; i++) { + const response = await client.chat.completions.create({ model: 'gpt-5.4', messages, tools }); + const choice = response.choices[0]; + + if (!choice.message.tool_calls?.length) { + console.log(`Answer: ${choice.message.content}`); + return; + } + + messages.push(choice.message); + for (const tc of choice.message.tool_calls) { + if (tc.type !== 'function') continue; + console.log(` -> ${tc.function.name}(${tc.function.arguments.slice(0, 80)})`); + const searchTools = toolset.getTools({ accountIds: [accountId] }); + const tool = searchTools.getTool(tc.function.name); + const result = tool ? await tool.execute(tc.function.arguments) : { error: 'Unknown tool' }; + messages.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result) }); + } + } +} + +// --- Example 1: Search and execute mode --- +console.log('=== Search and execute mode ===\n'); +const searchTools = toolset.getTools({ accountIds: [accountId] }).toOpenAI(); +await runAgent( + [ + { role: 'system', content: 'Use tool_search to find tools, then tool_execute to run them.' }, + { role: 'user', content: 'List the first 5 employees.' }, + ], + searchTools, +); + +// --- Example 2: Normal mode --- +console.log('\n=== Normal mode ===\n'); +const tools = await toolset.fetchTools({ actions: ['workday_*_employee*'] }); +if (tools.length === 0) { + console.log('No Workday tools found for this account.'); +} else { + await runAgent([{ role: 'user', content: 'List the first 5 employees.' }], tools.toOpenAI()); +} diff --git a/src/rpc-client.ts b/src/rpc-client.ts index ceb90dc..bc2f88a 100644 --- a/src/rpc-client.ts +++ b/src/rpc-client.ts @@ -23,6 +23,7 @@ export type { RpcActionResponse } from './schema'; export class RpcClient { private readonly baseUrl: string; private readonly authHeader: string; + private readonly timeout: number; constructor(config: RpcClientConfig) { const validatedConfig = rpcClientConfigSchema.parse(config); @@ -30,6 +31,7 @@ export class RpcClient { const username = validatedConfig.security.username; const password = validatedConfig.security.password || ''; this.authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; + this.timeout = validatedConfig.timeout ?? 60_000; } /** @@ -73,13 +75,32 @@ export class RpcClient { ...forwardedHeaders, } satisfies Record; - const response = await fetch(url, { - method: 'POST', - headers: httpHeaders, - body: JSON.stringify(requestBody), - }); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); - const responseBody: unknown = await response.json(); + let response: Response; + let responseBody: unknown; + try { + response = await fetch(url, { + method: 'POST', + headers: httpHeaders, + body: JSON.stringify(requestBody), + signal: controller.signal, + }); + responseBody = await response.json(); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new StackOneAPIError( + `Request timed out after ${this.timeout}ms for ${url}`, + 0, + null, + requestBody, + ); + } + throw error; + } finally { + clearTimeout(timeoutId); + } if (!response.ok) { throw new StackOneAPIError( diff --git a/src/schema.ts b/src/schema.ts index 17f7f80..4e9ccac 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -56,6 +56,7 @@ export const rpcClientConfigSchema = z.object({ username: z.string(), password: z.optional(z.string()), }), + timeout: z.optional(z.number()), }); /** diff --git a/src/toolsets.ts b/src/toolsets.ts index 5093b39..3642a5e 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -97,6 +97,8 @@ export interface BaseToolSetConfig { authentication?: AuthenticationConfig; headers?: Record; rpcClient?: RpcClient; + /** Request timeout in milliseconds. Default: 60000 (60s). */ + timeout?: number; } /** @@ -134,6 +136,8 @@ type AccountConfig = SimplifyDeep ({ }); /** @internal */ -export function createSearchTool(toolset: StackOneToolSet, accountIds?: string[]): BaseTool { +export function createSearchTool( + toolset: StackOneToolSet, + accountIds?: string[], + connectors?: string, +): BaseTool { + const connectorLine = connectors ? ` Available connectors: ${connectors}.` : ''; const tool = new BaseTool( 'tool_search', - 'Search for available tools by describing what you need. Returns matching tool names, descriptions, and parameter schemas. Use the returned parameter schemas to know exactly what to pass when calling tool_execute.', + `Search for available tools by describing what you need. Returns matching tool names, descriptions, and parameter schemas. Use the returned parameter schemas to know exactly what to pass when calling tool_execute.${connectorLine}`, searchParameters, localConfig('search'), ); @@ -380,12 +389,17 @@ export function createSearchTool(toolset: StackOneToolSet, accountIds?: string[] } /** @internal */ -export function createExecuteTool(toolset: StackOneToolSet, accountIds?: string[]): BaseTool { +export function createExecuteTool( + toolset: StackOneToolSet, + accountIds?: string[], + connectors?: string, +): BaseTool { let cachedTools: Awaited> | null = null; + const connectorLine = connectors ? ` Available connectors: ${connectors}.` : ''; const tool = new BaseTool( 'tool_execute', - 'Execute a tool by name with the given parameters. Use tool_search first to find available tools. The parameters field must match the parameter schema returned by tool_search. Pass parameters as a nested object matching the schema structure.', + `Execute a tool by name with the given parameters. Use tool_search first to find available tools. The parameters field must match the parameter schema returned by tool_search. Pass parameters as a nested object matching the schema structure.${connectorLine}`, executeParameters, localConfig('execute'), ); @@ -442,6 +456,7 @@ export class StackOneToolSet { private authentication?: AuthenticationConfig; private headers: Record; private rpcClient?: RpcClient; + private readonly timeout: number; private readonly searchConfig: SearchConfig | null; private readonly executeConfig: ExecuteToolsConfig | undefined; @@ -497,6 +512,7 @@ export class StackOneToolSet { this.authentication = authentication; this.headers = configHeaders; this.rpcClient = config?.rpcClient; + this.timeout = config?.timeout ?? config?.execute?.timeout ?? 60_000; this.accountId = accountId; this.accountIds = config?.accountIds ?? []; @@ -633,21 +649,25 @@ export class StackOneToolSet { * @returns Tools collection containing tool_search and tool_execute */ getTools(options?: { accountIds?: string[] }): Tools { - return this.buildTools(options?.accountIds); + const accountIds = + options?.accountIds ?? + this.executeConfig?.accountIds ?? + (this.accountIds.length > 0 ? this.accountIds : undefined); + return this.buildTools(accountIds); } /** * Build tool_search + tool_execute tools scoped to this toolset. */ - private buildTools(accountIds?: string[]): Tools { + private buildTools(accountIds?: string[], connectors?: string): Tools { if (this.searchConfig === null) { throw new ToolSetConfigError( 'Search is disabled. Initialize StackOneToolSet with a search config to enable.', ); } - const searchTool = createSearchTool(this, accountIds); - const executeTool = createExecuteTool(this, accountIds); + const searchTool = createSearchTool(this, accountIds, connectors); + const executeTool = createExecuteTool(this, accountIds, connectors); return new Tools([searchTool, executeTool]); } @@ -681,7 +701,18 @@ export class StackOneToolSet { const effectiveAccountIds = options?.accountIds ?? this.executeConfig?.accountIds; if (options?.mode === 'search_and_execute') { - return this.buildTools(effectiveAccountIds).toOpenAI(); + // Discover available connectors for dynamic descriptions + let connectors: string | undefined; + try { + const allTools = await this.fetchTools({ accountIds: effectiveAccountIds }); + const connectorSet = allTools.getConnectors(); + if (connectorSet.size > 0) { + connectors = Array.from(connectorSet).sort().join(', '); + } + } catch { + // Best-effort: if discovery fails, use generic descriptions + } + return this.buildTools(effectiveAccountIds, connectors).toOpenAI(); } const tools = await this.fetchTools({ accountIds: effectiveAccountIds }); @@ -1134,6 +1165,7 @@ export class StackOneToolSet { username: apiKey, password, }, + timeout: this.timeout, }); return this.rpcClient;