@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.
Files changed (35) hide show
  1. package/dist/cjs/hooks/createToolPolicyHook.cjs +5 -5
  2. package/dist/cjs/hooks/createToolPolicyHook.cjs.map +1 -1
  3. package/dist/cjs/llm/google/index.cjs +2 -0
  4. package/dist/cjs/llm/google/index.cjs.map +1 -1
  5. package/dist/cjs/llm/google/utils/common.cjs +28 -0
  6. package/dist/cjs/llm/google/utils/common.cjs.map +1 -1
  7. package/dist/cjs/tools/ToolSearch.cjs +59 -14
  8. package/dist/cjs/tools/ToolSearch.cjs.map +1 -1
  9. package/dist/cjs/tools/search/tool.cjs +5 -3
  10. package/dist/cjs/tools/search/tool.cjs.map +1 -1
  11. package/dist/esm/hooks/createToolPolicyHook.mjs +5 -5
  12. package/dist/esm/hooks/createToolPolicyHook.mjs.map +1 -1
  13. package/dist/esm/llm/google/index.mjs +3 -1
  14. package/dist/esm/llm/google/index.mjs.map +1 -1
  15. package/dist/esm/llm/google/utils/common.mjs +28 -1
  16. package/dist/esm/llm/google/utils/common.mjs.map +1 -1
  17. package/dist/esm/tools/ToolSearch.mjs +59 -14
  18. package/dist/esm/tools/ToolSearch.mjs.map +1 -1
  19. package/dist/esm/tools/search/tool.mjs +5 -3
  20. package/dist/esm/tools/search/tool.mjs.map +1 -1
  21. package/dist/types/hooks/createToolPolicyHook.d.ts +12 -15
  22. package/dist/types/llm/google/utils/common.d.ts +9 -0
  23. package/dist/types/tools/search/types.d.ts +2 -0
  24. package/package.json +1 -1
  25. package/src/hooks/__tests__/createToolPolicyHook.test.ts +13 -4
  26. package/src/hooks/createToolPolicyHook.ts +16 -19
  27. package/src/llm/google/index.ts +3 -0
  28. package/src/llm/google/utils/common.test.ts +57 -2
  29. package/src/llm/google/utils/common.ts +44 -0
  30. package/src/tools/ToolSearch.ts +81 -23
  31. package/src/tools/__tests__/ToolSearch.test.ts +151 -0
  32. package/src/tools/__tests__/hitl.test.ts +114 -1
  33. package/src/tools/search/source-processing.test.ts +57 -0
  34. package/src/tools/search/tool.ts +13 -1
  35. 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":"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"}
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
- * Maps directly to the Claude Code Agent SDK permission vocabulary
7
- * (`allowed_tools` / `disallowed_tools` / `permissionMode`) so users of
8
- * either SDK can think in the same terms. See the README's HITL section
9
- * for the cross-walk and `docs/hooks-design-report.md` for the broader
10
- * hook system context.
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` — every tool is approved, except those matching `deny`
22
- * patterns. The kill switch you flip when you trust
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, regardless
47
- * of `mode: 'default'` vs `'dontAsk'`. In `mode: 'bypass'` these are
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
- * Evaluation order matches Claude Code's permission flow:
70
+ * Explicit rules take precedence over fallback modes:
74
71
  *
75
72
  * 1. `deny` rule match → `'deny'` (always wins, even in `bypass`).
76
- * 2. `mode === 'bypass'` → `'allow'`.
73
+ * 2. `ask` rule match → `'ask'`.
77
74
  * 3. `allow` rule match → `'allow'`.
78
- * 4. `ask` rule match → `'ask'`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@librechat/agents",
3
- "version": "3.2.66",
3
+ "version": "3.2.68",
4
4
  "main": "./dist/cjs/main.cjs",
5
5
  "module": "./dist/esm/main.mjs",
6
6
  "types": "./dist/types/index.d.ts",
@@ -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('overrides explicit ask patterns (bypass means stop asking)', async () => {
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('allow');
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('allow wins over ask in default mode', async () => {
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('allow');
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
- * Maps directly to the Claude Code Agent SDK permission vocabulary
7
- * (`allowed_tools` / `disallowed_tools` / `permissionMode`) so users of
8
- * either SDK can think in the same terms. See the README's HITL section
9
- * for the cross-walk and `docs/hooks-design-report.md` for the broader
10
- * hook system context.
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` — every tool is approved, except those matching `deny`
24
- * patterns. The kill switch you flip when you trust
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, regardless
50
- * of `mode: 'default'` vs `'dontAsk'`. In `mode: 'bypass'` these are
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
- * Evaluation order matches Claude Code's permission flow:
115
+ * Explicit rules take precedence over fallback modes:
119
116
  *
120
117
  * 1. `deny` rule match → `'deny'` (always wins, even in `bypass`).
121
- * 2. `mode === 'bypass'` → `'allow'`.
118
+ * 2. `ask` rule match → `'ask'`.
122
119
  * 3. `allow` rule match → `'allow'`.
123
- * 4. `ask` rule match → `'ask'`.
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 (mode === 'bypass') {
172
- return 'allow';
168
+ if (askMatch(toolName)) {
169
+ return 'ask';
173
170
  }
174
171
  if (allowMatch(toolName)) {
175
172
  return 'allow';
176
173
  }
177
- if (askMatch(toolName)) {
178
- return 'ask';
174
+ if (mode === 'bypass') {
175
+ return 'allow';
179
176
  }
180
177
  if (mode === 'dontAsk') {
181
178
  return 'deny';
@@ -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 { convertResponseContentToChatGenerationChunk } from './common';
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: {
@@ -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 on underscores and non-alphanumeric characters for consistent matching.
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
- .toLowerCase()
371
- .replace(/[^a-z0-9]/g, ' ')
372
- .split(/\s+/)
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: t.ToolSearchResult[] = [];
534
+ const results: Array<{
535
+ result: t.ToolSearchResult;
536
+ identifierPriority: number;
537
+ }> = [];
503
538
  for (let i = 0; i < tools.length; i++) {
504
- if (scores[i] > 0) {
505
- const { field, snippet } = findMatchedField(
506
- tools[i],
507
- queryTokens,
508
- fields
509
- );
510
- let normalizedScore = Math.min(scores[i] / maxScore, 1.0);
511
-
512
- const baseName = getBaseToolName(tools[i].name).toLowerCase();
513
- if (baseName === queryLower) {
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 (baseName.startsWith(queryLower)) {
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
- results.push({
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((a, b) => b.match_score - a.match_score);
529
- const topResults = results.slice(0, maxResults);
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,