feat: add QVeris tools for dynamic tool discovery and execution

- Introduced `createQverisTools` function to create QVeris search and execute tools.
- Updated `TOOL_GROUPS` to include QVeris tools.
- Added configuration options for QVeris in schema and types.
- Implemented pre-search optimization for short queries in QVeris tools.
- Enhanced error handling and response management for QVeris API interactions.
This commit is contained in:
chris7iu 2026-01-27 17:35:54 +08:00
parent b151b8d196
commit 69b882c6fc
6 changed files with 576 additions and 1 deletions

View File

@ -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({

View File

@ -14,6 +14,8 @@ export const TOOL_GROUPS: Record<string, string[]> = {
// 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<string, string[]> = {
"web_search",
"web_fetch",
"image",
"qveris_search",
"qveris_execute",
],
};
@ -61,7 +65,7 @@ const TOOL_PROFILES: Record<ToolProfileId, ToolProfilePolicy> = {
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: [

View File

@ -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<ClawdbotConfig["tools"]>["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<string, unknown>;
}
/** 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<QverisSearchResponse> {
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<string, unknown>;
maxResponseSize: number;
apiKey: string;
baseUrl: string;
timeoutSeconds: number;
}): Promise<QverisExecutionResponse> {
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<string, QverisToolMapping>();
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<QverisSearchResponse | null> {
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
try {
toolParams = JSON.parse(paramsToToolRaw) as Record<string, unknown>;
} 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));
}

View File

@ -199,6 +199,14 @@ const FIELD_LABELS: Record<string, string> = {
"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<string, string> = {
"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":

View File

@ -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;
};
};

View File

@ -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) => {