@librechat/agents 3.2.66 → 3.2.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/hooks/createToolPolicyHook.cjs +5 -5
- package/dist/cjs/hooks/createToolPolicyHook.cjs.map +1 -1
- package/dist/cjs/llm/google/index.cjs +2 -0
- package/dist/cjs/llm/google/index.cjs.map +1 -1
- package/dist/cjs/llm/google/utils/common.cjs +28 -0
- package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
- package/dist/cjs/tools/ToolSearch.cjs +59 -14
- package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
- package/dist/cjs/tools/search/tool.cjs +5 -3
- package/dist/cjs/tools/search/tool.cjs.map +1 -1
- package/dist/esm/hooks/createToolPolicyHook.mjs +5 -5
- package/dist/esm/hooks/createToolPolicyHook.mjs.map +1 -1
- package/dist/esm/llm/google/index.mjs +3 -1
- package/dist/esm/llm/google/index.mjs.map +1 -1
- package/dist/esm/llm/google/utils/common.mjs +28 -1
- package/dist/esm/llm/google/utils/common.mjs.map +1 -1
- package/dist/esm/tools/ToolSearch.mjs +59 -14
- package/dist/esm/tools/ToolSearch.mjs.map +1 -1
- package/dist/esm/tools/search/tool.mjs +5 -3
- package/dist/esm/tools/search/tool.mjs.map +1 -1
- package/dist/types/hooks/createToolPolicyHook.d.ts +12 -15
- package/dist/types/llm/google/utils/common.d.ts +9 -0
- package/dist/types/tools/search/types.d.ts +2 -0
- package/package.json +1 -1
- package/src/hooks/__tests__/createToolPolicyHook.test.ts +13 -4
- package/src/hooks/createToolPolicyHook.ts +16 -19
- package/src/llm/google/index.ts +3 -0
- package/src/llm/google/utils/common.test.ts +57 -2
- package/src/llm/google/utils/common.ts +44 -0
- package/src/tools/ToolSearch.ts +81 -23
- package/src/tools/__tests__/ToolSearch.test.ts +151 -0
- package/src/tools/__tests__/hitl.test.ts +114 -1
- package/src/tools/search/source-processing.test.ts +57 -0
- package/src/tools/search/tool.ts +13 -1
- package/src/tools/search/types.ts +2 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tool.mjs","names":["params"],"sources":["../../../../src/tools/search/tool.ts"],"sourcesContent":["import { tool, DynamicStructuredTool } from '@langchain/core/tools';\nimport type { RunnableConfig } from '@langchain/core/runnables';\nimport type * as t from './types';\nimport {\n WebSearchToolDescription,\n WebSearchToolName,\n countrySchema,\n imagesSchema,\n videosSchema,\n querySchema,\n dateSchema,\n newsSchema,\n DATE_RANGE,\n} from './schema';\nimport { createSearchAPI, createSourceProcessor } from './search';\nimport { createSerperScraper } from './serper-scraper';\nimport { createTavilyScraper } from './tavily-scraper';\nimport { createFirecrawlScraper } from './firecrawl';\nimport { createCrwScraper } from './crw-scraper';\nimport { expandHighlights } from './highlights';\nimport { formatResultsForLLM } from './format';\nimport { createDefaultLogger } from './utils';\nimport { createReranker } from './rerankers';\nimport { Constants } from '@/common';\n\n/**\n * Executes parallel searches and merges the results,\n * deduplicating top stories by link\n */\nexport async function executeParallelSearches({\n searchAPI,\n query,\n date,\n country,\n safeSearch,\n images,\n videos,\n news,\n logger,\n}: {\n searchAPI: ReturnType<typeof createSearchAPI>;\n query: string;\n date?: DATE_RANGE;\n country?: string;\n safeSearch: t.SearchToolConfig['safeSearch'];\n images: boolean;\n videos: boolean;\n news: boolean;\n logger: t.Logger;\n}): Promise<t.SearchResult> {\n // Prepare all search tasks to run in parallel\n const searchTasks: Promise<t.SearchResult>[] = [\n // Main search\n searchAPI.getSources({\n query,\n date,\n country,\n safeSearch,\n }),\n ];\n\n if (images) {\n searchTasks.push(\n searchAPI\n .getSources({\n query,\n date,\n country,\n safeSearch,\n type: 'images',\n })\n .catch((error) => {\n logger.error('Error fetching images:', error);\n return {\n success: false,\n error: `Images search failed: ${error instanceof Error ? error.message : String(error)}`,\n };\n })\n );\n }\n if (videos) {\n searchTasks.push(\n searchAPI\n .getSources({\n query,\n date,\n country,\n safeSearch,\n type: 'videos',\n })\n .catch((error) => {\n logger.error('Error fetching videos:', error);\n return {\n success: false,\n error: `Videos search failed: ${error instanceof Error ? error.message : String(error)}`,\n };\n })\n );\n }\n if (news) {\n searchTasks.push(\n searchAPI\n .getSources({\n query,\n date,\n country,\n safeSearch,\n type: 'news',\n })\n .catch((error) => {\n logger.error('Error fetching news:', error);\n return {\n success: false,\n error: `News search failed: ${error instanceof Error ? error.message : String(error)}`,\n };\n })\n );\n }\n\n // Run all searches in parallel\n const results = await Promise.all(searchTasks);\n\n // Get the main search result (first result)\n const mainResult = results[0];\n if (!mainResult.success) {\n throw new Error(mainResult.error ?? 'Search failed');\n }\n\n // Merge additional results with the main results\n const mergedResults = { ...mainResult.data };\n\n // Convert existing news to topStories if present\n if (mergedResults.news !== undefined && mergedResults.news.length > 0) {\n const existingNewsAsTopStories = mergedResults.news\n .filter((newsItem) => newsItem.link !== undefined && newsItem.link !== '')\n .map((newsItem) => ({\n title: newsItem.title ?? '',\n link: newsItem.link ?? '',\n source: newsItem.source ?? '',\n date: newsItem.date ?? '',\n imageUrl: newsItem.imageUrl ?? '',\n processed: false,\n }));\n mergedResults.topStories = [\n ...(mergedResults.topStories ?? []),\n ...existingNewsAsTopStories,\n ];\n delete mergedResults.news;\n }\n\n results.slice(1).forEach((result) => {\n if (result.success && result.data !== undefined) {\n if (result.data.images !== undefined && result.data.images.length > 0) {\n mergedResults.images = [\n ...(mergedResults.images ?? []),\n ...result.data.images,\n ];\n }\n if (result.data.videos !== undefined && result.data.videos.length > 0) {\n mergedResults.videos = [\n ...(mergedResults.videos ?? []),\n ...result.data.videos,\n ];\n }\n if (result.data.news !== undefined && result.data.news.length > 0) {\n const newsAsTopStories = result.data.news.map((newsItem) => ({\n ...newsItem,\n link: newsItem.link ?? '',\n }));\n mergedResults.topStories = [\n ...(mergedResults.topStories ?? []),\n ...newsAsTopStories,\n ];\n }\n }\n });\n\n if (\n mergedResults.topStories !== undefined &&\n mergedResults.topStories.length > 1\n ) {\n /** The main search's own news results and the parallel news sub-search\n * frequently return the same stories — keep the first occurrence of each\n * link so duplicates aren't scraped, reranked, and formatted repeatedly */\n const seenLinks = new Set<string>();\n mergedResults.topStories = mergedResults.topStories.filter((story) => {\n if (!story.link || seenLinks.has(story.link)) {\n return false;\n }\n seenLinks.add(story.link);\n return true;\n });\n }\n\n return { success: true, data: mergedResults };\n}\n\nfunction createSearchProcessor({\n searchAPI,\n safeSearch,\n supportsImages,\n supportsVideos,\n supportsNews,\n sourceProcessor,\n onGetHighlights,\n logger,\n}: {\n safeSearch: t.SearchToolConfig['safeSearch'];\n supportsImages: boolean;\n supportsVideos: boolean;\n supportsNews: boolean;\n searchAPI: ReturnType<typeof createSearchAPI>;\n sourceProcessor: ReturnType<typeof createSourceProcessor>;\n onGetHighlights: t.SearchToolConfig['onGetHighlights'];\n logger: t.Logger;\n}) {\n return async function ({\n query,\n date,\n country,\n proMode = true,\n maxSources = 5,\n onSearchResults,\n images = false,\n videos = false,\n news = false,\n }: {\n query: string;\n country?: string;\n date?: DATE_RANGE;\n proMode?: boolean;\n maxSources?: number;\n onSearchResults: t.SearchToolConfig['onSearchResults'];\n images?: boolean;\n videos?: boolean;\n news?: boolean;\n }): Promise<t.SearchResultData> {\n try {\n // Execute parallel searches and merge results\n const searchResult = await executeParallelSearches({\n searchAPI,\n query,\n date,\n country,\n safeSearch,\n images: supportsImages && images,\n videos: supportsVideos && videos,\n news: supportsNews && news,\n logger,\n });\n\n onSearchResults?.(searchResult);\n\n const processedSources = await sourceProcessor.processSources({\n query,\n news,\n result: searchResult,\n proMode,\n onGetHighlights,\n numElements: maxSources,\n });\n\n return expandHighlights(processedSources);\n } catch (error) {\n logger.error('Error in search:', error);\n return {\n organic: [],\n topStories: [],\n images: [],\n videos: [],\n news: [],\n relatedSearches: [],\n error: error instanceof Error ? error.message : String(error),\n };\n }\n };\n}\n\nfunction createOnSearchResults({\n runnableConfig,\n onSearchResults,\n}: {\n runnableConfig: RunnableConfig;\n onSearchResults: t.SearchToolConfig['onSearchResults'];\n}) {\n return function (results: t.SearchResult): void {\n if (!onSearchResults) {\n return;\n }\n onSearchResults(results, runnableConfig);\n };\n}\n\nfunction createTool({\n schema,\n search,\n maxOutputChars,\n onSearchResults: _onSearchResults,\n}: {\n schema: Record<string, unknown>;\n search: ReturnType<typeof createSearchProcessor>;\n maxOutputChars?: number;\n onSearchResults: t.SearchToolConfig['onSearchResults'];\n}): DynamicStructuredTool {\n return tool(\n async (rawParams, runnableConfig) => {\n const params = rawParams as SearchToolParams;\n const { query, date, country: _c, images, videos, news } = params;\n const country = typeof _c === 'string' && _c ? _c : undefined;\n const searchResult = await search({\n query,\n date,\n country,\n images,\n videos,\n news,\n onSearchResults: createOnSearchResults({\n runnableConfig,\n onSearchResults: _onSearchResults,\n }),\n });\n const turn = runnableConfig.toolCall?.turn ?? 0;\n const { output, references } = formatResultsForLLM(\n turn,\n searchResult,\n maxOutputChars\n );\n const data: t.SearchResultData = { turn, ...searchResult, references };\n return [output, { [Constants.WEB_SEARCH]: data }];\n },\n {\n name: WebSearchToolName,\n description: WebSearchToolDescription,\n schema: schema,\n responseFormat: Constants.CONTENT_AND_ARTIFACT,\n }\n );\n}\n\n/**\n * Creates a search tool with configurable search and scraper providers.\n *\n * Search providers: Serper (Google results), SearXNG (self-hosted meta-search), Tavily (AI-optimized), fastCRW (Firecrawl-compatible, self-host or cloud).\n * Scraper providers: Firecrawl (default, full-featured), Serper (lightweight), Tavily (batch extraction), fastCRW (Firecrawl-compatible, self-host or cloud).\n *\n * The country schema field is exposed to the LLM for providers that support localized results.\n */\n/** Input params type for search tool */\ninterface SearchToolParams {\n query: string;\n date?: DATE_RANGE;\n country?: string;\n images?: boolean;\n videos?: boolean;\n news?: boolean;\n}\n\nexport const createSearchTool = (\n config: t.SearchToolConfig = {}\n): DynamicStructuredTool => {\n const {\n searchProvider = 'serper',\n serperApiKey,\n searxngInstanceUrl,\n searxngApiKey,\n tavilyApiKey,\n tavilySearchUrl,\n tavilyExtractUrl,\n tavilySearchOptions,\n keenableApiKey,\n keenableApiUrl,\n keenableSearchOptions,\n rerankerType = 'cohere',\n rerankerTimeout,\n topResults = 5,\n maxContentLength,\n chunkSize,\n chunkOverlap,\n maxOutputChars,\n strategies = ['no_extraction'],\n filterContent = true,\n safeSearch = 1,\n scraperProvider = 'firecrawl',\n firecrawlApiKey,\n firecrawlApiUrl,\n firecrawlVersion,\n firecrawlOptions,\n serperScraperOptions,\n tavilyScraperOptions,\n crwApiKey,\n crwApiUrl,\n crwSearchOptions,\n crwScraperOptions,\n scraperTimeout,\n jinaApiKey,\n jinaApiUrl,\n cohereApiKey,\n onSearchResults: _onSearchResults,\n onGetHighlights,\n } = config;\n\n const logger = config.logger || createDefaultLogger();\n const effectiveTavilySearchOptions =\n searchProvider === 'tavily' && config.safeSearch != null\n ? {\n ...tavilySearchOptions,\n safeSearch: config.safeSearch !== 0,\n }\n : tavilySearchOptions;\n\n const schemaProperties: Record<string, unknown> = {\n query: querySchema,\n date: dateSchema,\n images: imagesSchema,\n videos: videosSchema,\n news: newsSchema,\n };\n\n if (searchProvider === 'serper' || searchProvider === 'tavily') {\n schemaProperties.country = countrySchema;\n }\n\n const toolSchema = {\n type: 'object',\n properties: schemaProperties,\n required: ['query'],\n };\n\n const searchAPI = createSearchAPI({\n searchProvider,\n serperApiKey,\n searxngInstanceUrl,\n searxngApiKey,\n tavilyApiKey,\n tavilySearchUrl,\n tavilySearchOptions: effectiveTavilySearchOptions,\n keenableApiKey,\n keenableApiUrl,\n keenableSearchOptions,\n crwApiKey,\n crwApiUrl,\n crwSearchOptions,\n });\n\n /** Create scraper based on scraperProvider */\n let scraperInstance: t.BaseScraper;\n\n if (scraperProvider === 'serper') {\n scraperInstance = createSerperScraper({\n ...serperScraperOptions,\n apiKey: serperApiKey,\n timeout: scraperTimeout ?? serperScraperOptions?.timeout,\n logger,\n });\n } else if (scraperProvider === 'tavily') {\n scraperInstance = createTavilyScraper({\n ...tavilyScraperOptions,\n apiKey:\n tavilyScraperOptions?.apiKey ??\n tavilyApiKey ??\n process.env.TAVILY_API_KEY,\n apiUrl: tavilyScraperOptions?.apiUrl ?? tavilyExtractUrl,\n timeout: scraperTimeout ?? tavilyScraperOptions?.timeout,\n logger,\n });\n } else if (scraperProvider === 'crw') {\n scraperInstance = createCrwScraper({\n ...crwScraperOptions,\n apiKey:\n crwScraperOptions?.apiKey ?? crwApiKey ?? process.env.CRW_API_KEY,\n apiUrl: crwScraperOptions?.apiUrl ?? crwApiUrl,\n timeout: scraperTimeout ?? crwScraperOptions?.timeout,\n formats: crwScraperOptions?.formats ?? ['markdown', 'rawHtml'],\n logger,\n });\n } else {\n scraperInstance = createFirecrawlScraper({\n ...firecrawlOptions,\n apiKey: firecrawlApiKey ?? process.env.FIRECRAWL_API_KEY,\n apiUrl: firecrawlApiUrl,\n version: firecrawlVersion,\n timeout: scraperTimeout ?? firecrawlOptions?.timeout,\n formats: firecrawlOptions?.formats ?? ['markdown', 'rawHtml'],\n logger,\n });\n }\n\n const selectedReranker = createReranker({\n rerankerType,\n jinaApiKey,\n jinaApiUrl,\n cohereApiKey,\n rerankerTimeout,\n logger,\n });\n\n if (!selectedReranker) {\n logger.warn('No reranker selected. Using default ranking.');\n }\n\n const sourceProcessor = createSourceProcessor(\n {\n reranker: selectedReranker,\n topResults,\n maxContentLength,\n chunkSize,\n chunkOverlap,\n strategies,\n filterContent,\n logger,\n },\n scraperInstance\n );\n\n const search = createSearchProcessor({\n searchAPI,\n safeSearch,\n // Keenable is organic-only: its API ignores `type`, so image/news\n // sub-searches would spend rate limit and merge nothing.\n supportsImages: searchProvider !== 'keenable',\n supportsVideos:\n searchProvider !== 'tavily' &&\n searchProvider !== 'keenable' &&\n searchProvider !== 'crw',\n supportsNews: searchProvider !== 'keenable',\n sourceProcessor,\n onGetHighlights,\n logger,\n });\n\n return createTool({\n search,\n schema: toolSchema,\n maxOutputChars,\n onSearchResults: _onSearchResults,\n });\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;AA6BA,eAAsB,wBAAwB,EAC5C,WACA,OACA,MACA,SACA,YACA,QACA,QACA,MACA,UAW0B;CAE1B,MAAM,cAAyC,CAE7C,UAAU,WAAW;EACnB;EACA;EACA;EACA;CACF,CAAC,CACH;CAEA,IAAI,QACF,YAAY,KACV,UACG,WAAW;EACV;EACA;EACA;EACA;EACA,MAAM;CACR,CAAC,CAAC,CACD,OAAO,UAAU;EAChB,OAAO,MAAM,0BAA0B,KAAK;EAC5C,OAAO;GACL,SAAS;GACT,OAAO,yBAAyB,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;EACvF;CACF,CAAC,CACL;CAEF,IAAI,QACF,YAAY,KACV,UACG,WAAW;EACV;EACA;EACA;EACA;EACA,MAAM;CACR,CAAC,CAAC,CACD,OAAO,UAAU;EAChB,OAAO,MAAM,0BAA0B,KAAK;EAC5C,OAAO;GACL,SAAS;GACT,OAAO,yBAAyB,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;EACvF;CACF,CAAC,CACL;CAEF,IAAI,MACF,YAAY,KACV,UACG,WAAW;EACV;EACA;EACA;EACA;EACA,MAAM;CACR,CAAC,CAAC,CACD,OAAO,UAAU;EAChB,OAAO,MAAM,wBAAwB,KAAK;EAC1C,OAAO;GACL,SAAS;GACT,OAAO,uBAAuB,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;EACrF;CACF,CAAC,CACL;CAIF,MAAM,UAAU,MAAM,QAAQ,IAAI,WAAW;CAG7C,MAAM,aAAa,QAAQ;CAC3B,IAAI,CAAC,WAAW,SACd,MAAM,IAAI,MAAM,WAAW,SAAS,eAAe;CAIrD,MAAM,gBAAgB,EAAE,GAAG,WAAW,KAAK;CAG3C,IAAI,cAAc,SAAS,KAAA,KAAa,cAAc,KAAK,SAAS,GAAG;EACrE,MAAM,2BAA2B,cAAc,KAC5C,QAAQ,aAAa,SAAS,SAAS,KAAA,KAAa,SAAS,SAAS,EAAE,CAAC,CACzE,KAAK,cAAc;GAClB,OAAO,SAAS,SAAS;GACzB,MAAM,SAAS,QAAQ;GACvB,QAAQ,SAAS,UAAU;GAC3B,MAAM,SAAS,QAAQ;GACvB,UAAU,SAAS,YAAY;GAC/B,WAAW;EACb,EAAE;EACJ,cAAc,aAAa,CACzB,GAAI,cAAc,cAAc,CAAC,GACjC,GAAG,wBACL;EACA,OAAO,cAAc;CACvB;CAEA,QAAQ,MAAM,CAAC,CAAC,CAAC,SAAS,WAAW;EACnC,IAAI,OAAO,WAAW,OAAO,SAAS,KAAA,GAAW;GAC/C,IAAI,OAAO,KAAK,WAAW,KAAA,KAAa,OAAO,KAAK,OAAO,SAAS,GAClE,cAAc,SAAS,CACrB,GAAI,cAAc,UAAU,CAAC,GAC7B,GAAG,OAAO,KAAK,MACjB;GAEF,IAAI,OAAO,KAAK,WAAW,KAAA,KAAa,OAAO,KAAK,OAAO,SAAS,GAClE,cAAc,SAAS,CACrB,GAAI,cAAc,UAAU,CAAC,GAC7B,GAAG,OAAO,KAAK,MACjB;GAEF,IAAI,OAAO,KAAK,SAAS,KAAA,KAAa,OAAO,KAAK,KAAK,SAAS,GAAG;IACjE,MAAM,mBAAmB,OAAO,KAAK,KAAK,KAAK,cAAc;KAC3D,GAAG;KACH,MAAM,SAAS,QAAQ;IACzB,EAAE;IACF,cAAc,aAAa,CACzB,GAAI,cAAc,cAAc,CAAC,GACjC,GAAG,gBACL;GACF;EACF;CACF,CAAC;CAED,IACE,cAAc,eAAe,KAAA,KAC7B,cAAc,WAAW,SAAS,GAClC;;;;EAIA,MAAM,4BAAY,IAAI,IAAY;EAClC,cAAc,aAAa,cAAc,WAAW,QAAQ,UAAU;GACpE,IAAI,CAAC,MAAM,QAAQ,UAAU,IAAI,MAAM,IAAI,GACzC,OAAO;GAET,UAAU,IAAI,MAAM,IAAI;GACxB,OAAO;EACT,CAAC;CACH;CAEA,OAAO;EAAE,SAAS;EAAM,MAAM;CAAc;AAC9C;AAEA,SAAS,sBAAsB,EAC7B,WACA,YACA,gBACA,gBACA,cACA,iBACA,iBACA,UAUC;CACD,OAAO,eAAgB,EACrB,OACA,MACA,SACA,UAAU,MACV,aAAa,GACb,iBACA,SAAS,OACT,SAAS,OACT,OAAO,SAWuB;EAC9B,IAAI;GAEF,MAAM,eAAe,MAAM,wBAAwB;IACjD;IACA;IACA;IACA;IACA;IACA,QAAQ,kBAAkB;IAC1B,QAAQ,kBAAkB;IAC1B,MAAM,gBAAgB;IACtB;GACF,CAAC;GAED,kBAAkB,YAAY;GAW9B,OAAO,iBAAiB,MATO,gBAAgB,eAAe;IAC5D;IACA;IACA,QAAQ;IACR;IACA;IACA,aAAa;GACf,CAAC,CAEuC;EAC1C,SAAS,OAAO;GACd,OAAO,MAAM,oBAAoB,KAAK;GACtC,OAAO;IACL,SAAS,CAAC;IACV,YAAY,CAAC;IACb,QAAQ,CAAC;IACT,QAAQ,CAAC;IACT,MAAM,CAAC;IACP,iBAAiB,CAAC;IAClB,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;GAC9D;EACF;CACF;AACF;AAEA,SAAS,sBAAsB,EAC7B,gBACA,mBAIC;CACD,OAAO,SAAU,SAA+B;EAC9C,IAAI,CAAC,iBACH;EAEF,gBAAgB,SAAS,cAAc;CACzC;AACF;AAEA,SAAS,WAAW,EAClB,QACA,QACA,gBACA,iBAAiB,oBAMO;CACxB,OAAO,KACL,OAAO,WAAW,mBAAmB;EAEnC,MAAM,EAAE,OAAO,MAAM,SAAS,IAAI,QAAQ,QAAQ,SAASA;EAE3D,MAAM,eAAe,MAAM,OAAO;GAChC;GACA;GACA,SAJc,OAAO,OAAO,YAAY,KAAK,KAAK,KAAA;GAKlD;GACA;GACA;GACA,iBAAiB,sBAAsB;IACrC;IACA,iBAAiB;GACnB,CAAC;EACH,CAAC;EACD,MAAM,OAAO,eAAe,UAAU,QAAQ;EAC9C,MAAM,EAAE,QAAQ,eAAe,oBAC7B,MACA,cACA,cACF;EACA,MAAM,OAA2B;GAAE;GAAM,GAAG;GAAc;EAAW;EACrE,OAAO,CAAC,QAAQ,GAAA,eAA0B,KAAK,CAAC;CAClD,GACA;EACE,MAAM;EACN,aAAa;EACL;EACR,gBAAA;CACF,CACF;AACF;AAoBA,MAAa,oBACX,SAA6B,CAAC,MACJ;CAC1B,MAAM,EACJ,iBAAiB,UACjB,cACA,oBACA,eACA,cACA,iBACA,kBACA,qBACA,gBACA,gBACA,uBACA,eAAe,UACf,iBACA,aAAa,GACb,kBACA,WACA,cACA,gBACA,aAAa,CAAC,eAAe,GAC7B,gBAAgB,MAChB,aAAa,GACb,kBAAkB,aAClB,iBACA,iBACA,kBACA,kBACA,sBACA,sBACA,WACA,WACA,kBACA,mBACA,gBACA,YACA,YACA,cACA,iBAAiB,kBACjB,oBACE;CAEJ,MAAM,SAAS,OAAO,UAAU,oBAAoB;CACpD,MAAM,+BACJ,mBAAmB,YAAY,OAAO,cAAc,OAChD;EACA,GAAG;EACH,YAAY,OAAO,eAAe;CACpC,IACE;CAEN,MAAM,mBAA4C;EAChD,OAAO;EACP,MAAM;EACN,QAAQ;EACR,QAAQ;EACR,MAAM;CACR;CAEA,IAAI,mBAAmB,YAAY,mBAAmB,UACpD,iBAAiB,UAAU;CAG7B,MAAM,aAAa;EACjB,MAAM;EACN,YAAY;EACZ,UAAU,CAAC,OAAO;CACpB;CAEA,MAAM,YAAY,gBAAgB;EAChC;EACA;EACA;EACA;EACA;EACA;EACA,qBAAqB;EACrB;EACA;EACA;EACA;EACA;EACA;CACF,CAAC;;CAGD,IAAI;CAEJ,IAAI,oBAAoB,UACtB,kBAAkB,oBAAoB;EACpC,GAAG;EACH,QAAQ;EACR,SAAS,kBAAkB,sBAAsB;EACjD;CACF,CAAC;MACI,IAAI,oBAAoB,UAC7B,kBAAkB,oBAAoB;EACpC,GAAG;EACH,QACE,sBAAsB,UACtB,gBACA,QAAQ,IAAI;EACd,QAAQ,sBAAsB,UAAU;EACxC,SAAS,kBAAkB,sBAAsB;EACjD;CACF,CAAC;MACI,IAAI,oBAAoB,OAC7B,kBAAkB,iBAAiB;EACjC,GAAG;EACH,QACE,mBAAmB,UAAU,aAAa,QAAQ,IAAI;EACxD,QAAQ,mBAAmB,UAAU;EACrC,SAAS,kBAAkB,mBAAmB;EAC9C,SAAS,mBAAmB,WAAW,CAAC,YAAY,SAAS;EAC7D;CACF,CAAC;MAED,kBAAkB,uBAAuB;EACvC,GAAG;EACH,QAAQ,mBAAmB,QAAQ,IAAI;EACvC,QAAQ;EACR,SAAS;EACT,SAAS,kBAAkB,kBAAkB;EAC7C,SAAS,kBAAkB,WAAW,CAAC,YAAY,SAAS;EAC5D;CACF,CAAC;CAGH,MAAM,mBAAmB,eAAe;EACtC;EACA;EACA;EACA;EACA;EACA;CACF,CAAC;CAED,IAAI,CAAC,kBACH,OAAO,KAAK,8CAA8C;CAG5D,MAAM,kBAAkB,sBACtB;EACE,UAAU;EACV;EACA;EACA;EACA;EACA;EACA;EACA;CACF,GACA,eACF;CAkBA,OAAO,WAAW;EAChB,QAjBa,sBAAsB;GACnC;GACA;GAGA,gBAAgB,mBAAmB;GACnC,gBACE,mBAAmB,YACnB,mBAAmB,cACnB,mBAAmB;GACrB,cAAc,mBAAmB;GACjC;GACA;GACA;EACF,CAGO;EACL,QAAQ;EACR;EACA,iBAAiB;CACnB,CAAC;AACH"}
|
|
1
|
+
{"version":3,"file":"tool.mjs","names":["params"],"sources":["../../../../src/tools/search/tool.ts"],"sourcesContent":["import { tool, DynamicStructuredTool } from '@langchain/core/tools';\nimport type { RunnableConfig } from '@langchain/core/runnables';\nimport type * as t from './types';\nimport {\n WebSearchToolDescription,\n WebSearchToolName,\n countrySchema,\n imagesSchema,\n videosSchema,\n querySchema,\n dateSchema,\n newsSchema,\n DATE_RANGE,\n} from './schema';\nimport { createSearchAPI, createSourceProcessor } from './search';\nimport { createSerperScraper } from './serper-scraper';\nimport { createTavilyScraper } from './tavily-scraper';\nimport { createFirecrawlScraper } from './firecrawl';\nimport { createCrwScraper } from './crw-scraper';\nimport { expandHighlights } from './highlights';\nimport { formatResultsForLLM } from './format';\nimport { createDefaultLogger } from './utils';\nimport { createReranker } from './rerankers';\nimport { Constants } from '@/common';\n\n/**\n * Executes parallel searches and merges the results,\n * deduplicating top stories by link\n */\nexport async function executeParallelSearches({\n searchAPI,\n query,\n date,\n country,\n safeSearch,\n images,\n videos,\n news,\n logger,\n}: {\n searchAPI: ReturnType<typeof createSearchAPI>;\n query: string;\n date?: DATE_RANGE;\n country?: string;\n safeSearch: t.SearchToolConfig['safeSearch'];\n images: boolean;\n videos: boolean;\n news: boolean;\n logger: t.Logger;\n}): Promise<t.SearchResult> {\n // Prepare all search tasks to run in parallel\n const searchTasks: Promise<t.SearchResult>[] = [\n // Main search\n searchAPI.getSources({\n query,\n date,\n country,\n safeSearch,\n }),\n ];\n\n if (images) {\n searchTasks.push(\n searchAPI\n .getSources({\n query,\n date,\n country,\n safeSearch,\n type: 'images',\n })\n .catch((error) => {\n logger.error('Error fetching images:', error);\n return {\n success: false,\n error: `Images search failed: ${error instanceof Error ? error.message : String(error)}`,\n };\n })\n );\n }\n if (videos) {\n searchTasks.push(\n searchAPI\n .getSources({\n query,\n date,\n country,\n safeSearch,\n type: 'videos',\n })\n .catch((error) => {\n logger.error('Error fetching videos:', error);\n return {\n success: false,\n error: `Videos search failed: ${error instanceof Error ? error.message : String(error)}`,\n };\n })\n );\n }\n if (news) {\n searchTasks.push(\n searchAPI\n .getSources({\n query,\n date,\n country,\n safeSearch,\n type: 'news',\n })\n .catch((error) => {\n logger.error('Error fetching news:', error);\n return {\n success: false,\n error: `News search failed: ${error instanceof Error ? error.message : String(error)}`,\n };\n })\n );\n }\n\n // Run all searches in parallel\n const results = await Promise.all(searchTasks);\n\n // Get the main search result (first result)\n const mainResult = results[0];\n if (!mainResult.success) {\n throw new Error(mainResult.error ?? 'Search failed');\n }\n\n // Merge additional results with the main results\n const mergedResults = { ...mainResult.data };\n\n // Convert existing news to topStories if present\n if (mergedResults.news !== undefined && mergedResults.news.length > 0) {\n const existingNewsAsTopStories = mergedResults.news\n .filter((newsItem) => newsItem.link !== undefined && newsItem.link !== '')\n .map((newsItem) => ({\n title: newsItem.title ?? '',\n link: newsItem.link ?? '',\n source: newsItem.source ?? '',\n date: newsItem.date ?? '',\n imageUrl: newsItem.imageUrl ?? '',\n processed: false,\n }));\n mergedResults.topStories = [\n ...(mergedResults.topStories ?? []),\n ...existingNewsAsTopStories,\n ];\n delete mergedResults.news;\n }\n\n results.slice(1).forEach((result) => {\n if (result.success && result.data !== undefined) {\n if (result.data.images !== undefined && result.data.images.length > 0) {\n mergedResults.images = [\n ...(mergedResults.images ?? []),\n ...result.data.images,\n ];\n }\n if (result.data.videos !== undefined && result.data.videos.length > 0) {\n mergedResults.videos = [\n ...(mergedResults.videos ?? []),\n ...result.data.videos,\n ];\n }\n if (result.data.news !== undefined && result.data.news.length > 0) {\n const newsAsTopStories = result.data.news.map((newsItem) => ({\n ...newsItem,\n link: newsItem.link ?? '',\n }));\n mergedResults.topStories = [\n ...(mergedResults.topStories ?? []),\n ...newsAsTopStories,\n ];\n }\n }\n });\n\n if (\n mergedResults.topStories !== undefined &&\n mergedResults.topStories.length > 1\n ) {\n /** The main search's own news results and the parallel news sub-search\n * frequently return the same stories — keep the first occurrence of each\n * link so duplicates aren't scraped, reranked, and formatted repeatedly */\n const seenLinks = new Set<string>();\n mergedResults.topStories = mergedResults.topStories.filter((story) => {\n if (!story.link || seenLinks.has(story.link)) {\n return false;\n }\n seenLinks.add(story.link);\n return true;\n });\n }\n\n return { success: true, data: mergedResults };\n}\n\nfunction createSearchProcessor({\n searchAPI,\n safeSearch,\n supportsImages,\n supportsVideos,\n supportsNews,\n sourceProcessor,\n onGetHighlights,\n mainExpandBy,\n separatorExpandBy,\n logger,\n}: {\n safeSearch: t.SearchToolConfig['safeSearch'];\n supportsImages: boolean;\n supportsVideos: boolean;\n supportsNews: boolean;\n searchAPI: ReturnType<typeof createSearchAPI>;\n sourceProcessor: ReturnType<typeof createSourceProcessor>;\n onGetHighlights: t.SearchToolConfig['onGetHighlights'];\n mainExpandBy: t.SearchToolConfig['mainExpandBy'];\n separatorExpandBy: t.SearchToolConfig['separatorExpandBy'];\n logger: t.Logger;\n}) {\n return async function ({\n query,\n date,\n country,\n proMode = true,\n maxSources = 5,\n onSearchResults,\n images = false,\n videos = false,\n news = false,\n }: {\n query: string;\n country?: string;\n date?: DATE_RANGE;\n proMode?: boolean;\n maxSources?: number;\n onSearchResults: t.SearchToolConfig['onSearchResults'];\n images?: boolean;\n videos?: boolean;\n news?: boolean;\n }): Promise<t.SearchResultData> {\n try {\n // Execute parallel searches and merge results\n const searchResult = await executeParallelSearches({\n searchAPI,\n query,\n date,\n country,\n safeSearch,\n images: supportsImages && images,\n videos: supportsVideos && videos,\n news: supportsNews && news,\n logger,\n });\n\n onSearchResults?.(searchResult);\n\n const processedSources = await sourceProcessor.processSources({\n query,\n news,\n result: searchResult,\n proMode,\n onGetHighlights,\n numElements: maxSources,\n });\n\n return expandHighlights(\n processedSources,\n mainExpandBy,\n separatorExpandBy\n );\n } catch (error) {\n logger.error('Error in search:', error);\n return {\n organic: [],\n topStories: [],\n images: [],\n videos: [],\n news: [],\n relatedSearches: [],\n error: error instanceof Error ? error.message : String(error),\n };\n }\n };\n}\n\nfunction createOnSearchResults({\n runnableConfig,\n onSearchResults,\n}: {\n runnableConfig: RunnableConfig;\n onSearchResults: t.SearchToolConfig['onSearchResults'];\n}) {\n return function (results: t.SearchResult): void {\n if (!onSearchResults) {\n return;\n }\n onSearchResults(results, runnableConfig);\n };\n}\n\nfunction createTool({\n schema,\n search,\n maxOutputChars,\n onSearchResults: _onSearchResults,\n}: {\n schema: Record<string, unknown>;\n search: ReturnType<typeof createSearchProcessor>;\n maxOutputChars?: number;\n onSearchResults: t.SearchToolConfig['onSearchResults'];\n}): DynamicStructuredTool {\n return tool(\n async (rawParams, runnableConfig) => {\n const params = rawParams as SearchToolParams;\n const { query, date, country: _c, images, videos, news } = params;\n const country = typeof _c === 'string' && _c ? _c : undefined;\n const searchResult = await search({\n query,\n date,\n country,\n images,\n videos,\n news,\n onSearchResults: createOnSearchResults({\n runnableConfig,\n onSearchResults: _onSearchResults,\n }),\n });\n const turn = runnableConfig.toolCall?.turn ?? 0;\n const { output, references } = formatResultsForLLM(\n turn,\n searchResult,\n maxOutputChars\n );\n const data: t.SearchResultData = { turn, ...searchResult, references };\n return [output, { [Constants.WEB_SEARCH]: data }];\n },\n {\n name: WebSearchToolName,\n description: WebSearchToolDescription,\n schema: schema,\n responseFormat: Constants.CONTENT_AND_ARTIFACT,\n }\n );\n}\n\n/**\n * Creates a search tool with configurable search and scraper providers.\n *\n * Search providers: Serper (Google results), SearXNG (self-hosted meta-search), Tavily (AI-optimized), fastCRW (Firecrawl-compatible, self-host or cloud).\n * Scraper providers: Firecrawl (default, full-featured), Serper (lightweight), Tavily (batch extraction), fastCRW (Firecrawl-compatible, self-host or cloud).\n *\n * The country schema field is exposed to the LLM for providers that support localized results.\n */\n/** Input params type for search tool */\ninterface SearchToolParams {\n query: string;\n date?: DATE_RANGE;\n country?: string;\n images?: boolean;\n videos?: boolean;\n news?: boolean;\n}\n\nexport const createSearchTool = (\n config: t.SearchToolConfig = {}\n): DynamicStructuredTool => {\n const {\n searchProvider = 'serper',\n serperApiKey,\n searxngInstanceUrl,\n searxngApiKey,\n tavilyApiKey,\n tavilySearchUrl,\n tavilyExtractUrl,\n tavilySearchOptions,\n keenableApiKey,\n keenableApiUrl,\n keenableSearchOptions,\n rerankerType = 'cohere',\n rerankerTimeout,\n topResults = 5,\n maxContentLength,\n chunkSize,\n chunkOverlap,\n mainExpandBy,\n separatorExpandBy,\n maxOutputChars,\n strategies = ['no_extraction'],\n filterContent = true,\n safeSearch = 1,\n scraperProvider = 'firecrawl',\n firecrawlApiKey,\n firecrawlApiUrl,\n firecrawlVersion,\n firecrawlOptions,\n serperScraperOptions,\n tavilyScraperOptions,\n crwApiKey,\n crwApiUrl,\n crwSearchOptions,\n crwScraperOptions,\n scraperTimeout,\n jinaApiKey,\n jinaApiUrl,\n cohereApiKey,\n onSearchResults: _onSearchResults,\n onGetHighlights,\n } = config;\n\n const logger = config.logger || createDefaultLogger();\n const effectiveTavilySearchOptions =\n searchProvider === 'tavily' && config.safeSearch != null\n ? {\n ...tavilySearchOptions,\n safeSearch: config.safeSearch !== 0,\n }\n : tavilySearchOptions;\n\n const schemaProperties: Record<string, unknown> = {\n query: querySchema,\n date: dateSchema,\n images: imagesSchema,\n videos: videosSchema,\n news: newsSchema,\n };\n\n if (searchProvider === 'serper' || searchProvider === 'tavily') {\n schemaProperties.country = countrySchema;\n }\n\n const toolSchema = {\n type: 'object',\n properties: schemaProperties,\n required: ['query'],\n };\n\n const searchAPI = createSearchAPI({\n searchProvider,\n serperApiKey,\n searxngInstanceUrl,\n searxngApiKey,\n tavilyApiKey,\n tavilySearchUrl,\n tavilySearchOptions: effectiveTavilySearchOptions,\n keenableApiKey,\n keenableApiUrl,\n keenableSearchOptions,\n crwApiKey,\n crwApiUrl,\n crwSearchOptions,\n });\n\n /** Create scraper based on scraperProvider */\n let scraperInstance: t.BaseScraper;\n\n if (scraperProvider === 'serper') {\n scraperInstance = createSerperScraper({\n ...serperScraperOptions,\n apiKey: serperApiKey,\n timeout: scraperTimeout ?? serperScraperOptions?.timeout,\n logger,\n });\n } else if (scraperProvider === 'tavily') {\n scraperInstance = createTavilyScraper({\n ...tavilyScraperOptions,\n apiKey:\n tavilyScraperOptions?.apiKey ??\n tavilyApiKey ??\n process.env.TAVILY_API_KEY,\n apiUrl: tavilyScraperOptions?.apiUrl ?? tavilyExtractUrl,\n timeout: scraperTimeout ?? tavilyScraperOptions?.timeout,\n logger,\n });\n } else if (scraperProvider === 'crw') {\n scraperInstance = createCrwScraper({\n ...crwScraperOptions,\n apiKey:\n crwScraperOptions?.apiKey ?? crwApiKey ?? process.env.CRW_API_KEY,\n apiUrl: crwScraperOptions?.apiUrl ?? crwApiUrl,\n timeout: scraperTimeout ?? crwScraperOptions?.timeout,\n formats: crwScraperOptions?.formats ?? ['markdown', 'rawHtml'],\n logger,\n });\n } else {\n scraperInstance = createFirecrawlScraper({\n ...firecrawlOptions,\n apiKey: firecrawlApiKey ?? process.env.FIRECRAWL_API_KEY,\n apiUrl: firecrawlApiUrl,\n version: firecrawlVersion,\n timeout: scraperTimeout ?? firecrawlOptions?.timeout,\n formats: firecrawlOptions?.formats ?? ['markdown', 'rawHtml'],\n logger,\n });\n }\n\n const selectedReranker = createReranker({\n rerankerType,\n jinaApiKey,\n jinaApiUrl,\n cohereApiKey,\n rerankerTimeout,\n logger,\n });\n\n if (!selectedReranker) {\n logger.warn('No reranker selected. Using default ranking.');\n }\n\n const sourceProcessor = createSourceProcessor(\n {\n reranker: selectedReranker,\n topResults,\n maxContentLength,\n chunkSize,\n chunkOverlap,\n strategies,\n filterContent,\n logger,\n },\n scraperInstance\n );\n\n const search = createSearchProcessor({\n searchAPI,\n safeSearch,\n // Keenable is organic-only: its API ignores `type`, so image/news\n // sub-searches would spend rate limit and merge nothing.\n supportsImages: searchProvider !== 'keenable',\n supportsVideos:\n searchProvider !== 'tavily' &&\n searchProvider !== 'keenable' &&\n searchProvider !== 'crw',\n supportsNews: searchProvider !== 'keenable',\n sourceProcessor,\n onGetHighlights,\n mainExpandBy,\n separatorExpandBy,\n logger,\n });\n\n return createTool({\n search,\n schema: toolSchema,\n maxOutputChars,\n onSearchResults: _onSearchResults,\n });\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;AA6BA,eAAsB,wBAAwB,EAC5C,WACA,OACA,MACA,SACA,YACA,QACA,QACA,MACA,UAW0B;CAE1B,MAAM,cAAyC,CAE7C,UAAU,WAAW;EACnB;EACA;EACA;EACA;CACF,CAAC,CACH;CAEA,IAAI,QACF,YAAY,KACV,UACG,WAAW;EACV;EACA;EACA;EACA;EACA,MAAM;CACR,CAAC,CAAC,CACD,OAAO,UAAU;EAChB,OAAO,MAAM,0BAA0B,KAAK;EAC5C,OAAO;GACL,SAAS;GACT,OAAO,yBAAyB,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;EACvF;CACF,CAAC,CACL;CAEF,IAAI,QACF,YAAY,KACV,UACG,WAAW;EACV;EACA;EACA;EACA;EACA,MAAM;CACR,CAAC,CAAC,CACD,OAAO,UAAU;EAChB,OAAO,MAAM,0BAA0B,KAAK;EAC5C,OAAO;GACL,SAAS;GACT,OAAO,yBAAyB,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;EACvF;CACF,CAAC,CACL;CAEF,IAAI,MACF,YAAY,KACV,UACG,WAAW;EACV;EACA;EACA;EACA;EACA,MAAM;CACR,CAAC,CAAC,CACD,OAAO,UAAU;EAChB,OAAO,MAAM,wBAAwB,KAAK;EAC1C,OAAO;GACL,SAAS;GACT,OAAO,uBAAuB,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;EACrF;CACF,CAAC,CACL;CAIF,MAAM,UAAU,MAAM,QAAQ,IAAI,WAAW;CAG7C,MAAM,aAAa,QAAQ;CAC3B,IAAI,CAAC,WAAW,SACd,MAAM,IAAI,MAAM,WAAW,SAAS,eAAe;CAIrD,MAAM,gBAAgB,EAAE,GAAG,WAAW,KAAK;CAG3C,IAAI,cAAc,SAAS,KAAA,KAAa,cAAc,KAAK,SAAS,GAAG;EACrE,MAAM,2BAA2B,cAAc,KAC5C,QAAQ,aAAa,SAAS,SAAS,KAAA,KAAa,SAAS,SAAS,EAAE,CAAC,CACzE,KAAK,cAAc;GAClB,OAAO,SAAS,SAAS;GACzB,MAAM,SAAS,QAAQ;GACvB,QAAQ,SAAS,UAAU;GAC3B,MAAM,SAAS,QAAQ;GACvB,UAAU,SAAS,YAAY;GAC/B,WAAW;EACb,EAAE;EACJ,cAAc,aAAa,CACzB,GAAI,cAAc,cAAc,CAAC,GACjC,GAAG,wBACL;EACA,OAAO,cAAc;CACvB;CAEA,QAAQ,MAAM,CAAC,CAAC,CAAC,SAAS,WAAW;EACnC,IAAI,OAAO,WAAW,OAAO,SAAS,KAAA,GAAW;GAC/C,IAAI,OAAO,KAAK,WAAW,KAAA,KAAa,OAAO,KAAK,OAAO,SAAS,GAClE,cAAc,SAAS,CACrB,GAAI,cAAc,UAAU,CAAC,GAC7B,GAAG,OAAO,KAAK,MACjB;GAEF,IAAI,OAAO,KAAK,WAAW,KAAA,KAAa,OAAO,KAAK,OAAO,SAAS,GAClE,cAAc,SAAS,CACrB,GAAI,cAAc,UAAU,CAAC,GAC7B,GAAG,OAAO,KAAK,MACjB;GAEF,IAAI,OAAO,KAAK,SAAS,KAAA,KAAa,OAAO,KAAK,KAAK,SAAS,GAAG;IACjE,MAAM,mBAAmB,OAAO,KAAK,KAAK,KAAK,cAAc;KAC3D,GAAG;KACH,MAAM,SAAS,QAAQ;IACzB,EAAE;IACF,cAAc,aAAa,CACzB,GAAI,cAAc,cAAc,CAAC,GACjC,GAAG,gBACL;GACF;EACF;CACF,CAAC;CAED,IACE,cAAc,eAAe,KAAA,KAC7B,cAAc,WAAW,SAAS,GAClC;;;;EAIA,MAAM,4BAAY,IAAI,IAAY;EAClC,cAAc,aAAa,cAAc,WAAW,QAAQ,UAAU;GACpE,IAAI,CAAC,MAAM,QAAQ,UAAU,IAAI,MAAM,IAAI,GACzC,OAAO;GAET,UAAU,IAAI,MAAM,IAAI;GACxB,OAAO;EACT,CAAC;CACH;CAEA,OAAO;EAAE,SAAS;EAAM,MAAM;CAAc;AAC9C;AAEA,SAAS,sBAAsB,EAC7B,WACA,YACA,gBACA,gBACA,cACA,iBACA,iBACA,cACA,mBACA,UAYC;CACD,OAAO,eAAgB,EACrB,OACA,MACA,SACA,UAAU,MACV,aAAa,GACb,iBACA,SAAS,OACT,SAAS,OACT,OAAO,SAWuB;EAC9B,IAAI;GAEF,MAAM,eAAe,MAAM,wBAAwB;IACjD;IACA;IACA;IACA;IACA;IACA,QAAQ,kBAAkB;IAC1B,QAAQ,kBAAkB;IAC1B,MAAM,gBAAgB;IACtB;GACF,CAAC;GAED,kBAAkB,YAAY;GAW9B,OAAO,iBACL,MAV6B,gBAAgB,eAAe;IAC5D;IACA;IACA,QAAQ;IACR;IACA;IACA,aAAa;GACf,CAAC,GAIC,cACA,iBACF;EACF,SAAS,OAAO;GACd,OAAO,MAAM,oBAAoB,KAAK;GACtC,OAAO;IACL,SAAS,CAAC;IACV,YAAY,CAAC;IACb,QAAQ,CAAC;IACT,QAAQ,CAAC;IACT,MAAM,CAAC;IACP,iBAAiB,CAAC;IAClB,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;GAC9D;EACF;CACF;AACF;AAEA,SAAS,sBAAsB,EAC7B,gBACA,mBAIC;CACD,OAAO,SAAU,SAA+B;EAC9C,IAAI,CAAC,iBACH;EAEF,gBAAgB,SAAS,cAAc;CACzC;AACF;AAEA,SAAS,WAAW,EAClB,QACA,QACA,gBACA,iBAAiB,oBAMO;CACxB,OAAO,KACL,OAAO,WAAW,mBAAmB;EAEnC,MAAM,EAAE,OAAO,MAAM,SAAS,IAAI,QAAQ,QAAQ,SAASA;EAE3D,MAAM,eAAe,MAAM,OAAO;GAChC;GACA;GACA,SAJc,OAAO,OAAO,YAAY,KAAK,KAAK,KAAA;GAKlD;GACA;GACA;GACA,iBAAiB,sBAAsB;IACrC;IACA,iBAAiB;GACnB,CAAC;EACH,CAAC;EACD,MAAM,OAAO,eAAe,UAAU,QAAQ;EAC9C,MAAM,EAAE,QAAQ,eAAe,oBAC7B,MACA,cACA,cACF;EACA,MAAM,OAA2B;GAAE;GAAM,GAAG;GAAc;EAAW;EACrE,OAAO,CAAC,QAAQ,GAAA,eAA0B,KAAK,CAAC;CAClD,GACA;EACE,MAAM;EACN,aAAa;EACL;EACR,gBAAA;CACF,CACF;AACF;AAoBA,MAAa,oBACX,SAA6B,CAAC,MACJ;CAC1B,MAAM,EACJ,iBAAiB,UACjB,cACA,oBACA,eACA,cACA,iBACA,kBACA,qBACA,gBACA,gBACA,uBACA,eAAe,UACf,iBACA,aAAa,GACb,kBACA,WACA,cACA,cACA,mBACA,gBACA,aAAa,CAAC,eAAe,GAC7B,gBAAgB,MAChB,aAAa,GACb,kBAAkB,aAClB,iBACA,iBACA,kBACA,kBACA,sBACA,sBACA,WACA,WACA,kBACA,mBACA,gBACA,YACA,YACA,cACA,iBAAiB,kBACjB,oBACE;CAEJ,MAAM,SAAS,OAAO,UAAU,oBAAoB;CACpD,MAAM,+BACJ,mBAAmB,YAAY,OAAO,cAAc,OAChD;EACA,GAAG;EACH,YAAY,OAAO,eAAe;CACpC,IACE;CAEN,MAAM,mBAA4C;EAChD,OAAO;EACP,MAAM;EACN,QAAQ;EACR,QAAQ;EACR,MAAM;CACR;CAEA,IAAI,mBAAmB,YAAY,mBAAmB,UACpD,iBAAiB,UAAU;CAG7B,MAAM,aAAa;EACjB,MAAM;EACN,YAAY;EACZ,UAAU,CAAC,OAAO;CACpB;CAEA,MAAM,YAAY,gBAAgB;EAChC;EACA;EACA;EACA;EACA;EACA;EACA,qBAAqB;EACrB;EACA;EACA;EACA;EACA;EACA;CACF,CAAC;;CAGD,IAAI;CAEJ,IAAI,oBAAoB,UACtB,kBAAkB,oBAAoB;EACpC,GAAG;EACH,QAAQ;EACR,SAAS,kBAAkB,sBAAsB;EACjD;CACF,CAAC;MACI,IAAI,oBAAoB,UAC7B,kBAAkB,oBAAoB;EACpC,GAAG;EACH,QACE,sBAAsB,UACtB,gBACA,QAAQ,IAAI;EACd,QAAQ,sBAAsB,UAAU;EACxC,SAAS,kBAAkB,sBAAsB;EACjD;CACF,CAAC;MACI,IAAI,oBAAoB,OAC7B,kBAAkB,iBAAiB;EACjC,GAAG;EACH,QACE,mBAAmB,UAAU,aAAa,QAAQ,IAAI;EACxD,QAAQ,mBAAmB,UAAU;EACrC,SAAS,kBAAkB,mBAAmB;EAC9C,SAAS,mBAAmB,WAAW,CAAC,YAAY,SAAS;EAC7D;CACF,CAAC;MAED,kBAAkB,uBAAuB;EACvC,GAAG;EACH,QAAQ,mBAAmB,QAAQ,IAAI;EACvC,QAAQ;EACR,SAAS;EACT,SAAS,kBAAkB,kBAAkB;EAC7C,SAAS,kBAAkB,WAAW,CAAC,YAAY,SAAS;EAC5D;CACF,CAAC;CAGH,MAAM,mBAAmB,eAAe;EACtC;EACA;EACA;EACA;EACA;EACA;CACF,CAAC;CAED,IAAI,CAAC,kBACH,OAAO,KAAK,8CAA8C;CAG5D,MAAM,kBAAkB,sBACtB;EACE,UAAU;EACV;EACA;EACA;EACA;EACA;EACA;EACA;CACF,GACA,eACF;CAoBA,OAAO,WAAW;EAChB,QAnBa,sBAAsB;GACnC;GACA;GAGA,gBAAgB,mBAAmB;GACnC,gBACE,mBAAmB,YACnB,mBAAmB,cACnB,mBAAmB;GACrB,cAAc,mBAAmB;GACjC;GACA;GACA;GACA;GACA;EACF,CAGO;EACL,QAAQ;EACR;EACA,iBAAiB;CACnB,CAAC;AACH"}
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
* permission policies (allow / deny / ask lists + a global mode) without
|
|
4
4
|
* hand-rolling matching, precedence, and decision logic per-host.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* Uses the Claude Code Agent SDK permission vocabulary (`allowed_tools` /
|
|
7
|
+
* `disallowed_tools` / `permissionMode`) while treating modes as fallbacks
|
|
8
|
+
* for calls that match no explicit rule. See the README's HITL section for
|
|
9
|
+
* the cross-walk and `docs/hooks-design-report.md` for the broader hook
|
|
10
|
+
* system context.
|
|
11
11
|
*/
|
|
12
12
|
import type { HookCallback } from './types';
|
|
13
13
|
/**
|
|
@@ -18,10 +18,8 @@ import type { HookCallback } from './types';
|
|
|
18
18
|
* - `dontAsk` — unmatched tools are denied; the human is never
|
|
19
19
|
* prompted. Useful for headless / API agents where a
|
|
20
20
|
* silent denial is preferable to a hung interrupt.
|
|
21
|
-
* - `bypass` —
|
|
22
|
-
*
|
|
23
|
-
* the agent and want to stop being asked. Equivalent to
|
|
24
|
-
* Claude Code's `bypassPermissions`.
|
|
21
|
+
* - `bypass` — unmatched tools are approved. Explicit `deny` and `ask`
|
|
22
|
+
* rules still apply.
|
|
25
23
|
*/
|
|
26
24
|
export type ToolPolicyMode = 'default' | 'dontAsk' | 'bypass';
|
|
27
25
|
export interface ToolPolicyConfig {
|
|
@@ -43,9 +41,8 @@ export interface ToolPolicyConfig {
|
|
|
43
41
|
*/
|
|
44
42
|
deny?: readonly string[];
|
|
45
43
|
/**
|
|
46
|
-
* Tool name patterns that always trigger human approval
|
|
47
|
-
*
|
|
48
|
-
* still bypassed (because that's what bypass means).
|
|
44
|
+
* Tool name patterns that always trigger human approval. Wins over
|
|
45
|
+
* `allow` and every mode, but not `deny`.
|
|
49
46
|
*/
|
|
50
47
|
ask?: readonly string[];
|
|
51
48
|
/**
|
|
@@ -70,12 +67,12 @@ export interface ToolPolicyConfig {
|
|
|
70
67
|
* registry.register('PreToolUse', { hooks: [policyHook] });
|
|
71
68
|
* ```
|
|
72
69
|
*
|
|
73
|
-
*
|
|
70
|
+
* Explicit rules take precedence over fallback modes:
|
|
74
71
|
*
|
|
75
72
|
* 1. `deny` rule match → `'deny'` (always wins, even in `bypass`).
|
|
76
|
-
* 2. `
|
|
73
|
+
* 2. `ask` rule match → `'ask'`.
|
|
77
74
|
* 3. `allow` rule match → `'allow'`.
|
|
78
|
-
* 4. `
|
|
75
|
+
* 4. `mode === 'bypass'` → `'allow'`.
|
|
79
76
|
* 5. `mode === 'dontAsk'` → `'deny'`.
|
|
80
77
|
* 6. fallthrough → `'ask'`.
|
|
81
78
|
*
|
|
@@ -21,6 +21,15 @@ export declare function getMessageAuthor(message: BaseMessage): string;
|
|
|
21
21
|
export declare function convertAuthorToRole(author: string): (typeof POSSIBLE_ROLES)[number];
|
|
22
22
|
export declare function convertMessageContentToParts(message: BaseMessage, isMultimodalModel: boolean, previousMessages: BaseMessage[], model?: string): Part[];
|
|
23
23
|
export declare function convertBaseMessagesToContent(messages: BaseMessage[], isMultimodalModel: boolean, convertSystemMessageToHumanContent?: boolean, model?: string): Content[] | undefined;
|
|
24
|
+
export declare function rejectsModelTurnPrefill(model?: string): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Drops trailing `model`-role turns for models that reject prefill (see
|
|
27
|
+
* {@link rejectsModelTurnPrefill}). Such a turn is only produced by prefill
|
|
28
|
+
* flows (e.g. editing an assistant reply and resubmitting); these models return
|
|
29
|
+
* HTTP 400 for it, so we drop it and let the model generate fresh from the
|
|
30
|
+
* preceding user turn. No-op for every other model, preserving working prefill.
|
|
31
|
+
*/
|
|
32
|
+
export declare function dropUnsupportedModelTurnPrefill(contents: Content[] | undefined, model?: string): Content[] | undefined;
|
|
24
33
|
export declare function convertResponseContentToChatGenerationChunk(response: EnhancedGenerateContentResponse, extra: {
|
|
25
34
|
usageMetadata?: UsageMetadata | undefined;
|
|
26
35
|
index: number;
|
|
@@ -200,6 +200,8 @@ export interface ProcessSourcesConfig {
|
|
|
200
200
|
* configurable via the `SEARCH_CHUNK_OVERLAP` env var. Clamped below
|
|
201
201
|
* `chunkSize`. */
|
|
202
202
|
chunkOverlap?: number;
|
|
203
|
+
mainExpandBy?: number;
|
|
204
|
+
separatorExpandBy?: number;
|
|
203
205
|
strategies?: string[];
|
|
204
206
|
filterContent?: boolean;
|
|
205
207
|
reranker?: BaseReranker;
|
package/package.json
CHANGED
|
@@ -98,12 +98,12 @@ describe('createToolPolicyHook — bypass mode', () => {
|
|
|
98
98
|
expect((await callHook(hook, 'read_file')).decision).toBe('allow');
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
-
it('
|
|
101
|
+
it('still asks tools that match an explicit ask pattern', async () => {
|
|
102
102
|
const hook = createToolPolicyHook({
|
|
103
103
|
mode: 'bypass',
|
|
104
104
|
ask: ['execute_*'],
|
|
105
105
|
});
|
|
106
|
-
expect((await callHook(hook, 'execute_code')).decision).toBe('
|
|
106
|
+
expect((await callHook(hook, 'execute_code')).decision).toBe('ask');
|
|
107
107
|
});
|
|
108
108
|
});
|
|
109
109
|
|
|
@@ -154,6 +154,15 @@ describe('createToolPolicyHook — pattern matching', () => {
|
|
|
154
154
|
});
|
|
155
155
|
|
|
156
156
|
describe('createToolPolicyHook — precedence', () => {
|
|
157
|
+
it('deny wins over ask', async () => {
|
|
158
|
+
const hook = createToolPolicyHook({
|
|
159
|
+
mode: 'default',
|
|
160
|
+
deny: ['execute_delete'],
|
|
161
|
+
ask: ['execute_*'],
|
|
162
|
+
});
|
|
163
|
+
expect((await callHook(hook, 'execute_delete')).decision).toBe('deny');
|
|
164
|
+
});
|
|
165
|
+
|
|
157
166
|
it('deny wins over allow', async () => {
|
|
158
167
|
const hook = createToolPolicyHook({
|
|
159
168
|
mode: 'default',
|
|
@@ -173,13 +182,13 @@ describe('createToolPolicyHook — precedence', () => {
|
|
|
173
182
|
expect((await callHook(hook, 'anything_else')).decision).toBe('allow');
|
|
174
183
|
});
|
|
175
184
|
|
|
176
|
-
it('
|
|
185
|
+
it('ask wins over allow in default mode', async () => {
|
|
177
186
|
const hook = createToolPolicyHook({
|
|
178
187
|
mode: 'default',
|
|
179
188
|
allow: ['execute_safe'],
|
|
180
189
|
ask: ['execute_*'],
|
|
181
190
|
});
|
|
182
|
-
expect((await callHook(hook, 'execute_safe')).decision).toBe('
|
|
191
|
+
expect((await callHook(hook, 'execute_safe')).decision).toBe('ask');
|
|
183
192
|
expect((await callHook(hook, 'execute_dangerous')).decision).toBe('ask');
|
|
184
193
|
});
|
|
185
194
|
});
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
* permission policies (allow / deny / ask lists + a global mode) without
|
|
4
4
|
* hand-rolling matching, precedence, and decision logic per-host.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* Uses the Claude Code Agent SDK permission vocabulary (`allowed_tools` /
|
|
7
|
+
* `disallowed_tools` / `permissionMode`) while treating modes as fallbacks
|
|
8
|
+
* for calls that match no explicit rule. See the README's HITL section for
|
|
9
|
+
* the cross-walk and `docs/hooks-design-report.md` for the broader hook
|
|
10
|
+
* system context.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { HookCallback, PreToolUseHookOutput, ToolDecision } from './types';
|
|
@@ -20,10 +20,8 @@ import type { HookCallback, PreToolUseHookOutput, ToolDecision } from './types';
|
|
|
20
20
|
* - `dontAsk` — unmatched tools are denied; the human is never
|
|
21
21
|
* prompted. Useful for headless / API agents where a
|
|
22
22
|
* silent denial is preferable to a hung interrupt.
|
|
23
|
-
* - `bypass` —
|
|
24
|
-
*
|
|
25
|
-
* the agent and want to stop being asked. Equivalent to
|
|
26
|
-
* Claude Code's `bypassPermissions`.
|
|
23
|
+
* - `bypass` — unmatched tools are approved. Explicit `deny` and `ask`
|
|
24
|
+
* rules still apply.
|
|
27
25
|
*/
|
|
28
26
|
export type ToolPolicyMode = 'default' | 'dontAsk' | 'bypass';
|
|
29
27
|
|
|
@@ -46,9 +44,8 @@ export interface ToolPolicyConfig {
|
|
|
46
44
|
*/
|
|
47
45
|
deny?: readonly string[];
|
|
48
46
|
/**
|
|
49
|
-
* Tool name patterns that always trigger human approval
|
|
50
|
-
*
|
|
51
|
-
* still bypassed (because that's what bypass means).
|
|
47
|
+
* Tool name patterns that always trigger human approval. Wins over
|
|
48
|
+
* `allow` and every mode, but not `deny`.
|
|
52
49
|
*/
|
|
53
50
|
ask?: readonly string[];
|
|
54
51
|
/**
|
|
@@ -115,12 +112,12 @@ function formatReason(
|
|
|
115
112
|
* registry.register('PreToolUse', { hooks: [policyHook] });
|
|
116
113
|
* ```
|
|
117
114
|
*
|
|
118
|
-
*
|
|
115
|
+
* Explicit rules take precedence over fallback modes:
|
|
119
116
|
*
|
|
120
117
|
* 1. `deny` rule match → `'deny'` (always wins, even in `bypass`).
|
|
121
|
-
* 2. `
|
|
118
|
+
* 2. `ask` rule match → `'ask'`.
|
|
122
119
|
* 3. `allow` rule match → `'allow'`.
|
|
123
|
-
* 4. `
|
|
120
|
+
* 4. `mode === 'bypass'` → `'allow'`.
|
|
124
121
|
* 5. `mode === 'dontAsk'` → `'deny'`.
|
|
125
122
|
* 6. fallthrough → `'ask'`.
|
|
126
123
|
*
|
|
@@ -168,14 +165,14 @@ function decide(
|
|
|
168
165
|
if (denyMatch(toolName)) {
|
|
169
166
|
return 'deny';
|
|
170
167
|
}
|
|
171
|
-
if (
|
|
172
|
-
return '
|
|
168
|
+
if (askMatch(toolName)) {
|
|
169
|
+
return 'ask';
|
|
173
170
|
}
|
|
174
171
|
if (allowMatch(toolName)) {
|
|
175
172
|
return 'allow';
|
|
176
173
|
}
|
|
177
|
-
if (
|
|
178
|
-
return '
|
|
174
|
+
if (mode === 'bypass') {
|
|
175
|
+
return 'allow';
|
|
179
176
|
}
|
|
180
177
|
if (mode === 'dontAsk') {
|
|
181
178
|
return 'deny';
|
package/src/llm/google/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type { GoogleClientOptions, GoogleThinkingConfig } from '@/types';
|
|
|
19
19
|
import {
|
|
20
20
|
convertResponseContentToChatGenerationChunk,
|
|
21
21
|
convertBaseMessagesToContent,
|
|
22
|
+
dropUnsupportedModelTurnPrefill,
|
|
22
23
|
mapGenerateContentResultToChatResult,
|
|
23
24
|
} from './utils/common';
|
|
24
25
|
|
|
@@ -254,6 +255,7 @@ export class CustomChatGoogleGenerativeAI extends ChatGoogleGenerativeAI {
|
|
|
254
255
|
this.client.systemInstruction = systemInstruction;
|
|
255
256
|
actualPrompt = prompt.slice(1);
|
|
256
257
|
}
|
|
258
|
+
actualPrompt = dropUnsupportedModelTurnPrefill(actualPrompt, this.model);
|
|
257
259
|
const parameters = this.invocationParams(options);
|
|
258
260
|
const request = {
|
|
259
261
|
...parameters,
|
|
@@ -308,6 +310,7 @@ export class CustomChatGoogleGenerativeAI extends ChatGoogleGenerativeAI {
|
|
|
308
310
|
this.client.systemInstruction = systemInstruction;
|
|
309
311
|
actualPrompt = prompt.slice(1);
|
|
310
312
|
}
|
|
313
|
+
actualPrompt = dropUnsupportedModelTurnPrefill(actualPrompt, this.model);
|
|
311
314
|
const parameters = this.invocationParams(options);
|
|
312
315
|
const request = {
|
|
313
316
|
...parameters,
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { expect, test, describe } from '@jest/globals';
|
|
2
2
|
import { AIMessageChunk } from '@langchain/core/messages';
|
|
3
|
-
import type { EnhancedGenerateContentResponse } from '@google/generative-ai';
|
|
3
|
+
import type { Content, EnhancedGenerateContentResponse } from '@google/generative-ai';
|
|
4
4
|
import {
|
|
5
5
|
STREAMED_TOOL_CALL_SEAL_METADATA_KEY,
|
|
6
6
|
STREAMED_TOOL_CALL_ADAPTER_METADATA_KEY,
|
|
7
7
|
GOOGLE_STREAMED_TOOL_CALL_ADAPTER,
|
|
8
8
|
} from '@/tools/streamedToolCallSeals';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
convertResponseContentToChatGenerationChunk,
|
|
11
|
+
dropUnsupportedModelTurnPrefill,
|
|
12
|
+
rejectsModelTurnPrefill,
|
|
13
|
+
} from './common';
|
|
10
14
|
|
|
11
15
|
function buildResponse(
|
|
12
16
|
parts: Array<Record<string, unknown>>
|
|
@@ -62,3 +66,54 @@ describe('convertResponseContentToChatGenerationChunk seal metadata', () => {
|
|
|
62
66
|
expect(metadata[STREAMED_TOOL_CALL_SEAL_METADATA_KEY]).toBeUndefined();
|
|
63
67
|
});
|
|
64
68
|
});
|
|
69
|
+
|
|
70
|
+
describe('rejectsModelTurnPrefill', () => {
|
|
71
|
+
test('is true for models that reject a trailing model turn', () => {
|
|
72
|
+
expect(rejectsModelTurnPrefill('gemini-3.6-flash')).toBe(true);
|
|
73
|
+
expect(rejectsModelTurnPrefill('gemini-3.5-flash-lite')).toBe(true);
|
|
74
|
+
expect(rejectsModelTurnPrefill('models/gemini-3.6-flash')).toBe(true);
|
|
75
|
+
expect(rejectsModelTurnPrefill('google/gemini-3.5-flash-lite-latest')).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('is false for models that still accept prefill and for empty input', () => {
|
|
79
|
+
expect(rejectsModelTurnPrefill('gemini-3.5-flash')).toBe(false);
|
|
80
|
+
expect(rejectsModelTurnPrefill('gemini-2.5-flash')).toBe(false);
|
|
81
|
+
expect(rejectsModelTurnPrefill('gemini-3-pro-preview')).toBe(false);
|
|
82
|
+
expect(rejectsModelTurnPrefill(undefined)).toBe(false);
|
|
83
|
+
expect(rejectsModelTurnPrefill('')).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('dropUnsupportedModelTurnPrefill', () => {
|
|
88
|
+
const userTurn: Content = { role: 'user', parts: [{ text: 'Hi' }] };
|
|
89
|
+
const modelTurn: Content = { role: 'model', parts: [{ text: 'Hello, I am' }] };
|
|
90
|
+
|
|
91
|
+
test('drops a trailing model turn for no-prefill models', () => {
|
|
92
|
+
const contents: Content[] = [userTurn, modelTurn];
|
|
93
|
+
const result = dropUnsupportedModelTurnPrefill(contents, 'gemini-3.6-flash');
|
|
94
|
+
expect(result).toEqual([userTurn]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('drops multiple consecutive trailing model turns but keeps one turn', () => {
|
|
98
|
+
const contents: Content[] = [userTurn, modelTurn, modelTurn];
|
|
99
|
+
const result = dropUnsupportedModelTurnPrefill(contents, 'gemini-3.5-flash-lite');
|
|
100
|
+
expect(result).toEqual([userTurn]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('leaves a trailing model turn for models that accept prefill', () => {
|
|
104
|
+
const contents: Content[] = [userTurn, modelTurn];
|
|
105
|
+
const result = dropUnsupportedModelTurnPrefill(contents, 'gemini-3.5-flash');
|
|
106
|
+
expect(result).toBe(contents);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('is a no-op when the request already ends with a user turn', () => {
|
|
110
|
+
const contents: Content[] = [modelTurn, userTurn];
|
|
111
|
+
const result = dropUnsupportedModelTurnPrefill(contents, 'gemini-3.6-flash');
|
|
112
|
+
expect(result).toBe(contents);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('is a no-op for empty or undefined contents', () => {
|
|
116
|
+
expect(dropUnsupportedModelTurnPrefill([], 'gemini-3.6-flash')).toEqual([]);
|
|
117
|
+
expect(dropUnsupportedModelTurnPrefill(undefined, 'gemini-3.6-flash')).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -631,6 +631,50 @@ export function convertBaseMessagesToContent(
|
|
|
631
631
|
).content;
|
|
632
632
|
}
|
|
633
633
|
|
|
634
|
+
/**
|
|
635
|
+
* Gemini models that reject a request whose `contents` end with a `model`-role
|
|
636
|
+
* turn (a "prefill"). Google enforces this on newer generations (Gemini 3.6
|
|
637
|
+
* Flash, Gemini 3.5 Flash-Lite) while older/sibling models still accept a
|
|
638
|
+
* trailing model turn, so the rule is model-scoped rather than version-wide.
|
|
639
|
+
* Extend this list as Google applies the restriction to further models.
|
|
640
|
+
* @see https://ai.google.dev/gemini-api/docs/latest-model#api-changes-and-parameter-updates
|
|
641
|
+
*/
|
|
642
|
+
const NO_PREFILL_GEMINI_MODELS = [
|
|
643
|
+
'gemini-3.6-flash',
|
|
644
|
+
'gemini-3.5-flash-lite',
|
|
645
|
+
] as const;
|
|
646
|
+
|
|
647
|
+
export function rejectsModelTurnPrefill(model?: string): boolean {
|
|
648
|
+
if (model == null || model === '') {
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
const modelId = model.toLowerCase().split('/').pop() ?? '';
|
|
652
|
+
return NO_PREFILL_GEMINI_MODELS.some(
|
|
653
|
+
(id) => modelId === id || modelId.startsWith(`${id}-`)
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Drops trailing `model`-role turns for models that reject prefill (see
|
|
659
|
+
* {@link rejectsModelTurnPrefill}). Such a turn is only produced by prefill
|
|
660
|
+
* flows (e.g. editing an assistant reply and resubmitting); these models return
|
|
661
|
+
* HTTP 400 for it, so we drop it and let the model generate fresh from the
|
|
662
|
+
* preceding user turn. No-op for every other model, preserving working prefill.
|
|
663
|
+
*/
|
|
664
|
+
export function dropUnsupportedModelTurnPrefill(
|
|
665
|
+
contents: Content[] | undefined,
|
|
666
|
+
model?: string
|
|
667
|
+
): Content[] | undefined {
|
|
668
|
+
if (contents == null || contents.length === 0 || !rejectsModelTurnPrefill(model)) {
|
|
669
|
+
return contents;
|
|
670
|
+
}
|
|
671
|
+
let end = contents.length;
|
|
672
|
+
while (end > 1 && contents[end - 1]?.role === 'model') {
|
|
673
|
+
end -= 1;
|
|
674
|
+
}
|
|
675
|
+
return end === contents.length ? contents : contents.slice(0, end);
|
|
676
|
+
}
|
|
677
|
+
|
|
634
678
|
export function convertResponseContentToChatGenerationChunk(
|
|
635
679
|
response: EnhancedGenerateContentResponse,
|
|
636
680
|
extra: {
|
package/src/tools/ToolSearch.ts
CHANGED
|
@@ -359,18 +359,48 @@ function simplifyParametersForSearch(
|
|
|
359
359
|
return { type: parameters.type };
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Splits one alphanumeric identifier segment on case boundaries without
|
|
364
|
+
* emitting artificial one-character acronym fragments.
|
|
365
|
+
*/
|
|
366
|
+
function splitCaseSegment(segment: string): string[] {
|
|
367
|
+
const splitTokens = segment
|
|
368
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
369
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
370
|
+
.toLowerCase()
|
|
371
|
+
.split(/\s+/)
|
|
372
|
+
.filter(Boolean);
|
|
373
|
+
|
|
374
|
+
if (splitTokens.length < 2) return splitTokens;
|
|
375
|
+
|
|
376
|
+
const mergedTokens: string[] = [];
|
|
377
|
+
let prefix = '';
|
|
378
|
+
for (const token of splitTokens) {
|
|
379
|
+
if (token.length === 1) {
|
|
380
|
+
prefix += token;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
mergedTokens.push(`${prefix}${token}`);
|
|
384
|
+
prefix = '';
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (prefix && mergedTokens.length > 0) {
|
|
388
|
+
mergedTokens[mergedTokens.length - 1] += prefix;
|
|
389
|
+
}
|
|
390
|
+
return mergedTokens.length > 0 ? mergedTokens : [prefix];
|
|
391
|
+
}
|
|
392
|
+
|
|
362
393
|
/**
|
|
363
394
|
* Tokenizes a string into lowercase words for BM25.
|
|
364
|
-
* Splits
|
|
395
|
+
* Splits camelCase, underscores, and non-alphanumeric characters for consistent matching.
|
|
365
396
|
* @param text - The text to tokenize
|
|
366
397
|
* @returns Array of lowercase tokens
|
|
367
398
|
*/
|
|
368
399
|
function tokenize(text: string): string[] {
|
|
369
400
|
return text
|
|
370
|
-
.
|
|
371
|
-
.
|
|
372
|
-
.
|
|
373
|
-
.filter((token) => token.length > 0);
|
|
401
|
+
.split(/[^a-zA-Z0-9]+/)
|
|
402
|
+
.filter(Boolean)
|
|
403
|
+
.flatMap(splitCaseSegment);
|
|
374
404
|
}
|
|
375
405
|
|
|
376
406
|
/**
|
|
@@ -396,7 +426,7 @@ function createToolDocument(tool: t.ToolMetadata, fields: string[]): string {
|
|
|
396
426
|
parts.push(paramNames);
|
|
397
427
|
}
|
|
398
428
|
|
|
399
|
-
return parts.join(' ');
|
|
429
|
+
return tokenize(parts.join(' ')).join(' ');
|
|
400
430
|
}
|
|
401
431
|
|
|
402
432
|
/**
|
|
@@ -498,35 +528,63 @@ function performLocalSearch(
|
|
|
498
528
|
|
|
499
529
|
const maxScore = Math.max(...scores.filter((s) => s > 0), 1);
|
|
500
530
|
const queryLower = query.toLowerCase().trim();
|
|
531
|
+
const queryIdentifier = queryTokens.join('');
|
|
532
|
+
const matchesIdentifiers = fields.includes('name');
|
|
501
533
|
|
|
502
|
-
const results:
|
|
534
|
+
const results: Array<{
|
|
535
|
+
result: t.ToolSearchResult;
|
|
536
|
+
identifierPriority: number;
|
|
537
|
+
}> = [];
|
|
503
538
|
for (let i = 0; i < tools.length; i++) {
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
const
|
|
513
|
-
|
|
539
|
+
const score = scores[i];
|
|
540
|
+
const hasSearchScore = Number.isFinite(score) && score > 0;
|
|
541
|
+
let identifierPriority = 0;
|
|
542
|
+
let normalizedScore = hasSearchScore ? Math.min(score / maxScore, 1.0) : 0;
|
|
543
|
+
|
|
544
|
+
if (matchesIdentifiers) {
|
|
545
|
+
const rawBaseName = getBaseToolName(tools[i].name).toLowerCase();
|
|
546
|
+
const rawFullName = tools[i].name.toLowerCase();
|
|
547
|
+
const baseIdentifier = tokenize(rawBaseName).join('');
|
|
548
|
+
const fullIdentifier = tokenize(rawFullName).join('');
|
|
549
|
+
|
|
550
|
+
if (rawFullName === queryLower) {
|
|
551
|
+
identifierPriority = 4;
|
|
552
|
+
normalizedScore = 1.0;
|
|
553
|
+
} else if (rawBaseName === queryLower) {
|
|
554
|
+
identifierPriority = 3;
|
|
555
|
+
normalizedScore = 1.0;
|
|
556
|
+
} else if (
|
|
557
|
+
baseIdentifier === queryIdentifier ||
|
|
558
|
+
fullIdentifier === queryIdentifier
|
|
559
|
+
) {
|
|
560
|
+
identifierPriority = 2;
|
|
514
561
|
normalizedScore = 1.0;
|
|
515
|
-
} else if (
|
|
562
|
+
} else if (baseIdentifier.startsWith(queryIdentifier)) {
|
|
563
|
+
identifierPriority = 1;
|
|
516
564
|
normalizedScore = Math.max(normalizedScore, 0.95);
|
|
517
565
|
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (!hasSearchScore && identifierPriority === 0) continue;
|
|
518
569
|
|
|
519
|
-
|
|
570
|
+
const { field, snippet } = findMatchedField(tools[i], queryTokens, fields);
|
|
571
|
+
results.push({
|
|
572
|
+
result: {
|
|
520
573
|
tool_name: tools[i].name,
|
|
521
574
|
match_score: normalizedScore,
|
|
522
575
|
matched_field: field,
|
|
523
576
|
snippet,
|
|
524
|
-
}
|
|
525
|
-
|
|
577
|
+
},
|
|
578
|
+
identifierPriority,
|
|
579
|
+
});
|
|
526
580
|
}
|
|
527
581
|
|
|
528
|
-
results.sort(
|
|
529
|
-
|
|
582
|
+
results.sort(
|
|
583
|
+
(a, b) =>
|
|
584
|
+
b.identifierPriority - a.identifierPriority ||
|
|
585
|
+
b.result.match_score - a.result.match_score
|
|
586
|
+
);
|
|
587
|
+
const topResults = results.slice(0, maxResults).map(({ result }) => result);
|
|
530
588
|
|
|
531
589
|
return {
|
|
532
590
|
tool_references: topResults,
|