Merge pull request #442 from claude-code-best/feature/tool_search

feat: 支持 SearchExtraTools 能力以替代 Tool Search
This commit is contained in:
claude-code-best
2026-05-09 17:23:03 +08:00
committed by GitHub
78 changed files with 4987 additions and 791 deletions

View File

@@ -387,11 +387,11 @@ async function countBuiltInToolTokens(
}
// Check if tool search is enabled
const { isToolSearchEnabled } = await import('./toolSearch.js')
const { isSearchExtraToolsEnabled } = await import('./searchExtraTools.js')
const { isDeferredTool } = await import(
'@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js'
'@claude-code-best/builtin-tools/tools/SearchExtraToolsTool/prompt.js'
)
const isDeferred = await isToolSearchEnabled(
const isDeferred = await isSearchExtraToolsEnabled(
model ?? '',
tools,
getToolPermissionContext,
@@ -672,13 +672,13 @@ export async function countMcpToolTokens(
)
// Check if tool search is enabled - if so, MCP tools are deferred
// isToolSearchEnabled handles threshold calculation internally for TstAuto mode
const { isToolSearchEnabled } = await import('./toolSearch.js')
// isSearchExtraToolsEnabled handles threshold calculation internally for TstAuto mode
const { isSearchExtraToolsEnabled } = await import('./searchExtraTools.js')
const { isDeferredTool } = await import(
'@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js'
'@claude-code-best/builtin-tools/tools/SearchExtraToolsTool/prompt.js'
)
const isDeferred = await isToolSearchEnabled(
const isDeferred = await isSearchExtraToolsEnabled(
model,
tools,
getToolPermissionContext,
@@ -686,7 +686,7 @@ export async function countMcpToolTokens(
'analyzeMcp',
)
// Find MCP tools that have been used in messages (loaded via ToolSearchTool)
// Find MCP tools that have been used in messages (loaded via SearchExtraToolsTool)
const loadedMcpToolNames = new Set<string>()
if (isDeferred && messages) {
const mcpToolNameSet = new Set(mcpTools.map(t => t.name))

View File

@@ -230,11 +230,7 @@ export async function toolToAPISchema(
}
// CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS is the kill switch for beta API
// shapes. Proxy gateways (ANTHROPIC_BASE_URL → LiteLLM → Bedrock) reject
// fields like defer_loading with "Extra inputs are not permitted". The gates
// above each field are scattered and not all provider-aware, so this strips
// everything not in the base-tool allowlist at the one choke point all tool
// schemas pass through — including fields added in the future.
// shapes. Strips defer_loading and other beta fields from tool schemas.
// cache_control is allowlisted: the base {type: 'ephemeral'} shape is
// standard prompt caching (Bedrock/Vertex supported); the beta sub-fields
// (scope, ttl) are already gated upstream by shouldIncludeFirstPartyOnlyBetas

View File

@@ -1,4 +1,5 @@
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
import type { ToolDiscoveryResult } from '../services/searchExtraTools/prefetch.js'
import {
logEvent,
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
@@ -97,6 +98,12 @@ const skillSearchModules = feature('EXPERIMENTAL_SKILL_SEARCH')
require('../services/skillSearch/prefetch.js') as typeof import('../services/skillSearch/prefetch.js'),
}
: null
const searchExtraToolsModules = feature('EXPERIMENTAL_SEARCH_EXTRA_TOOLS')
? {
prefetch:
require('../services/searchExtraTools/prefetch.js') as typeof import('../services/searchExtraTools/prefetch.js'),
}
: null
const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER')
? (require('./permissions/autoModeState.js') as typeof import('./permissions/autoModeState.js'))
: null
@@ -159,18 +166,17 @@ import type { QuerySource } from '../constants/querySource.js'
import {
getDeferredToolsDelta,
isDeferredToolsDeltaEnabled,
isToolSearchEnabledOptimistic,
isToolSearchToolAvailable,
modelSupportsToolReference,
isSearchExtraToolsEnabledOptimistic,
isSearchExtraToolsToolAvailable,
type DeferredToolsDeltaScanContext,
} from './toolSearch.js'
} from './searchExtraTools.js'
import {
getMcpInstructionsDelta,
isMcpInstructionsDeltaEnabled,
type ClientSideInstruction,
} from './mcpInstructionsDelta.js'
import { CLAUDE_IN_CHROME_MCP_SERVER_NAME } from './claudeInChrome/common.js'
import { CHROME_TOOL_SEARCH_INSTRUCTIONS } from './claudeInChrome/prompt.js'
import { CHROME_SEARCH_EXTRA_TOOLS_INSTRUCTIONS } from './claudeInChrome/prompt.js'
import type { MCPServerConnection } from '../services/mcp/types.js'
import type {
HookEvent,
@@ -553,6 +559,14 @@ export type Attachment =
activePath?: string
}
}
| {
type: 'tool_discovery'
tools: ToolDiscoveryResult[]
trigger: 'assistant_turn' | 'user_input'
queryText: string
durationMs: number
indexSize: number
}
| {
type: 'queued_command'
prompt: string | Array<ContentBlockParam>
@@ -830,6 +844,25 @@ export async function getAttachments(
}),
]
: []),
// Tool discovery on turn 0. Inter-turn discovery runs via
// startSearchExtraToolsPrefetch in query.ts.
...(feature('EXPERIMENTAL_SEARCH_EXTRA_TOOLS') &&
searchExtraToolsModules &&
!options?.skipSkillDiscovery
? [
maybe('tool_discovery', async () => {
if (suppressNextDiscovery) {
return []
}
const result =
await searchExtraToolsModules.prefetch.getTurnZeroSearchExtraToolsPrefetch(
input,
context.options.tools ?? [],
)
return result ? [result] : []
}),
]
: []),
]
: []
@@ -1480,16 +1513,15 @@ export function getDeferredToolsDeltaAttachment(
scanContext?: DeferredToolsDeltaScanContext,
): Attachment[] {
if (!isDeferredToolsDeltaEnabled()) return []
// These three checks mirror the sync parts of isToolSearchEnabled —
// the attachment text says "available via ToolSearch", so ToolSearch
// These three checks mirror the sync parts of isSearchExtraToolsEnabled —
// the attachment text says "available via SearchExtraTools", so SearchExtraTools
// has to actually be in the request. The async auto-threshold check
// is not replicated (would double-fire tengu_tool_search_mode_decision);
// in tst-auto below-threshold the attachment can fire while ToolSearch
// is not replicated (would double-fire tengu_search_extra_tools_mode_decision);
// in tst-auto below-threshold the attachment can fire while SearchExtraTools
// is filtered out, but that's a narrow case and the tools announced
// are directly callable anyway.
if (!isToolSearchEnabledOptimistic()) return []
if (!modelSupportsToolReference(model)) return []
if (!isToolSearchToolAvailable(tools)) return []
if (!isSearchExtraToolsEnabledOptimistic()) return []
if (!isSearchExtraToolsToolAvailable(tools)) return []
const delta = getDeferredToolsDelta(tools, messages ?? [], scanContext)
if (!delta) return []
return [{ type: 'deferred_tools_delta', ...delta }]
@@ -1586,18 +1618,17 @@ export function getMcpInstructionsDeltaAttachment(
): Attachment[] {
if (!isMcpInstructionsDeltaEnabled()) return []
// The chrome ToolSearch hint is client-authored and ToolSearch-conditional;
// The chrome SearchExtraTools hint is client-authored and SearchExtraTools-conditional;
// actual server `instructions` are unconditional. Decide the chrome part
// here, pass it into the pure diff as a synthesized entry.
const clientSide: ClientSideInstruction[] = []
if (
isToolSearchEnabledOptimistic() &&
modelSupportsToolReference(model) &&
isToolSearchToolAvailable(tools)
isSearchExtraToolsEnabledOptimistic() &&
isSearchExtraToolsToolAvailable(tools)
) {
clientSide.push({
serverName: CLAUDE_IN_CHROME_MCP_SERVER_NAME,
block: CHROME_TOOL_SEARCH_INSTRUCTIONS,
block: CHROME_SEARCH_EXTRA_TOOLS_INSTRUCTIONS,
})
}

View File

@@ -16,8 +16,8 @@ import {
REDACT_THINKING_BETA_HEADER,
STRUCTURED_OUTPUTS_BETA_HEADER,
TOKEN_EFFICIENT_TOOLS_BETA_HEADER,
TOOL_SEARCH_BETA_HEADER_1P,
TOOL_SEARCH_BETA_HEADER_3P,
SEARCH_EXTRA_TOOLS_BETA_HEADER_1P,
SEARCH_EXTRA_TOOLS_BETA_HEADER_3P,
WEB_SEARCH_BETA_HEADER,
} from '../constants/betas.js'
import { OAUTH_BETA_HEADER } from '../constants/oauth.js'
@@ -197,15 +197,15 @@ export function modelSupportsAutoMode(model: string): boolean {
/**
* Get the correct tool search beta header for the current API provider.
* - Claude API / Foundry: advanced-tool-use-2025-11-20
* - Vertex AI / Bedrock: tool-search-tool-2025-10-19
* - All other providers: advanced-tool-use-2025-11-20
*/
export function getToolSearchBetaHeader(): string {
export function getSearchExtraToolsBetaHeader(): string {
const provider = getAPIProvider()
if (provider === 'vertex' || provider === 'bedrock') {
return TOOL_SEARCH_BETA_HEADER_3P
return SEARCH_EXTRA_TOOLS_BETA_HEADER_3P
}
return TOOL_SEARCH_BETA_HEADER_1P
return SEARCH_EXTRA_TOOLS_BETA_HEADER_1P
}
/**

View File

@@ -1,4 +1,4 @@
import { createUserMessage } from './messages.js'
import { randomUUID } from 'crypto'
import { getInitialSettings } from './settings/settings.js'
import type { Message } from '../types/message.js'
@@ -109,12 +109,11 @@ export function shouldShowCacheWarning(
/**
* 生成缓存警告消息
* @param info 缓存警告信息
* @returns 用户消息,标记为 isVisibleInTranscriptOnly
* @returns system 类型消息,在 REPL 主界面和 transcript 模式下可见
*/
export function createCacheWarningMessage(info: CacheHitRateInfo): Message {
const { hitRate, threshold, trend } = info
// 构建消息内容
let content = `Cache hit rate ${hitRate.toFixed(0)}%, below ${threshold}% threshold`
if (trend !== null && Math.abs(trend) > 0.1) {
@@ -123,9 +122,13 @@ export function createCacheWarningMessage(info: CacheHitRateInfo): Message {
content += ` (${trendIcon}${trendPercent}%)`
}
return createUserMessage({
return {
type: 'system',
subtype: 'cache_warning',
level: 'warning' as const,
content,
isMeta: true,
isVisibleInTranscriptOnly: true,
})
timestamp: new Date().toISOString(),
uuid: randomUUID(),
isMeta: false,
} as Message
}

View File

@@ -47,17 +47,17 @@ Never reuse tab IDs from a previous/other session. Follow these guidelines:
/**
* Additional instructions for chrome tools when tool search is enabled.
* These instruct the model to load chrome tools via ToolSearch before using them.
* These instruct the model to load chrome tools via SearchExtraTools before using them.
* Only injected when tool search is actually enabled (not just optimistically possible).
*/
export const CHROME_TOOL_SEARCH_INSTRUCTIONS = `**IMPORTANT: Before using any chrome browser tools, you MUST first load them using ToolSearch.**
export const CHROME_SEARCH_EXTRA_TOOLS_INSTRUCTIONS = `**IMPORTANT: Before using any chrome browser tools, you MUST first load them using SearchExtraTools.**
Chrome browser tools are MCP tools that require loading before use. Before calling any mcp__claude-in-chrome__* tool:
1. Use ToolSearch with \`select:mcp__claude-in-chrome__<tool_name>\` to load the specific tool
1. Use SearchExtraTools with \`select:mcp__claude-in-chrome__<tool_name>\` to load the specific tool
2. Then call the tool
For example, to get tab context:
1. First: ToolSearch with query "select:mcp__claude-in-chrome__tabs_context_mcp"
1. First: SearchExtraTools with query "select:mcp__claude-in-chrome__tabs_context_mcp"
2. Then: Call mcp__claude-in-chrome__tabs_context_mcp`
/**

View File

@@ -13,7 +13,7 @@ import {
detectGitOperation,
type PrAction,
} from '@claude-code-best/builtin-tools/tools/shared/gitOperationTracking.js'
import { TOOL_SEARCH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js'
import { SEARCH_EXTRA_TOOLS_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SearchExtraToolsTool/prompt.js'
import type {
CollapsedReadSearchGroup,
CollapsibleMessage,
@@ -76,7 +76,7 @@ export type SearchOrReadResult = {
isMemoryWrite: boolean
/**
* True for meta-operations that should be absorbed into a collapse group
* without incrementing any count (Snip, ToolSearch). They remain visible
* without incrementing any count (Snip, SearchExtraTools). They remain visible
* in verbose mode via the groupMessages iteration.
*/
isAbsorbedSilently: boolean
@@ -162,7 +162,7 @@ function commandAsHint(command: string): string {
* Also treats Write/Edit of memory files as collapsible.
* Returns detailed information about whether it's a search or read operation.
*/
export function getToolSearchOrReadInfo(
export function getSearchExtraToolsOrReadInfo(
toolName: string,
toolInput: unknown,
tools: Tools,
@@ -196,12 +196,12 @@ export function getToolSearchOrReadInfo(
}
}
// Meta-operations absorbed silently: Snip (context cleanup) and ToolSearch
// Meta-operations absorbed silently: Snip (context cleanup) and SearchExtraTools
// (lazy tool schema loading). Neither should break a collapse group or
// contribute to its count, but both stay visible in verbose mode.
if (
(feature('HISTORY_SNIP') && toolName === SNIP_TOOL_NAME) ||
(isFullscreenEnvEnabled() && toolName === TOOL_SEARCH_TOOL_NAME)
(isFullscreenEnvEnabled() && toolName === SEARCH_EXTRA_TOOLS_TOOL_NAME)
) {
return {
isCollapsible: true,
@@ -277,7 +277,11 @@ export function getSearchOrReadFromContent(
isBash?: boolean
} | null {
if (content?.type === 'tool_use' && content.name) {
const info = getToolSearchOrReadInfo(content.name, content.input, tools)
const info = getSearchExtraToolsOrReadInfo(
content.name,
content.input,
tools,
)
if (info.isCollapsible || info.isREPL) {
return {
isSearch: info.isSearch,
@@ -297,12 +301,12 @@ export function getSearchOrReadFromContent(
/**
* Checks if a tool is a search/read operation (for backwards compatibility).
*/
function isToolSearchOrRead(
function isSearchExtraToolsOrRead(
toolName: string,
toolInput: unknown,
tools: Tools,
): boolean {
return getToolSearchOrReadInfo(toolName, toolInput, tools).isCollapsible
return getSearchExtraToolsOrReadInfo(toolName, toolInput, tools).isCollapsible
}
/**
@@ -389,7 +393,7 @@ function isNonCollapsibleToolUse(
if (
content &&
content.type === 'tool_use' &&
!isToolSearchOrRead(
!isSearchExtraToolsOrRead(
(content as { name: string }).name,
(content as { input: unknown }).input,
tools,
@@ -403,7 +407,7 @@ function isNonCollapsibleToolUse(
if (
firstContent &&
firstContent.type === 'tool_use' &&
!isToolSearchOrRead(
!isSearchExtraToolsOrRead(
msg.toolName,
(firstContent as { input: unknown }).input,
tools,
@@ -463,7 +467,7 @@ function isCollapsibleToolUse(
return (
content !== undefined &&
content.type === 'tool_use' &&
isToolSearchOrRead(
isSearchExtraToolsOrRead(
(content as { name: string }).name,
(content as { input: unknown }).input,
tools,
@@ -475,7 +479,7 @@ function isCollapsibleToolUse(
return (
firstContent !== undefined &&
firstContent.type === 'tool_use' &&
isToolSearchOrRead(
isSearchExtraToolsOrRead(
msg.toolName,
(firstContent as { input: unknown }).input,
tools,
@@ -865,7 +869,7 @@ export function collapseReadSearchGroups(
currentGroup.memoryWriteCount += count
}
} else if (toolInfo.isAbsorbedSilently) {
// Snip/ToolSearch absorbed silently — no count, no summary text.
// Snip/SearchExtraTools absorbed silently — no count, no summary text.
// Hidden from the default view but still shown in verbose mode
// (Ctrl+O) via the groupMessages iteration in CollapsedReadSearchContent.
} else if (toolInfo.mcpServerName) {

View File

@@ -222,7 +222,7 @@ export const SAFE_ENV_VARS = new Set([
'DISABLE_ERROR_REPORTING',
'DISABLE_FEEDBACK_COMMAND',
'DISABLE_TELEMETRY',
'ENABLE_TOOL_SEARCH',
'ENABLE_SEARCH_EXTRA_TOOLS',
'MAX_MCP_OUTPUT_TOKENS',
'MAX_THINKING_TOKENS',
'MCP_TIMEOUT',

View File

@@ -171,8 +171,8 @@ function getTeammateMailbox(): typeof import('./teammateMailbox.js') {
import {
isToolReferenceBlock,
isToolSearchEnabledOptimistic,
} from './toolSearch.js'
isSearchExtraToolsEnabledOptimistic,
} from './searchExtraTools.js'
const MEMORY_CORRECTION_HINT =
"\n\nNote: The user's next message may contain a correction or preference. Pay close attention — if they explain what went wrong or how they'd prefer you to work, consider saving that to memory for future sessions."
@@ -2058,7 +2058,7 @@ export function stripCallerFieldFromAssistantMessage(
/**
* Does the content array have a tool_result block whose inner content
* contains tool_reference (ToolSearch loaded tools)?
* contains tool_reference (SearchExtraTools loaded tools)?
*/
function contentHasToolReference(
content: ReadonlyArray<ContentBlockParam>,
@@ -2387,7 +2387,7 @@ export function normalizeMessagesForAPI(
// When tool search IS enabled, strip only tool_reference blocks for
// tools that no longer exist (e.g., MCP server was disconnected).
let normalizedMessage = message
if (!isToolSearchEnabledOptimistic()) {
if (!isSearchExtraToolsEnabledOptimistic()) {
normalizedMessage = stripToolReferenceBlocksFromUserMessage(message)
} else {
normalizedMessage = stripUnavailableToolReferencesFromUserMessage(
@@ -2489,7 +2489,7 @@ export function normalizeMessagesForAPI(
// When tool search is NOT enabled, we must strip tool_search-specific fields
// like 'caller' from tool_use blocks, as these are only valid with the
// tool search beta header
const toolSearchEnabled = isToolSearchEnabledOptimistic()
const searchExtraToolsEnabled = isSearchExtraToolsEnabledOptimistic()
const normalizedMessage: AssistantMessage = {
...message,
message: {
@@ -2513,7 +2513,7 @@ export function normalizeMessagesForAPI(
const canonicalName = tool?.name ?? toolUseBlk.name
// When tool search is enabled, preserve all fields including 'caller'
if (toolSearchEnabled) {
if (searchExtraToolsEnabled) {
return {
...block,
name: canonicalName,
@@ -3909,7 +3909,24 @@ Read the team config to discover your teammates' names. Check the task list peri
}
}
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- teammate_mailbox/team_context/skill_discovery/bagel_console handled above
// tool_discovery handled here (not in the switch) so the 'tool_discovery'
// string literal lives inside a feature()-guarded block.
if (feature('EXPERIMENTAL_SEARCH_EXTRA_TOOLS')) {
if (attachment.type === 'tool_discovery') {
if (attachment.tools.length === 0) return []
const lines = attachment.tools.map(
t => `- ${t.name}: ${t.description.slice(0, 100)}`,
)
return wrapMessagesInSystemReminder([
createUserMessage({
content: `The following tools were discovered as relevant to your task. To invoke them, you MUST use ExecuteExtraTool — this is the only way to call these tools. Do not read source code or reason about whether they are callable; just call ExecuteExtraTool({"tool_name": "<name>", "params": {...}}) directly.\n\n${lines.join('\n')}`,
isMeta: true,
}),
])
}
}
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- teammate_mailbox/team_context/skill_discovery/tool_discovery/bagel_console handled above
switch (attachment.type) {
case 'directory': {
return wrapMessagesInSystemReminder([
@@ -4576,12 +4593,12 @@ You have exited auto mode. The user may now want to interact more directly. You
const parts: string[] = []
if (attachment.addedLines.length > 0) {
parts.push(
`The following deferred tools are now available via ToolSearch:\n${attachment.addedLines.join('\n')}`,
`The following deferred tools are now available via SearchExtraTools:\n${attachment.addedLines.join('\n')}`,
)
}
if (attachment.removedNames.length > 0) {
parts.push(
`The following deferred tools are no longer available (their MCP server disconnected). Do not search for them — ToolSearch will return no match:\n${attachment.removedNames.join('\n')}`,
`The following deferred tools are no longer available (their MCP server disconnected). Do not search for them — SearchExtraTools will return no match:\n${attachment.removedNames.join('\n')}`,
)
}
return wrapMessagesInSystemReminder([

View File

@@ -18,7 +18,7 @@ import { TASK_UPDATE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/Tas
import { TEAM_CREATE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/TeamCreateTool/constants.js'
import { TEAM_DELETE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/TeamDeleteTool/constants.js'
import { TODO_WRITE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/TodoWriteTool/constants.js'
import { TOOL_SEARCH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js'
import { SEARCH_EXTRA_TOOLS_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SearchExtraToolsTool/prompt.js'
import { YOLO_CLASSIFIER_TOOL_NAME } from './yoloClassifier.js'
// Ant-only tool names: conditional require so Bun can DCE these in external builds.
@@ -60,7 +60,7 @@ const SAFE_YOLO_ALLOWLISTED_TOOLS = new Set([
GREP_TOOL_NAME,
GLOB_TOOL_NAME,
LSP_TOOL_NAME,
TOOL_SEARCH_TOOL_NAME,
SEARCH_EXTRA_TOOLS_TOOL_NAME,
LIST_MCP_RESOURCES_TOOL_NAME,
'ReadMcpResourceTool', // no exported constant
// Task management (metadata only)

View File

@@ -1,9 +1,9 @@
/**
* Tool Search utilities for dynamically discovering deferred tools.
*
* When enabled, deferred tools (MCP and shouldDefer tools) are sent with
* defer_loading: true and discovered via ToolSearchTool rather than being
* loaded upfront.
* When enabled, deferred tools (all non-core tools) are sent with
* defer_loading: true and discovered via SearchExtraToolsTool rather than being
* loaded upfront. Core tools are defined in CORE_TOOLS (src/constants/tools.ts).
*/
import memoize from 'lodash-es/memoize.js'
@@ -22,8 +22,8 @@ import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/Agen
import {
formatDeferredToolLine,
isDeferredTool,
TOOL_SEARCH_TOOL_NAME,
} from '@claude-code-best/builtin-tools/tools/ToolSearchTool/prompt.js'
SEARCH_EXTRA_TOOLS_TOOL_NAME,
} from '@claude-code-best/builtin-tools/tools/SearchExtraToolsTool/prompt.js'
import type { Message } from '../types/message.js'
import {
countToolDefinitionTokens,
@@ -34,22 +34,18 @@ import { getMergedBetas } from './betas.js'
import { getContextWindowForModel } from './context.js'
import { logForDebugging } from './debug.js'
import { isEnvDefinedFalsy, isEnvTruthy } from './envUtils.js'
import {
getAPIProvider,
isFirstPartyAnthropicBaseUrl,
} from './model/providers.js'
import { jsonStringify } from './slowOperations.js'
import { zodToJsonSchema } from './zodToJsonSchema.js'
/**
* Default percentage of context window at which to auto-enable tool search.
* When MCP tool descriptions exceed this percentage (in tokens), tool search is enabled.
* Can be overridden via ENABLE_TOOL_SEARCH=auto:N where N is 0-100.
* Can be overridden via ENABLE_SEARCH_EXTRA_TOOLS=auto:N where N is 0-100.
*/
const DEFAULT_AUTO_TOOL_SEARCH_PERCENTAGE = 10 // 10%
const DEFAULT_AUTO_SEARCH_EXTRA_TOOLS_PERCENTAGE = 10 // 10%
/**
* Parse auto:N syntax from ENABLE_TOOL_SEARCH env var.
* Parse auto:N syntax from ENABLE_SEARCH_EXTRA_TOOLS env var.
* Returns the percentage clamped to 0-100, or null if not auto:N format or not a number.
*/
function parseAutoPercentage(value: string): number | null {
@@ -60,7 +56,7 @@ function parseAutoPercentage(value: string): number | null {
if (isNaN(percent)) {
logForDebugging(
`Invalid ENABLE_TOOL_SEARCH value "${value}": expected auto:N where N is a number.`,
`Invalid ENABLE_SEARCH_EXTRA_TOOLS value "${value}": expected auto:N where N is a number.`,
)
return null
}
@@ -70,9 +66,9 @@ function parseAutoPercentage(value: string): number | null {
}
/**
* Check if ENABLE_TOOL_SEARCH is set to auto mode (auto or auto:N).
* Check if ENABLE_SEARCH_EXTRA_TOOLS is set to auto mode (auto or auto:N).
*/
function isAutoToolSearchMode(value: string | undefined): boolean {
function isAutoSearchExtraToolsMode(value: string | undefined): boolean {
if (!value) return false
return value === 'auto' || value.startsWith('auto:')
}
@@ -80,16 +76,16 @@ function isAutoToolSearchMode(value: string | undefined): boolean {
/**
* Get the auto-enable percentage from env var or default.
*/
function getAutoToolSearchPercentage(): number {
const value = process.env.ENABLE_TOOL_SEARCH
if (!value) return DEFAULT_AUTO_TOOL_SEARCH_PERCENTAGE
function getAutoSearchExtraToolsPercentage(): number {
const value = process.env.ENABLE_SEARCH_EXTRA_TOOLS
if (!value) return DEFAULT_AUTO_SEARCH_EXTRA_TOOLS_PERCENTAGE
if (value === 'auto') return DEFAULT_AUTO_TOOL_SEARCH_PERCENTAGE
if (value === 'auto') return DEFAULT_AUTO_SEARCH_EXTRA_TOOLS_PERCENTAGE
const parsed = parseAutoPercentage(value)
if (parsed !== null) return parsed
return DEFAULT_AUTO_TOOL_SEARCH_PERCENTAGE
return DEFAULT_AUTO_SEARCH_EXTRA_TOOLS_PERCENTAGE
}
/**
@@ -101,10 +97,10 @@ const CHARS_PER_TOKEN = 2.5
/**
* Get the token threshold for auto-enabling tool search for a given model.
*/
function getAutoToolSearchTokenThreshold(model: string): number {
function getAutoSearchExtraToolsTokenThreshold(model: string): number {
const betas = getMergedBetas(model)
const contextWindow = getContextWindowForModel(model, betas)
const percentage = getAutoToolSearchPercentage() / 100
const percentage = getAutoSearchExtraToolsPercentage() / 100
return Math.floor(contextWindow * percentage)
}
@@ -112,8 +108,10 @@ function getAutoToolSearchTokenThreshold(model: string): number {
* Get the character threshold for auto-enabling tool search for a given model.
* Used as fallback when the token counting API is unavailable.
*/
export function getAutoToolSearchCharThreshold(model: string): number {
return Math.floor(getAutoToolSearchTokenThreshold(model) * CHARS_PER_TOKEN)
export function getAutoSearchExtraToolsCharThreshold(model: string): number {
return Math.floor(
getAutoSearchExtraToolsTokenThreshold(model) * CHARS_PER_TOKEN,
)
}
/**
@@ -152,185 +150,98 @@ const getDeferredToolTokenCount = memoize(
)
/**
* Tool search mode. Determines how deferrable tools (MCP + shouldDefer) are
* surfaced:
* - 'tst': Tool Search Tool deferred tools discovered via ToolSearchTool (always enabled)
* Tool search mode. Determines how deferred tools (all non-core tools)
* are surfaced:
* - 'tst': Tool Search Tool deferred tools discovered via SearchExtraToolsTool (always enabled)
* - 'tst-auto': auto tools deferred only when they exceed threshold
* - 'standard': tool search disabled all tools exposed inline
*/
export type ToolSearchMode = 'tst' | 'tst-auto' | 'standard'
export type SearchExtraToolsMode = 'tst' | 'tst-auto' | 'standard'
/**
* Determines the tool search mode from ENABLE_TOOL_SEARCH.
* Determines the tool search mode from ENABLE_SEARCH_EXTRA_TOOLS.
*
* ENABLE_TOOL_SEARCH Mode
* ENABLE_SEARCH_EXTRA_TOOLS Mode
* auto / auto:1-99 tst-auto
* true / auto:0 tst
* false / auto:100 standard
* (unset) tst (default: always defer MCP and shouldDefer tools)
* (unset) tst (default: always defer non-core tools)
*/
export function getToolSearchMode(): ToolSearchMode {
// CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS is a kill switch for beta API
// features. Tool search emits defer_loading on tool definitions and
// tool_reference content blocks — both require the API to accept a beta
// header. When the kill switch is set, force 'standard' so no beta shapes
// reach the wire, even if ENABLE_TOOL_SEARCH is also set. This is the
// explicit escape hatch for proxy gateways that the heuristic in
// isToolSearchEnabledOptimistic doesn't cover.
// github.com/anthropics/claude-code/issues/20031
export function getSearchExtraToolsMode(): SearchExtraToolsMode {
// CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS still acts as a kill switch
// for tool search, even though we no longer send beta headers.
// Users who set this flag explicitly opt out of tool search.
if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)) {
return 'standard'
}
const value = process.env.ENABLE_TOOL_SEARCH
const value = process.env.ENABLE_SEARCH_EXTRA_TOOLS
// Handle auto:N syntax - check edge cases first
const autoPercent = value ? parseAutoPercentage(value) : null
if (autoPercent === 0) return 'tst' // auto:0 = always enabled
if (autoPercent === 100) return 'standard'
if (isAutoToolSearchMode(value)) {
if (isAutoSearchExtraToolsMode(value)) {
return 'tst-auto' // auto or auto:1-99
}
if (isEnvTruthy(value)) return 'tst'
if (isEnvDefinedFalsy(process.env.ENABLE_TOOL_SEARCH)) return 'standard'
return 'tst' // default: always defer MCP and shouldDefer tools
}
/**
* Default patterns for models that do NOT support tool_reference.
* New models are assumed to support tool_reference unless explicitly listed here.
*/
const DEFAULT_UNSUPPORTED_MODEL_PATTERNS = ['haiku']
/**
* Get the list of model patterns that do NOT support tool_reference.
* Can be configured via GrowthBook for live updates without code changes.
*/
function getUnsupportedToolReferencePatterns(): string[] {
try {
// Try to get from GrowthBook for live configuration
const patterns = getFeatureValue_CACHED_MAY_BE_STALE<string[] | null>(
'tengu_tool_search_unsupported_models',
null,
)
if (patterns && Array.isArray(patterns) && patterns.length > 0) {
return patterns
}
} catch {
// GrowthBook not ready, use defaults
}
return DEFAULT_UNSUPPORTED_MODEL_PATTERNS
}
/**
* Check if a model supports tool_reference blocks (required for tool search).
*
* This uses a negative test: models are assumed to support tool_reference
* UNLESS they match a pattern in the unsupported list. This ensures new
* models work by default without code changes.
*
* Currently, Haiku models do NOT support tool_reference. This can be
* updated via GrowthBook feature 'tengu_tool_search_unsupported_models'.
*
* @param model The model name to check
* @returns true if the model supports tool_reference, false otherwise
*/
export function modelSupportsToolReference(model: string): boolean {
const normalizedModel = model.toLowerCase()
const unsupportedPatterns = getUnsupportedToolReferencePatterns()
// Check if model matches any unsupported pattern
for (const pattern of unsupportedPatterns) {
if (normalizedModel.includes(pattern.toLowerCase())) {
return false
}
}
// New models are assumed to support tool_reference
return true
if (isEnvDefinedFalsy(process.env.ENABLE_SEARCH_EXTRA_TOOLS))
return 'standard'
return 'tst' // default: always defer non-core tools
}
/**
* Check if tool search *might* be enabled (optimistic check).
*
* Returns true if tool search could potentially be enabled, without checking
* dynamic factors like model support or threshold. Use this for:
* - Including ToolSearchTool in base tools (so it's available if needed)
* - Preserving tool_reference fields in messages (can be stripped later)
* - Checking if ToolSearchTool should report itself as enabled
* dynamic factors like threshold. Use this for:
* - Including SearchExtraToolsTool in base tools (so it's available if needed)
* - Checking if SearchExtraToolsTool should report itself as enabled
*
* Returns false only when tool search is definitively disabled (standard mode).
*
* For the definitive check that includes model support and threshold,
* use isToolSearchEnabled().
* For the definitive check that includes threshold, use isSearchExtraToolsEnabled().
*/
let loggedOptimistic = false
export function isToolSearchEnabledOptimistic(): boolean {
const mode = getToolSearchMode()
export function isSearchExtraToolsEnabledOptimistic(): boolean {
const mode = getSearchExtraToolsMode()
if (mode === 'standard') {
if (!loggedOptimistic) {
loggedOptimistic = true
logForDebugging(
`[ToolSearch:optimistic] mode=${mode}, ENABLE_TOOL_SEARCH=${process.env.ENABLE_TOOL_SEARCH}, result=false`,
`[SearchExtraTools:optimistic] mode=${mode}, ENABLE_SEARCH_EXTRA_TOOLS=${process.env.ENABLE_SEARCH_EXTRA_TOOLS}, result=false`,
)
}
return false
}
// tool_reference is a beta content type that third-party API gateways
// (ANTHROPIC_BASE_URL proxies) typically don't support. When the provider
// is 'firstParty' but the base URL points elsewhere, the proxy will reject
// tool_reference blocks with a 400. Vertex/Bedrock/Foundry are unaffected —
// they have their own endpoints and beta headers.
// https://github.com/anthropics/claude-code/issues/30912
//
// HOWEVER: some proxies DO support tool_reference (LiteLLM passthrough,
// Cloudflare AI Gateway, corp gateways that forward beta headers). The
// blanket disable breaks defer_loading for those users — all MCP tools
// loaded into main context instead of on-demand (gh-31936 / CC-457,
// likely the real cause of CC-330 "v2.1.70 defer_loading regression").
// This gate only applies when ENABLE_TOOL_SEARCH is unset/empty (default
// behavior). Setting any non-empty value — 'true', 'auto', 'auto:N' —
// means the user is explicitly configuring tool search and asserts their
// setup supports it. The falsy check (rather than === undefined) aligns
// with getToolSearchMode(), which also treats "" as unset.
if (
!process.env.ENABLE_TOOL_SEARCH &&
getAPIProvider() === 'firstParty' &&
!isFirstPartyAnthropicBaseUrl()
) {
if (!loggedOptimistic) {
loggedOptimistic = true
logForDebugging(
`[ToolSearch:optimistic] disabled: ANTHROPIC_BASE_URL=${process.env.ANTHROPIC_BASE_URL} is not a first-party Anthropic host. Set ENABLE_TOOL_SEARCH=true (or auto / auto:N) if your proxy forwards tool_reference blocks.`,
)
}
return false
}
// All providers use the unified self-built tool search (TF-IDF + keyword).
// No first-party / tool_reference / defer_loading distinction.
// Users can still disable via ENABLE_SEARCH_EXTRA_TOOLS=false.
if (!loggedOptimistic) {
loggedOptimistic = true
logForDebugging(
`[ToolSearch:optimistic] mode=${mode}, ENABLE_TOOL_SEARCH=${process.env.ENABLE_TOOL_SEARCH}, result=true`,
`[SearchExtraTools:optimistic] mode=${mode}, ENABLE_SEARCH_EXTRA_TOOLS=${process.env.ENABLE_SEARCH_EXTRA_TOOLS}, result=true`,
)
}
return true
}
/**
* Check if ToolSearchTool is available in the provided tools list.
* If ToolSearchTool is not available (e.g., disallowed via disallowedTools),
* Check if SearchExtraToolsTool is available in the provided tools list.
* If SearchExtraToolsTool is not available (e.g., disallowed via disallowedTools),
* tool search cannot function and should be disabled.
*
* @param tools Array of tools with a 'name' property
* @returns true if ToolSearchTool is in the tools list, false otherwise
* @returns true if SearchExtraToolsTool is in the tools list, false otherwise
*/
export function isToolSearchToolAvailable(
export function isSearchExtraToolsToolAvailable(
tools: readonly { name: string }[],
): boolean {
return tools.some(tool => toolMatchesName(tool, TOOL_SEARCH_TOOL_NAME))
return tools.some(tool => toolMatchesName(tool, SEARCH_EXTRA_TOOLS_TOOL_NAME))
}
/**
@@ -370,19 +281,19 @@ async function calculateDeferredToolDescriptionChars(
* This is the definitive check that includes:
* - MCP mode (Tst, TstAuto, McpCli, Standard)
* - Model compatibility (haiku doesn't support tool_reference)
* - ToolSearchTool availability (must be in tools list)
* - SearchExtraToolsTool availability (must be in tools list)
* - Threshold check for TstAuto mode
*
* Use this when making actual API calls where all context is available.
*
* @param model The model to check for tool_reference support
* @param model The model being used (kept for API compatibility)
* @param tools Array of available tools (including MCP tools)
* @param getToolPermissionContext Function to get tool permission context
* @param agents Array of agent definitions
* @param source Optional identifier for the caller (for debugging)
* @returns true if tool search should be enabled for this request
*/
export async function isToolSearchEnabled(
export async function isSearchExtraToolsEnabled(
model: string,
tools: Tools,
getToolPermissionContext: () => Promise<ToolPermissionContext>,
@@ -394,11 +305,11 @@ export async function isToolSearchEnabled(
// Helper to log the mode decision event
function logModeDecision(
enabled: boolean,
mode: ToolSearchMode,
mode: SearchExtraToolsMode,
reason: string,
extraProps?: Record<string, number>,
): void {
logEvent('tengu_tool_search_mode_decision', {
logEvent('tengu_search_extra_tools_mode_decision', {
enabled,
mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
reason:
@@ -415,26 +326,19 @@ export async function isToolSearchEnabled(
})
}
// Check if model supports tool_reference
if (!modelSupportsToolReference(model)) {
logForDebugging(
`Tool search disabled for model '${model}': model does not support tool_reference blocks. ` +
`This feature is only available on Claude Sonnet 4+, Opus 4+, and newer models.`,
)
logModeDecision(false, 'standard', 'model_unsupported')
return false
}
// Tool search is enabled uniformly regardless of provider or model.
// All providers use self-built TF-IDF + keyword search via SearchExtraToolsTool + ExecuteExtraTool.
// Check if ToolSearchTool is available (respects disallowedTools)
if (!isToolSearchToolAvailable(tools)) {
// Check if SearchExtraToolsTool is available (respects disallowedTools)
if (!isSearchExtraToolsToolAvailable(tools)) {
logForDebugging(
`Tool search disabled: ToolSearchTool is not available (may have been disallowed via disallowedTools).`,
`Tool search disabled: SearchExtraToolsTool is not available (may have been disallowed via disallowedTools).`,
)
logModeDecision(false, 'standard', 'mcp_search_unavailable')
return false
}
const mode = getToolSearchMode()
const mode = getSearchExtraToolsMode()
switch (mode) {
case 'tst':
@@ -500,13 +404,22 @@ function isToolReferenceWithName(
/**
* Type representing a tool_result block with array content.
* Used for extracting tool_reference blocks from ToolSearchTool results.
* Used for extracting tool_reference blocks from SearchExtraToolsTool results.
*/
type ToolResultBlock = {
type: 'tool_result'
content: unknown[]
}
/**
* Type representing a tool_result block with string content.
* Used for extracting tool names from SearchExtraToolsTool text output.
*/
type ToolResultBlockWithStringContent = {
type: 'tool_result'
content: string
}
/**
* Type guard for tool_result blocks with array content.
*/
@@ -522,25 +435,56 @@ function isToolResultBlockWithContent(obj: unknown): obj is ToolResultBlock {
}
/**
* Extract tool names from tool_reference blocks in message history.
* Type guard for tool_result blocks with string content.
*/
function isToolResultBlockWithStringContent(
obj: unknown,
): obj is ToolResultBlockWithStringContent {
return (
typeof obj === 'object' &&
obj !== null &&
'type' in obj &&
(obj as { type: unknown }).type === 'tool_result' &&
'content' in obj &&
typeof (obj as { content: unknown }).content === 'string'
)
}
/**
* Regex to extract tool names from SearchExtraToolsTool text output.
* Matches: "Found N deferred tool(s): ToolA, mcp.server.ToolB."
* Uses multiline + end-of-line anchor so dots inside tool names (e.g. mcp__s__t) don't break parsing.
*/
const DISCOVERED_TOOLS_PATTERN = /^Found \d+ deferred tool\(s\): (.+)\.$/m
/**
* Extract tool names from SearchExtraToolsTool text output.
* Format: "Found N deferred tool(s): ToolA, ToolB.\n..."
*/
function extractToolNamesFromText(text: string): string[] {
const match = DISCOVERED_TOOLS_PATTERN.exec(text)
if (!match?.[1]) return []
return match[1]
.split(',')
.map(name => name.trim())
.filter(Boolean)
}
/**
* Extract tool names from SearchExtraToolsTool results in message history.
*
* When dynamic tool loading is enabled, MCP tools are not predeclared in the
* tools array. Instead, they are discovered via ToolSearchTool which returns
* tool_reference blocks. This function scans the message history to find all
* tool names that have been referenced, so we can include only those tools
* in subsequent API requests.
* Supports two formats:
* 1. Legacy tool_reference blocks (backward compat with old sessions)
* 2. Text output from unified self-built tool search
*
* This approach:
* - Eliminates the need to predeclare all MCP tools upfront
* - Removes limits on total quantity of MCP tools
* Discovered tool names are used to include deferred tools in subsequent
* API requests so the model can call them directly.
*
* Compaction replaces tool_reference-bearing messages with a summary, so it
* snapshots the discovered set onto compactMetadata.preCompactDiscoveredTools
* on the boundary marker; this scan reads it back. Snip instead protects the
* tool_reference-carrying messages from removal.
* Compaction snapshots the discovered set onto
* compactMetadata.preCompactDiscoveredTools on the boundary marker.
*
* @param messages Array of messages that may contain tool_result blocks with tool_reference content
* @returns Set of tool names that have been discovered via tool_reference blocks
* @param messages Array of messages that may contain tool_result blocks
* @returns Set of tool names that have been discovered
*/
export function extractDiscoveredToolNames(messages: Message[]): Set<string> {
const discoveredTools = new Set<string>()
@@ -561,6 +505,18 @@ export function extractDiscoveredToolNames(messages: Message[]): Set<string> {
continue
}
// Deferred-tools-delta attachments announce tools that the model should
// see as available. Include their addedNames so the filter in claude.ts
// keeps the corresponding tool schemas in the API request.
if (
msg.type === 'attachment' &&
(msg as any).attachment?.type === 'deferred_tools_delta'
) {
const added: string[] = (msg as any).attachment.addedNames ?? []
for (const name of added) discoveredTools.add(name)
continue
}
// Only user messages contain tool_result blocks (responses to tool_use)
if (msg.type !== 'user') continue
@@ -568,9 +524,7 @@ export function extractDiscoveredToolNames(messages: Message[]): Set<string> {
if (!Array.isArray(content)) continue
for (const block of content) {
// tool_reference blocks only appear inside tool_result content, specifically
// in results from ToolSearchTool. The API expands these references into full
// tool definitions in the model's context.
// Legacy: tool_reference blocks from old sessions (backward compat)
if (isToolResultBlockWithContent(block)) {
for (const item of block.content) {
if (isToolReferenceWithName(item)) {
@@ -578,6 +532,14 @@ export function extractDiscoveredToolNames(messages: Message[]): Set<string> {
}
}
}
// Unified self-built search: text output from SearchExtraToolsTool
if (isToolResultBlockWithStringContent(block)) {
const names = extractToolNamesFromText(block.content)
for (const name of names) {
discoveredTools.add(name)
}
}
}
}
@@ -730,12 +692,12 @@ async function checkAutoThreshold(
)
if (deferredToolTokens !== null) {
const threshold = getAutoToolSearchTokenThreshold(model)
const threshold = getAutoSearchExtraToolsTokenThreshold(model)
return {
enabled: deferredToolTokens >= threshold,
debugDescription:
`${deferredToolTokens} tokens (threshold: ${threshold}, ` +
`${getAutoToolSearchPercentage()}% of context)`,
`${getAutoSearchExtraToolsPercentage()}% of context)`,
metrics: { deferredToolTokens, threshold },
}
}
@@ -747,12 +709,12 @@ async function checkAutoThreshold(
getToolPermissionContext,
agents,
)
const charThreshold = getAutoToolSearchCharThreshold(model)
const charThreshold = getAutoSearchExtraToolsCharThreshold(model)
return {
enabled: deferredToolDescriptionChars >= charThreshold,
debugDescription:
`${deferredToolDescriptionChars} chars (threshold: ${charThreshold}, ` +
`${getAutoToolSearchPercentage()}% of context) (char fallback)`,
`${getAutoSearchExtraToolsPercentage()}% of context) (char fallback)`,
metrics: { deferredToolDescriptionChars, charThreshold },
}
}