import { buildBundledIndex, buildIndex, extractSections, getBundleSearchIndex, pageSearchKey, readPageByUrl, toCleanMarkdown, tokenize, type DocPage, } from './docs'; /** * search_docs — find the most relevant Composio docs pages for a query. * * This is a local, in-memory lexical retriever. It uses a BM25-style body score * plus field boosts for title, description, headings, and URL. The top results * include full page content (bounded per page), so the model gets rich context * in the same fast tool call instead of doing a serial search -> read round trip. */ // Collection priority: docs first, then examples, then references and toolkits. // (Curated knowledge ranks with docs.) A toolkit-name query still surfaces its // toolkit page because nothing else matches it. const PRIORITY: Record = { docs: 1.3, knowledge: 1.3, examples: 1.1, reference: 0.85, toolkits: 0.9, }; export const DEFAULT_SEARCH_LIMIT = 5; const DEFAULT_LIMIT = DEFAULT_SEARCH_LIMIT; export const DEFAULT_CONTENT_RESULT_COUNT = 4; export const DEFAULT_MAX_CONTENT_CHARS = 10_000; export const DEFAULT_MAX_SECTIONS = 16; const BM25_K1 = 1.2; const BM25_B = 0.75; const PERF_LOG_ENABLED = process.env.DOCS_AGENT_SEARCH_PERF_LOG === '1'; const PERF_LOG_QUERY = process.env.DOCS_AGENT_SEARCH_LOG_QUERY === '1'; type CorpusEntry = { page: DocPage; termCounts: Map; length: number; }; type Corpus = { entries: CorpusEntry[]; documentFrequency: Map; averageLength: number; }; type CorpusSource = 'precomputed' | 'runtime'; type PrecomputedCorpusResult = | { corpus: Corpus; fallbackReason?: never } | { corpus?: never; fallbackReason: string }; type CorpusLoad = { corpus: Corpus; source: CorpusSource; cached: boolean; loadMs: number; fallbackReason?: string; }; let corpusCache: Corpus | undefined; let corpusCacheSource: CorpusSource | undefined; let corpusCacheFallbackReason: string | undefined; function roundMs(ms: number): number { return Math.round(ms * 100) / 100; } function logPerf(payload: Record) { if (!PERF_LOG_ENABLED) return; console.info(`[docs-agent:search_docs] ${JSON.stringify(payload)}`); } function termCountsFor(page: DocPage): Map { const counts = new Map(); for (const token of tokenize(page.lowerText)) { counts.set(token, (counts.get(token) ?? 0) + 1); } return counts; } function buildRuntimeCorpus(pages: DocPage[]): Corpus { const entries = pages.map(page => { const termCounts = termCountsFor(page); return { page, termCounts, length: [...termCounts.values()].reduce((sum, count) => sum + count, 0), }; }); const documentFrequency = new Map(); for (const entry of entries) { for (const term of entry.termCounts.keys()) { documentFrequency.set(term, (documentFrequency.get(term) ?? 0) + 1); } } return { entries, documentFrequency, averageLength: entries.reduce((sum, entry) => sum + entry.length, 0) / Math.max(entries.length, 1), }; } function buildPrecomputedCorpus(pages: DocPage[]): PrecomputedCorpusResult { const search = getBundleSearchIndex(); if (!search) return { fallbackReason: 'missing-precomputed-index' }; if (search.entries.length !== pages.length) { return { fallbackReason: `entry-count-mismatch:${search.entries.length}:${pages.length}` }; } const entries: CorpusEntry[] = []; for (let index = 0; index < pages.length; index++) { const page = pages[index]; const entry = search.entries[index]; if (!entry) return { fallbackReason: `missing-entry:${index}` }; if (entry.key !== pageSearchKey(page)) { return { fallbackReason: `entry-key-mismatch:${index}:${page.url}` }; } entries.push({ page, termCounts: new Map(entry.terms), length: entry.length, }); } return { corpus: { entries, documentFrequency: new Map(search.documentFrequency), averageLength: search.averageLength, }, }; } function getCorpus(): CorpusLoad { if (corpusCache && corpusCacheSource) { return { corpus: corpusCache, source: corpusCacheSource, cached: true, loadMs: 0, fallbackReason: corpusCacheFallbackReason, }; } const started = performance.now(); const bundledPages = buildBundledIndex(); const bundledPrecomputed = buildPrecomputedCorpus(bundledPages); if (bundledPrecomputed.corpus) { corpusCache = bundledPrecomputed.corpus; corpusCacheSource = 'precomputed'; } else { const pages = buildIndex(); const livePrecomputed = buildPrecomputedCorpus(pages); if (livePrecomputed.corpus) { corpusCache = livePrecomputed.corpus; corpusCacheSource = 'precomputed'; } else { corpusCache = buildRuntimeCorpus(pages); corpusCacheSource = 'runtime'; corpusCacheFallbackReason = `bundle:${bundledPrecomputed.fallbackReason};live:${livePrecomputed.fallbackReason}`; } } return { corpus: corpusCache, source: corpusCacheSource, cached: false, loadMs: performance.now() - started, fallbackReason: corpusCacheFallbackReason, }; } function idf(term: string, corpus: Corpus): number { const n = corpus.entries.length; const df = corpus.documentFrequency.get(term) ?? 0; return Math.log(1 + (n - df + 0.5) / (df + 0.5)); } function bm25(entry: CorpusEntry, terms: string[], corpus: Corpus): number { let total = 0; for (const term of terms) { const tf = entry.termCounts.get(term) ?? 0; if (tf === 0) continue; const denominator = tf + BM25_K1 * (1 - BM25_B + BM25_B * (entry.length / corpus.averageLength)); total += idf(term, corpus) * ((tf * (BM25_K1 + 1)) / denominator); } return total; } function fieldBoost(page: DocPage, terms: string[]): number { const title = page.title.toLowerCase(); const description = page.description.toLowerCase(); const url = page.url.toLowerCase(); let total = 0; for (const term of terms) { if (title.includes(term)) total += 12; if (description.includes(term)) total += 5; for (const heading of page.headings) if (heading.includes(term)) total += 4; if (url.includes(term)) total += 6; } return total; } function score(entry: CorpusEntry, terms: string[], corpus: Corpus): number { let total = bm25(entry, terms, corpus) * 8 + fieldBoost(entry.page, terms); const isMigrationIntent = terms.some(term => ['migration', 'migrate', 'direct', 'legacy', 'v1', 'v2'].includes(term) ); // Heavily downrank legacy (direct-execution) pages so they only surface when // nothing in the session-based docs matches. if (entry.page.legacy) total *= 0.12; // Migration pages mention both old and current APIs a lot; keep them for // migration/direct-execution questions, but don't let them beat canonical // session docs for ordinary usage questions. if (!isMigrationIntent && entry.page.url.includes('/migration-guide')) total *= 0.35; return total * (PRIORITY[entry.page.collection] ?? 1); } function firstTermMatch(text: string, terms: string[]): number { const lower = text.toLowerCase(); return ( terms .map(term => lower.indexOf(term)) .filter(index => index >= 0) .sort((a, b) => a - b)[0] ?? 0 ); } function excerpt( text: string, terms: string[], maxChars: number ): { value: string; truncated: boolean } { const at = firstTermMatch(text, terms); const start = Math.max(0, at - 180); const end = Math.min(text.length, start + maxChars); const slice = text.slice(start, end).trim(); const prefix = start > 0 ? '…' : ''; const suffix = end < text.length ? '…' : ''; return { value: `${prefix}${slice}${suffix}`, truncated: start > 0 || end < text.length }; } function snippet(page: DocPage, terms: string[]): string { return excerpt(page.text, terms, 360).value; } function dedupeByUrl(ranked: { page: DocPage; s: number }[]): { page: DocPage; s: number }[] { const seen = new Set(); const deduped: { page: DocPage; s: number }[] = []; for (const item of ranked) { if (seen.has(item.page.url)) continue; seen.add(item.page.url); deduped.push(item); } return deduped; } function contentFor( page: DocPage, terms: string[], maxContentChars: number, maxSections: number ) { if (page.collection === 'knowledge') { const evidence = excerpt(page.text, terms, maxContentChars); return { content: evidence.value, contentTruncated: evidence.truncated, }; } const found = readPageByUrl(page.url); if (found) { const markdown = toCleanMarkdown(found.raw); const evidence = excerpt(markdown, terms, maxContentChars); return { sections: extractSections(markdown).slice(0, maxSections), content: evidence.value, contentTruncated: evidence.truncated, }; } const evidence = excerpt(page.text, terms, maxContentChars); return { content: evidence.value, contentTruncated: evidence.truncated, }; } export type SearchDocsInvocation = 'tool' | 'eager_context' | 'eager_preview' | (string & {}); export type SearchDocsOptions = { limit?: number; invocation?: SearchDocsInvocation; contentResultCount?: number; maxContentChars?: number; maxSections?: number; /** When false, skip page hydration and return metadata/snippets only. */ hydrateContent?: boolean; }; export const EAGER_SEARCH_ENABLED = process.env.DOCS_AGENT_EAGER_SEARCH !== '0'; export function shouldRunEagerDocsSearch(text: string): boolean { if (!EAGER_SEARCH_ENABLED) return false; if (text.trim().length < 3) return false; const normalized = text.toLowerCase(); const accountTerms = /\b(account|billing|invoice|payment|refund|subscription|ticket|dashboard|workspace|organization|org|api key)\b/; const personalTerms = /\b(my|our|me|us|latest|current|status|paid|check|look up|lookup|change|cancel|delete|update)\b/; return !(accountTerms.test(normalized) && personalTerms.test(normalized)); } export type SearchDocsResult = { retrieval: 'bm25-lexical-local'; results: Array<{ title: string; url: string; description: string; snippet: string; sections?: { title: string; anchor: string }[]; content?: string; contentTruncated?: boolean; }>; }; export function searchDocs(query: string, options: SearchDocsOptions = {}): SearchDocsResult { const limit = options.limit ?? DEFAULT_LIMIT; const contentResultCount = options.contentResultCount ?? DEFAULT_CONTENT_RESULT_COUNT; const maxContentChars = options.maxContentChars ?? DEFAULT_MAX_CONTENT_CHARS; const maxSections = options.maxSections ?? DEFAULT_MAX_SECTIONS; const hydrateContent = options.hydrateContent ?? true; const totalStarted = performance.now(); const tokenizeStarted = performance.now(); const terms = tokenize(query); // Fall back to raw terms if the query was all stopwords. const effective = terms.length > 0 ? terms : query .toLowerCase() .split(/\s+/) .filter(t => t.length > 1); const tokenizeMs = performance.now() - tokenizeStarted; const corpusLoad = getCorpus(); const corpus = corpusLoad.corpus; const rankStarted = performance.now(); const ranked = dedupeByUrl( corpus.entries .map(entry => ({ page: entry.page, s: score(entry, effective, corpus) })) .filter(({ s }) => s > 0) .sort((a, b) => b.s - a.s) ).slice(0, limit); const rankMs = performance.now() - rankStarted; const hydrateStarted = performance.now(); const results = ranked.map(({ page }, index) => ({ title: page.title, url: page.url, description: page.description, snippet: snippet(page, effective), ...(hydrateContent && index < contentResultCount ? contentFor(page, effective, maxContentChars, maxSections) : {}), })); const hydrateMs = performance.now() - hydrateStarted; const totalMs = performance.now() - totalStarted; logPerf({ event: 'search_docs', invocation: options.invocation ?? 'tool', retrieval: 'bm25-lexical-local', totalMs: roundMs(totalMs), tokenizeMs: roundMs(tokenizeMs), corpusLoadMs: roundMs(corpusLoad.loadMs), rankMs: roundMs(rankMs), hydrateMs: roundMs(hydrateMs), corpusSource: corpusLoad.source, corpusCached: corpusLoad.cached, corpusFallbackReason: corpusLoad.fallbackReason, queryChars: query.length, termCount: effective.length, limit, resultCount: results.length, contentResultCount: results.filter(result => 'content' in result).length, corpusPages: corpus.entries.length, corpusTerms: corpus.documentFrequency.size, topUrls: results.slice(0, 5).map(result => result.url), ...(PERF_LOG_QUERY ? { query, terms: effective } : {}), }); return { retrieval: 'bm25-lexical-local', results, }; }