diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index b420cad6f..d5ce0c2ab 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -18,6 +18,7 @@ import { createSessionsSendTool } from "./tools/sessions-send-tool.js"; import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js"; import { createTtsTool } from "./tools/tts-tool.js"; +import { createQverisTools } from "./tools/qveris-tools.js"; export function createClawdbotTools(options?: { browserControlUrl?: string; @@ -73,6 +74,11 @@ export function createClawdbotTools(options?: { config: options?.config, sandboxed: options?.sandboxed, }); + const qverisTools = createQverisTools({ + config: options?.config, + sandboxed: options?.sandboxed, + agentSessionKey: options?.agentSessionKey, + }); const tools: AnyAgentTool[] = [ createBrowserTool({ defaultControlUrl: options?.browserControlUrl, @@ -143,6 +149,7 @@ export function createClawdbotTools(options?: { ...(webSearchTool ? [webSearchTool] : []), ...(webFetchTool ? [webFetchTool] : []), ...(imageTool ? [imageTool] : []), + ...qverisTools, ]; const pluginTools = resolvePluginTools({ diff --git a/src/agents/tool-policy.ts b/src/agents/tool-policy.ts index 85152069e..24190cece 100644 --- a/src/agents/tool-policy.ts +++ b/src/agents/tool-policy.ts @@ -14,6 +14,8 @@ export const TOOL_GROUPS: Record = { // NOTE: Keep canonical (lowercase) tool names here. "group:memory": ["memory_search", "memory_get"], "group:web": ["web_search", "web_fetch"], + // QVeris dynamic tool discovery and execution + "group:qveris": ["qveris_search", "qveris_execute"], // Basic workspace/file tools "group:fs": ["read", "write", "edit", "apply_patch"], // Host/runtime execution tools @@ -53,6 +55,8 @@ export const TOOL_GROUPS: Record = { "web_search", "web_fetch", "image", + "qveris_search", + "qveris_execute", ], }; @@ -61,7 +65,7 @@ const TOOL_PROFILES: Record = { allow: ["session_status"], }, coding: { - allow: ["group:fs", "group:runtime", "group:sessions", "group:memory", "image"], + allow: ["group:fs", "group:runtime", "group:sessions", "group:memory", "image", "group:qveris"], }, messaging: { allow: [ diff --git a/src/agents/tools/qveris-tools.ts b/src/agents/tools/qveris-tools.ts new file mode 100644 index 000000000..d5b5ad070 --- /dev/null +++ b/src/agents/tools/qveris-tools.ts @@ -0,0 +1,516 @@ +import { Type } from "@sinclair/typebox"; + +import type { ClawdbotConfig } from "../../config/config.js"; +import type { AnyAgentTool } from "./common.js"; +import { jsonResult, readNumberParam, readStringParam } from "./common.js"; + +// ============================================================================ +// Constants +// ============================================================================ + +const DEFAULT_QVERIS_BASE_URL = "https://qveris.ai/api/v1"; +const DEFAULT_TIMEOUT_SECONDS = 60; +const DEFAULT_MAX_RESPONSE_SIZE = 20480; +const DEFAULT_SEARCH_LIMIT = 10; +const DEFAULT_PRE_SEARCH_BYTE_THRESHOLD = 100; + +// ============================================================================ +// Types +// ============================================================================ + +type QverisConfig = NonNullable["qveris"]; + +/** Search result parameter from QVeris API */ +interface QverisSearchResultParam { + name: string; + type: string; + required: boolean; + description?: { + en?: string; + [key: string]: string | undefined; + }; +} + +/** Example format from QVeris search API */ +interface QverisSearchResultExamples { + sample_parameters?: Record; +} + +/** Search result tool from QVeris API */ +interface QverisSearchResultTool { + tool_id: string; + name: string; + description: string; + params?: QverisSearchResultParam[]; + provider_description?: string; + stats?: { + avg_execution_time_ms?: number; + success_rate?: number; + }; + examples?: QverisSearchResultExamples; +} + +/** QVeris search API response */ +interface QverisSearchResponse { + query: string; + total: number; + results: QverisSearchResultTool[]; + search_id: string; + elapsed_time_ms?: number; +} + +/** QVeris tool execution response */ +interface QverisExecutionResponse { + execution_id: string; + result: { + data: unknown; + }; + success: boolean; + error_message: string | null; + elapsed_time_ms: number; + cost?: number; + credits_used?: number; +} + +/** Mapping from sanitized function name to original tool info */ +interface QverisToolMapping { + functionName: string; + toolId: string; + searchId: string; +} + +// ============================================================================ +// Config Resolution +// ============================================================================ + +function resolveQverisConfig(cfg?: ClawdbotConfig): QverisConfig { + return cfg?.tools?.qveris; +} + +function resolveQverisEnabled(params: { config?: QverisConfig; sandboxed?: boolean }): boolean { + if (typeof params.config?.enabled === "boolean") return params.config.enabled; + // Enabled by default if API key is present + return Boolean(resolveQverisApiKey(params.config)); +} + +function resolveQverisApiKey(config?: QverisConfig): string | undefined { + const fromConfig = + config && "apiKey" in config && typeof config.apiKey === "string" ? config.apiKey.trim() : ""; + const fromEnv = (process.env.QVERIS_API_KEY ?? "").trim(); + return fromConfig || fromEnv || undefined; +} + +function resolveQverisBaseUrl(config?: QverisConfig): string { + return config?.baseUrl?.trim() || DEFAULT_QVERIS_BASE_URL; +} + +function resolveTimeoutSeconds(config?: QverisConfig): number { + return config?.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS; +} + +function resolveMaxResponseSize(config?: QverisConfig): number { + return config?.maxResponseSize ?? DEFAULT_MAX_RESPONSE_SIZE; +} + +function resolveSearchLimit(config?: QverisConfig): number { + return config?.searchLimit ?? DEFAULT_SEARCH_LIMIT; +} + +function resolvePreSearchEnabled(config?: QverisConfig): boolean { + return config?.preSearchEnabled !== false; +} + +function resolvePreSearchByteThreshold(config?: QverisConfig): number { + return config?.preSearchByteThreshold ?? DEFAULT_PRE_SEARCH_BYTE_THRESHOLD; +} + +// ============================================================================ +// API Client +// ============================================================================ + +async function qverisSearch(params: { + query: string; + sessionId: string; + limit: number; + apiKey: string; + baseUrl: string; + timeoutSeconds: number; +}): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), params.timeoutSeconds * 1000); + + try { + const res = await fetch(`${params.baseUrl}/search`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.apiKey}`, + }, + body: JSON.stringify({ + query: params.query, + limit: params.limit, + session_id: params.sessionId, + }), + signal: controller.signal, + }); + + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error(`QVeris search failed (${res.status}): ${detail || res.statusText}`); + } + + return (await res.json()) as QverisSearchResponse; + } finally { + clearTimeout(timeoutId); + } +} + +async function qverisExecute(params: { + toolId: string; + searchId: string; + sessionId: string; + parameters: Record; + maxResponseSize: number; + apiKey: string; + baseUrl: string; + timeoutSeconds: number; +}): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), params.timeoutSeconds * 1000); + + try { + const res = await fetch( + `${params.baseUrl}/tools/execute?tool_id=${encodeURIComponent(params.toolId)}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.apiKey}`, + }, + body: JSON.stringify({ + parameters: params.parameters, + max_response_size: params.maxResponseSize, + search_id: params.searchId, + session_id: params.sessionId, + }), + signal: controller.signal, + }, + ); + + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error(`QVeris execute failed (${res.status}): ${detail || res.statusText}`); + } + + return (await res.json()) as QverisExecutionResponse; + } finally { + clearTimeout(timeoutId); + } +} + +// ============================================================================ +// Tool Schemas +// ============================================================================ + +const QverisSearchSchema = Type.Object({ + query: Type.String({ + description: + "Search query describing the capability of the tool you need. Describe what you want to accomplish, not specific params to pass. Example: 'weather forecast API', 'send email', 'stock prices'.", + }), + limit: Type.Optional( + Type.Number({ + description: "Maximum number of results to return (1-100). Default: 10.", + minimum: 1, + maximum: 100, + }), + ), +}); + +const QverisExecuteSchema = Type.Object({ + tool_id: Type.String({ + description: "The ID of the tool to execute. Must come from a previous qveris_search call.", + }), + search_id: Type.String({ + description: + "The search_id from the qveris_search response that returned this tool. Required for linking execution to the original search.", + }), + params_to_tool: Type.String({ + description: + 'A JSON stringified dictionary of parameters to pass to the tool. Keys are param names and values can be of any type. Example: \'{"city": "London", "units": "metric"}\'.', + }), + max_response_size: Type.Optional( + Type.Number({ + description: + "Maximum size of response data in bytes. If tool generates data longer than this, it will be truncated. Default: 20480 (20KB).", + }), + ), +}); + +// ============================================================================ +// Pre-Search Handler +// ============================================================================ + +/** + * PreSearchToolHandler manages the pre-search flow: + * 1. Determines if pre-search should be performed based on first query length + * 2. Performs pre-search and stores results + * 3. Provides tool mappings for execution + */ +class PreSearchToolHandler { + private searchId: string = ""; + private toolMappings = new Map(); + private preSearchedTools: QverisSearchResultTool[] = []; + private preSearchPerformed: boolean = false; + private preSearchEnabled: boolean = false; + private byteThreshold: number; + + constructor(byteThreshold: number = DEFAULT_PRE_SEARCH_BYTE_THRESHOLD) { + this.byteThreshold = byteThreshold; + } + + /** + * Check if pre-search should be performed + * Conditions: + * 1. First query length in bytes <= threshold + */ + shouldPreSearch(firstQuery: string): boolean { + const byteLength = new TextEncoder().encode(firstQuery).length; + return byteLength <= this.byteThreshold; + } + + /** + * Perform pre-search using the query + */ + async preSearch(params: { + query: string; + sessionId: string; + apiKey: string; + baseUrl: string; + timeoutSeconds: number; + searchLimit: number; + }): Promise { + if (this.preSearchPerformed) { + return null; + } + + try { + const result = await qverisSearch({ + query: params.query, + sessionId: params.sessionId, + limit: params.searchLimit, + apiKey: params.apiKey, + baseUrl: params.baseUrl, + timeoutSeconds: params.timeoutSeconds, + }); + + if (result.results && result.results.length > 0) { + this.searchId = result.search_id; + this.preSearchedTools = result.results; + + // Store mappings for execution + for (const tool of result.results) { + const functionName = sanitizeToolName(tool.tool_id); + this.toolMappings.set(functionName, { + functionName, + toolId: tool.tool_id, + searchId: result.search_id, + }); + } + + this.preSearchEnabled = true; + } + + this.preSearchPerformed = true; + return result; + } catch (error) { + this.preSearchPerformed = true; + return null; + } + } + + isPreSearchEnabled(): boolean { + return this.preSearchEnabled && this.preSearchedTools.length > 0; + } + + getPreSearchedTools(): QverisSearchResultTool[] { + return this.preSearchedTools; + } + + getSearchId(): string { + return this.searchId; + } + + isPreSearchedTool(toolName: string): boolean { + return this.toolMappings.has(toolName); + } + + getToolMapping(functionName: string): QverisToolMapping | undefined { + return this.toolMappings.get(functionName); + } +} + +/** + * Sanitize tool ID to create a valid function name + */ +function sanitizeToolName(toolId: string): string { + if (!toolId || typeof toolId !== "string") { + toolId = "unknown_tool"; + } + + // Replace invalid characters with underscore + let sanitized = toolId.replace(/[^a-zA-Z0-9_-]/g, "_"); + + // Remove leading hyphens and numbers + sanitized = sanitized.replace(/^[-0-9]+/, ""); + + // If empty after sanitization, use a default name + if (!sanitized || sanitized === "_") { + sanitized = "tool_" + Date.now().toString(36); + } + + // Ensure starts with letter or underscore + if (sanitized.startsWith("-")) { + sanitized = "_" + sanitized.substring(1); + } + + // Truncate to 64 characters + if (sanitized.length > 64) { + sanitized = sanitized.substring(0, 64); + } + + return sanitized; +} + +// ============================================================================ +// Tool Creation +// ============================================================================ + +export function createQverisTools(options?: { + config?: ClawdbotConfig; + sandboxed?: boolean; + agentSessionKey?: string; +}): AnyAgentTool[] { + const config = resolveQverisConfig(options?.config); + + if (!resolveQverisEnabled({ config, sandboxed: options?.sandboxed })) { + return []; + } + + const apiKey = resolveQverisApiKey(config); + if (!apiKey) { + // Return empty array if no API key - tools won't be available + return []; + } + + const baseUrl = resolveQverisBaseUrl(config); + const timeoutSeconds = resolveTimeoutSeconds(config); + const maxResponseSize = resolveMaxResponseSize(config); + const searchLimit = resolveSearchLimit(config); + + // Generate a session ID tied to clawdbot session key + const sessionId = options?.agentSessionKey ?? `clawdbot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const searchTool: AnyAgentTool = { + label: "QVeris Search", + name: "qveris_search", + description: + "Search for available third-party tools using natural language. Returns relevant tools that can help accomplish tasks. Use this to discover tools before executing them with qveris_execute.", + parameters: QverisSearchSchema, + execute: async (_toolCallId, args) => { + const params = args as Record; + const query = readStringParam(params, "query", { required: true }); + const limit = readNumberParam(params, "limit", { integer: true }) ?? searchLimit; + + const result = await qverisSearch({ + query, + sessionId, + limit: Math.min(Math.max(1, limit), 100), + apiKey, + baseUrl, + timeoutSeconds, + }); + + // Return simplified result for the model + return jsonResult({ + query: result.query, + total: result.total, + search_id: result.search_id, + elapsed_time_ms: result.elapsed_time_ms, + results: result.results.map((tool) => ({ + tool_id: tool.tool_id, + name: tool.name, + description: tool.description, + params: tool.params?.map((p) => ({ + name: p.name, + type: p.type, + required: p.required, + description: p.description?.en ?? Object.values(p.description ?? {})[0], + })), + examples: tool.examples?.sample_parameters + ? { sample_parameters: tool.examples.sample_parameters } + : undefined, + stats: tool.stats, + })), + }); + }, + }; + + const executeTool: AnyAgentTool = { + label: "QVeris Execute", + name: "qveris_execute", + description: + "Execute a specific third-party tool with provided parameters. The tool_id and search_id must come from a previous qveris_search call. Pass parameters to the tool through params_to_tool as a JSON string.", + parameters: QverisExecuteSchema, + execute: async (_toolCallId, args) => { + const params = args as Record; + const toolId = readStringParam(params, "tool_id", { required: true }); + const searchId = readStringParam(params, "search_id", { required: true }); + const paramsToToolRaw = readStringParam(params, "params_to_tool", { required: true }); + const maxSize = + readNumberParam(params, "max_response_size", { integer: true }) ?? maxResponseSize; + + // Parse params_to_tool JSON + let toolParams: Record; + try { + toolParams = JSON.parse(paramsToToolRaw) as Record; + } catch (parseError) { + throw new Error( + `Invalid JSON in params_to_tool: ${parseError instanceof Error ? parseError.message : "Unknown parse error"}`, + ); + } + + const result = await qverisExecute({ + toolId, + searchId, + sessionId, + parameters: toolParams, + maxResponseSize: maxSize, + apiKey, + baseUrl, + timeoutSeconds, + }); + + return jsonResult({ + execution_id: result.execution_id, + success: result.success, + elapsed_time_ms: result.elapsed_time_ms, + result: result.result?.data, + error_message: result.error_message, + cost: result.cost ?? result.credits_used, + }); + }, + }; + + return [searchTool, executeTool]; +} + +/** + * Check if QVeris tools are enabled/available + */ +export function isQverisEnabled(options?: { + config?: ClawdbotConfig; + sandboxed?: boolean; +}): boolean { + const config = resolveQverisConfig(options?.config); + return resolveQverisEnabled({ config, sandboxed: options?.sandboxed }) && Boolean(resolveQverisApiKey(config)); +} diff --git a/src/config/schema.ts b/src/config/schema.ts index 3261b5170..1283bedb3 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -199,6 +199,14 @@ const FIELD_LABELS: Record = { "tools.web.fetch.cacheTtlMinutes": "Web Fetch Cache TTL (min)", "tools.web.fetch.maxRedirects": "Web Fetch Max Redirects", "tools.web.fetch.userAgent": "Web Fetch User-Agent", + "tools.qveris.enabled": "Enable QVeris Tools", + "tools.qveris.apiKey": "QVeris API Key", + "tools.qveris.baseUrl": "QVeris API Base URL", + "tools.qveris.timeoutSeconds": "QVeris Timeout (sec)", + "tools.qveris.maxResponseSize": "QVeris Max Response Size", + "tools.qveris.searchLimit": "QVeris Search Limit", + "tools.qveris.preSearchEnabled": "QVeris Pre-Search", + "tools.qveris.preSearchByteThreshold": "QVeris Pre-Search Threshold", "gateway.controlUi.basePath": "Control UI Base Path", "gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth", "gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth", @@ -462,6 +470,14 @@ const FIELD_HELP: Record = { "tools.web.fetch.firecrawl.maxAgeMs": "Firecrawl maxAge (ms) for cached results when supported by the API.", "tools.web.fetch.firecrawl.timeoutSeconds": "Timeout in seconds for Firecrawl requests.", + "tools.qveris.enabled": "Enable QVeris dynamic tool discovery (requires API key).", + "tools.qveris.apiKey": "QVeris API key (fallback: QVERIS_API_KEY env var).", + "tools.qveris.baseUrl": "QVeris API base URL (default: https://qveris.ai/api/v1).", + "tools.qveris.timeoutSeconds": "Timeout in seconds for QVeris requests (default: 60).", + "tools.qveris.maxResponseSize": "Max response size in bytes for QVeris tool execution (default: 20480).", + "tools.qveris.searchLimit": "Default number of tools to return from search (default: 10).", + "tools.qveris.preSearchEnabled": "Enable pre-search optimization for short queries (default: true).", + "tools.qveris.preSearchByteThreshold": "Byte threshold for pre-search (queries under this limit trigger pre-search; default: 100).", "channels.slack.allowBots": "Allow bot-authored messages to trigger Slack replies (default: false).", "channels.slack.thread.historyScope": diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index d84dd1aa7..2b90eb94b 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -443,4 +443,23 @@ export type ToolsConfig = { deny?: string[]; }; }; + /** QVeris dynamic tool discovery and execution. */ + qveris?: { + /** Enable QVeris tools (default: true when API key is present). */ + enabled?: boolean; + /** QVeris API key (optional; defaults to QVERIS_API_KEY env var). */ + apiKey?: string; + /** QVeris API base URL (default: https://qveris.ai/api/v1). */ + baseUrl?: string; + /** Timeout in seconds for QVeris requests (default: 60). */ + timeoutSeconds?: number; + /** Max response size in bytes (default: 20480). */ + maxResponseSize?: number; + /** Default search result limit (default: 10). */ + searchLimit?: number; + /** Enable pre-search optimization for short queries (default: true). */ + preSearchEnabled?: boolean; + /** Byte threshold for pre-search (default: 100). */ + preSearchByteThreshold?: number; + }; }; diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index b5a03a3ea..ccdb5f1f1 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -536,6 +536,19 @@ export const ToolsSchema = z }) .strict() .optional(), + qveris: z + .object({ + enabled: z.boolean().optional(), + apiKey: z.string().optional(), + baseUrl: z.string().optional(), + timeoutSeconds: z.number().int().positive().optional(), + maxResponseSize: z.number().int().positive().optional(), + searchLimit: z.number().int().positive().optional(), + preSearchEnabled: z.boolean().optional(), + preSearchByteThreshold: z.number().int().positive().optional(), + }) + .strict() + .optional(), }) .strict() .superRefine((value, ctx) => {