diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 0246cc2f..ce34d732 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -401,6 +401,13 @@ async function buildTools(agent: z.infer): Promise { const tools: ToolSet = {}; for (const [name, tool] of Object.entries(agent.tools ?? {})) { try { + // Skip builtin tools that declare themselves unavailable + if (tool.type === 'builtin') { + const builtin = BuiltinTools[tool.name]; + if (builtin?.isAvailable && !(await builtin.isAvailable())) { + continue; + } + } tools[name] = await mapAgentTool(tool); } catch (error) { console.error(`Error mapping tool ${name}:`, error); diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 19fbc4e5..1fce5702 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -33,6 +33,7 @@ const BuiltinToolsSchema = z.record(z.string(), z.object({ input: z.any(), // (input, ctx?) => Promise output: z.promise(z.any()), }), + isAvailable: z.custom<() => Promise>().optional(), })); type SlackToolHint = { @@ -1265,4 +1266,102 @@ export const BuiltinTools: z.infer = { return executeSlackTool("listConversations", { types: "im", limit: limit ?? 50 }); }, }, + + // ============================================================================ + // Web Search (Brave Search API) + // ============================================================================ + + 'web-search': { + description: 'Search the web using Brave Search. Returns web results with titles, URLs, and descriptions.', + inputSchema: z.object({ + query: z.string().describe('The search query'), + count: z.number().optional().describe('Number of results to return (default: 5, max: 20)'), + freshness: z.string().optional().describe('Filter by freshness: pd (past day), pw (past week), pm (past month), py (past year)'), + }), + isAvailable: async () => { + try { + const homedir = process.env.HOME || process.env.USERPROFILE || ''; + const braveConfigPath = path.join(homedir, '.rowboat', 'config', 'brave-search.json'); + const raw = await fs.readFile(braveConfigPath, 'utf8'); + const config = JSON.parse(raw); + return !!config.apiKey; + } catch { + return false; + } + }, + execute: async ({ query, count, freshness }: { query: string; count?: number; freshness?: string }) => { + try { + // Read API key from config + const homedir = process.env.HOME || process.env.USERPROFILE || ''; + const braveConfigPath = path.join(homedir, '.rowboat', 'config', 'brave-search.json'); + + let apiKey: string; + try { + const raw = await fs.readFile(braveConfigPath, 'utf8'); + const config = JSON.parse(raw); + apiKey = config.apiKey; + } catch { + return { + success: false, + error: 'Brave Search API key not configured. Create ~/.rowboat/config/brave-search.json with { "apiKey": "" }', + }; + } + + if (!apiKey) { + return { + success: false, + error: 'Brave Search API key is empty. Set "apiKey" in ~/.rowboat/config/brave-search.json', + }; + } + + // Build query params + const resultCount = Math.min(Math.max(count || 5, 1), 20); + const params = new URLSearchParams({ + q: query, + count: String(resultCount), + }); + if (freshness) { + params.set('freshness', freshness); + } + + const url = `https://api.search.brave.com/res/v1/web/search?${params.toString()}`; + const response = await fetch(url, { + headers: { + 'X-Subscription-Token': apiKey, + 'Accept': 'application/json', + }, + }); + + if (!response.ok) { + const body = await response.text(); + return { + success: false, + error: `Brave Search API error (${response.status}): ${body}`, + }; + } + + const data = await response.json() as { + web?: { results?: Array<{ title?: string; url?: string; description?: string }> }; + }; + + const results = (data.web?.results || []).map((r: { title?: string; url?: string; description?: string }) => ({ + title: r.title || '', + url: r.url || '', + description: r.description || '', + })); + + return { + success: true, + query, + results, + count: results.length, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }, + }, };