src / tools / searchWeb.ts
src / tools / searchWeb.ts
import { tool } from "@lmstudio/sdk";
import { z } from "zod";
import { DuckDuckGoProvider } from "../search/providers/DuckDuckGoProvider";
import { normalizeUrl } from "../utils/url";
import { isNoiseUrl, pageRelevanceScore } from "../utils/relevance";
import { MAX_RESULT_CONTENT, CONCURRENCY_LIMIT } from "../utils/constants";
import { crawlPage } from "../services/crawlPage";
// Helper for concurrency-limited execution
async function asyncPool<T>(concurrency: number, array: T[], iteratorFn: (item: T) => Promise<any>): Promise<any[]> {
const results = [];
const queue = [...array];
while (queue.length || results.length < array.length) {
if (queue.length === 0 && results.length >= array.length) break;
// Process a chunk of items up to the concurrency limit
const chunk = queue.splice(0, concurrency);
const promises = chunk.map(item => iteratorFn(item));
const batchResults = await Promise.allSettled(promises);
for (const res of batchResults) {
if (res.status === 'fulfilled') results.push(res.value);
}
}
return results;
}
function cleanContent(raw: string): string {
if (!raw) return "";
let text = raw.replace(/[\t]+/g, " ");
text = text.replace(/[\r\n]+/g, "\n");
text = text.replace(/ {2,}/g, " ").trim();
const lines = text.split("\n").filter(line => {
const trimmed = line.trim();
if (!trimmed) return false;
if (/^[-*_]{3,}$/.test(trimmed)) return false;
if (/^[-*\u2022\u203A\u2023\u2043]+$/m.test(trimmed)) return false;
if (/^\s+$/.test(trimmed)) return false;
if (trimmed.length < 3) return false;
return true;
});
text = lines.join("\n").trim();
text = text.replace(/\n{3,}/g, "\n\n").trim();
return text;
}
function buildCleanContent(structuredContent: string[], rawContent: string, query: string): string {
if (structuredContent && structuredContent.length > 0) {
let combined = structuredContent.join("\n\n");
combined = cleanContent(combined);
if (combined.length > MAX_RESULT_CONTENT) {
combined = combined.slice(0, MAX_RESULT_CONTENT);
}
if (combined.length > 200) return combined;
}
const cleaned = cleanContent(rawContent);
if (cleaned.length > MAX_RESULT_CONTENT) {
return cleaned.slice(0, MAX_RESULT_CONTENT);
}
return cleaned;
}
function deduplicateResults(results: Array<{ url: string; title: string; content: string; score?: number }>): typeof results {
const seen = new Set<string>();
const deduped: typeof results = [];
for (const r of results) {
const key = r.url.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
deduped.push(r);
}
}
return deduped;
}
export const searchWebTool = tool({
name: "searchWeb",
description: "Search the web and return the full content of the top results.\n\nUSE THIS TOOL whenever the user asks about:\n- Current events, news, recent releases, patch notes, versions, changelogs\n- Specific games, software, products (name + version/release/update)\n- Anything time-sensitive or that may have changed recently\n\nALWAYS:\n- Use a specific, targeted query\n- Set maxResults to at least 5 for research questions\n- Call this tool MULTIPLE TIMES with different queries if the first query is insufficient\n\nDO NOT answer from memory when the user asks about recent releases, versions, or updates — always search first.",
parameters: {
query: z.string().describe("Search query. Be specific — include game/product name, version keywords, dates."),
maxResults: z.number().int().min(1).max(50).default(20).describe("Number of pages to fetch and read. Default 20."),
siteFilter: z.string().optional().describe("Restrict results to a specific domain, e.g. \"store.steampowered.com\" or \"reddit.com\"."),
},
implementation: async (input: {
query: string;
maxResults: number;
siteFilter?: string;
}) => {
const query = input.query;
const maxResults = input.maxResults ?? 20;
const siteFilter = input.siteFilter;
if (!query) {
return "Error: query is required";
}
const searchProviders = [new DuckDuckGoProvider()];
let allResults: Array<{ url: string; title: string; content: string; score?: number }> = [];
let providerErrors: string[] = [];
for (const provider of searchProviders) {
try {
const results = await provider.search(query, maxResults);
allResults.push(...results);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`Search provider ${provider.name} failed: ${message}`);
providerErrors.push(`${provider.name}: ${message}`);
}
}
allResults = deduplicateResults(allResults);
if (allResults.length === 0) {
const errorMsg = providerErrors.length > 0
? `Search failed: ${providerErrors.join(" | ")}. Try a different query or check your internet connection.`
: "Search returned no results. Try a different query.";
return errorMsg;
}
allResults = allResults.filter(r => r.url && r.title);
if (siteFilter) {
allResults = allResults.filter(r => r.url.includes(siteFilter));
}
allResults = allResults.map(r => ({
...r,
url: normalizeUrl(r.url),
score: r.score ?? pageRelevanceScore(query, r.title, r.content || ""),
}));
allResults.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
allResults = allResults.slice(0, Math.min(maxResults, allResults.length));
const batch = allResults.filter(r => !isNoiseUrl(r.url, query));
if (batch.length === 0) {
return `Search returned ${allResults.length} results but all were filtered as noise or blocked domains.`;
}
// Track per-URL failure reasons for better error messages
const failures: Array<{ url: string; reason: string }> = [];
const results: Array<{ url: string; title: string; content: string }> = [];
// Use concurrency-limited pool to crawl pages
await asyncPool(CONCURRENCY_LIMIT, batch, async (r) => {
try {
const crawlResult = await crawlPage(r.url, query);
if (crawlResult.success && (crawlResult.content || crawlResult.structuredContent.length > 0)) {
const cleanText = buildCleanContent(
crawlResult.structuredContent,
crawlResult.content || "",
query
);
if (!cleanText || cleanText.trim().length === 0) return;
results.push({ url: crawlResult.url, title: crawlResult.title, content: cleanText });
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
failures.push({ url: r.url, reason: message });
}
});
if (results.length === 0) {
const failureSummary = failures.slice(0, 5).map(f => `\n - ${f.url}: ${f.reason}`).join("");
return `Search returned ${allResults.length} results but failed to fetch content from all pages.${failureSummary}\nTry a different query.`;
}
return JSON.stringify(results, null, 2);
},
});import { tool } from "@lmstudio/sdk";
import { z } from "zod";
import { DuckDuckGoProvider } from "../search/providers/DuckDuckGoProvider";
import { normalizeUrl } from "../utils/url";
import { isNoiseUrl, pageRelevanceScore } from "../utils/relevance";
import { MAX_RESULT_CONTENT, CONCURRENCY_LIMIT } from "../utils/constants";
import { crawlPage } from "../services/crawlPage";
// Helper for concurrency-limited execution
async function asyncPool<T>(concurrency: number, array: T[], iteratorFn: (item: T) => Promise<any>): Promise<any[]> {
const results = [];
const queue = [...array];
while (queue.length || results.length < array.length) {
if (queue.length === 0 && results.length >= array.length) break;
// Process a chunk of items up to the concurrency limit
const chunk = queue.splice(0, concurrency);
const promises = chunk.map(item => iteratorFn(item));
const batchResults = await Promise.allSettled(promises);
for (const res of batchResults) {
if (res.status === 'fulfilled') results.push(res.value);
}
}
return results;
}
function cleanContent(raw: string): string {
if (!raw) return "";
let text = raw.replace(/[\t]+/g, " ");
text = text.replace(/[\r\n]+/g, "\n");
text = text.replace(/ {2,}/g, " ").trim();
const lines = text.split("\n").filter(line => {
const trimmed = line.trim();
if (!trimmed) return false;
if (/^[-*_]{3,}$/.test(trimmed)) return false;
if (/^[-*\u2022\u203A\u2023\u2043]+$/m.test(trimmed)) return false;
if (/^\s+$/.test(trimmed)) return false;
if (trimmed.length < 3) return false;
return true;
});
text = lines.join("\n").trim();
text = text.replace(/\n{3,}/g, "\n\n").trim();
return text;
}
function buildCleanContent(structuredContent: string[], rawContent: string, query: string): string {
if (structuredContent && structuredContent.length > 0) {
let combined = structuredContent.join("\n\n");
combined = cleanContent(combined);
if (combined.length > MAX_RESULT_CONTENT) {
combined = combined.slice(0, MAX_RESULT_CONTENT);
}
if (combined.length > 200) return combined;
}
const cleaned = cleanContent(rawContent);
if (cleaned.length > MAX_RESULT_CONTENT) {
return cleaned.slice(0, MAX_RESULT_CONTENT);
}
return cleaned;
}
function deduplicateResults(results: Array<{ url: string; title: string; content: string; score?: number }>): typeof results {
const seen = new Set<string>();
const deduped: typeof results = [];
for (const r of results) {
const key = r.url.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
deduped.push(r);
}
}
return deduped;
}
export const searchWebTool = tool({
name: "searchWeb",
description: "Search the web and return the full content of the top results.\n\nUSE THIS TOOL whenever the user asks about:\n- Current events, news, recent releases, patch notes, versions, changelogs\n- Specific games, software, products (name + version/release/update)\n- Anything time-sensitive or that may have changed recently\n\nALWAYS:\n- Use a specific, targeted query\n- Set maxResults to at least 5 for research questions\n- Call this tool MULTIPLE TIMES with different queries if the first query is insufficient\n\nDO NOT answer from memory when the user asks about recent releases, versions, or updates — always search first.",
parameters: {
query: z.string().describe("Search query. Be specific — include game/product name, version keywords, dates."),
maxResults: z.number().int().min(1).max(50).default(20).describe("Number of pages to fetch and read. Default 20."),
siteFilter: z.string().optional().describe("Restrict results to a specific domain, e.g. \"store.steampowered.com\" or \"reddit.com\"."),
},
implementation: async (input: {
query: string;
maxResults: number;
siteFilter?: string;
}) => {
const query = input.query;
const maxResults = input.maxResults ?? 20;
const siteFilter = input.siteFilter;
if (!query) {
return "Error: query is required";
}
const searchProviders = [new DuckDuckGoProvider()];
let allResults: Array<{ url: string; title: string; content: string; score?: number }> = [];
let providerErrors: string[] = [];
for (const provider of searchProviders) {
try {
const results = await provider.search(query, maxResults);
allResults.push(...results);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`Search provider ${provider.name} failed: ${message}`);
providerErrors.push(`${provider.name}: ${message}`);
}
}
allResults = deduplicateResults(allResults);
if (allResults.length === 0) {
const errorMsg = providerErrors.length > 0
? `Search failed: ${providerErrors.join(" | ")}. Try a different query or check your internet connection.`
: "Search returned no results. Try a different query.";
return errorMsg;
}
allResults = allResults.filter(r => r.url && r.title);
if (siteFilter) {
allResults = allResults.filter(r => r.url.includes(siteFilter));
}
allResults = allResults.map(r => ({
...r,
url: normalizeUrl(r.url),
score: r.score ?? pageRelevanceScore(query, r.title, r.content || ""),
}));
allResults.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
allResults = allResults.slice(0, Math.min(maxResults, allResults.length));
const batch = allResults.filter(r => !isNoiseUrl(r.url, query));
if (batch.length === 0) {
return `Search returned ${allResults.length} results but all were filtered as noise or blocked domains.`;
}
// Track per-URL failure reasons for better error messages
const failures: Array<{ url: string; reason: string }> = [];
const results: Array<{ url: string; title: string; content: string }> = [];
// Use concurrency-limited pool to crawl pages
await asyncPool(CONCURRENCY_LIMIT, batch, async (r) => {
try {
const crawlResult = await crawlPage(r.url, query);
if (crawlResult.success && (crawlResult.content || crawlResult.structuredContent.length > 0)) {
const cleanText = buildCleanContent(
crawlResult.structuredContent,
crawlResult.content || "",
query
);
if (!cleanText || cleanText.trim().length === 0) return;
results.push({ url: crawlResult.url, title: crawlResult.title, content: cleanText });
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
failures.push({ url: r.url, reason: message });
}
});
if (results.length === 0) {
const failureSummary = failures.slice(0, 5).map(f => `\n - ${f.url}: ${f.reason}`).join("");
return `Search returned ${allResults.length} results but failed to fetch content from all pages.${failureSummary}\nTry a different query.`;
}
return JSON.stringify(results, null, 2);
},
});