2026-03-31 19:22:47 +08:00
/ * *
* 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 .
* /
import memoize from 'lodash-es/memoize.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS ,
logEvent ,
} from '../services/analytics/index.js'
import type { Tool } from '../Tool.js'
import {
type ToolPermissionContext ,
type Tools ,
toolMatchesName ,
} from '../Tool.js'
import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'
import {
formatDeferredToolLine ,
isDeferredTool ,
TOOL_SEARCH_TOOL_NAME ,
} from '../tools/ToolSearchTool/prompt.js'
import type { Message } from '../types/message.js'
import {
countToolDefinitionTokens ,
TOOL_TOKEN_COUNT_OVERHEAD ,
} from './analyzeContext.js'
import { count } from './array.js'
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 .
* /
const DEFAULT_AUTO_TOOL_SEARCH_PERCENTAGE = 10 // 10%
/ * *
* Parse auto :N syntax from ENABLE_TOOL_SEARCH 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 {
if ( ! value . startsWith ( 'auto:' ) ) return null
const percentStr = value . slice ( 5 )
const percent = parseInt ( percentStr , 10 )
if ( isNaN ( percent ) ) {
logForDebugging (
` Invalid ENABLE_TOOL_SEARCH value " ${ value } ": expected auto:N where N is a number. ` ,
)
return null
}
// Clamp to valid range
return Math . max ( 0 , Math . min ( 100 , percent ) )
}
/ * *
* Check if ENABLE_TOOL_SEARCH is set to auto mode ( auto or auto :N ) .
* /
function isAutoToolSearchMode ( value : string | undefined ) : boolean {
if ( ! value ) return false
return value === 'auto' || value . startsWith ( 'auto:' )
}
/ * *
* 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
if ( value === 'auto' ) return DEFAULT_AUTO_TOOL_SEARCH_PERCENTAGE
const parsed = parseAutoPercentage ( value )
if ( parsed !== null ) return parsed
return DEFAULT_AUTO_TOOL_SEARCH_PERCENTAGE
}
/ * *
* Approximate chars per token for MCP tool definitions ( name + description + input schema ) .
* Used as fallback when the token counting API is unavailable .
* /
const CHARS_PER_TOKEN = 2.5
/ * *
* Get the token threshold for auto - enabling tool search for a given model .
* /
function getAutoToolSearchTokenThreshold ( model : string ) : number {
const betas = getMergedBetas ( model )
const contextWindow = getContextWindowForModel ( model , betas )
const percentage = getAutoToolSearchPercentage ( ) / 100
return Math . floor ( contextWindow * percentage )
}
/ * *
* 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 )
}
/ * *
* Get the total token count for all deferred tools using the token counting API .
* Memoized by deferred tool names — cache is invalidated when MCP servers connect / disconnect .
* Returns null if the API is unavailable ( caller should fall back to char heuristic ) .
* /
const getDeferredToolTokenCount = memoize (
async (
tools : Tools ,
getToolPermissionContext : ( ) = > Promise < ToolPermissionContext > ,
agents : AgentDefinition [ ] ,
model : string ,
) : Promise < number | null > = > {
const deferredTools = tools . filter ( t = > isDeferredTool ( t ) )
if ( deferredTools . length === 0 ) return 0
try {
const total = await countToolDefinitionTokens (
deferredTools ,
getToolPermissionContext ,
{ activeAgents : agents , allAgents : agents } ,
model ,
)
if ( total === 0 ) return null // API unavailable
return Math . max ( 0 , total - TOOL_TOKEN_COUNT_OVERHEAD )
} catch {
return null // Fall back to char heuristic
}
} ,
( tools : Tools ) = >
tools
. filter ( t = > isDeferredTool ( t ) )
. map ( t = > t . name )
. join ( ',' ) ,
)
/ * *
* Tool search mode . Determines how deferrable tools ( MCP + shouldDefer ) are
* surfaced :
* - 'tst' : Tool Search Tool — deferred tools discovered via ToolSearchTool ( 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'
/ * *
* Determines the tool search mode from ENABLE_TOOL_SEARCH .
*
* ENABLE_TOOL_SEARCH Mode
* auto / auto :1 - 99 tst - auto
* true / auto :0 tst
* false / auto :100 standard
* ( unset ) tst ( default : always defer MCP and shouldDefer 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
if ( isEnvTruthy ( process . env . CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS ) ) {
return 'standard'
}
const value = process . env . ENABLE_TOOL_SEARCH
// 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 ) ) {
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
}
/ * *
* 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
*
* Returns false only when tool search is definitively disabled ( standard mode ) .
*
* For the definitive check that includes model support and threshold ,
* use isToolSearchEnabled ( ) .
* /
let loggedOptimistic = false
export function isToolSearchEnabledOptimistic ( ) : boolean {
const mode = getToolSearchMode ( )
if ( mode === 'standard' ) {
if ( ! loggedOptimistic ) {
loggedOptimistic = true
logForDebugging (
` [ToolSearch:optimistic] mode= ${ mode } , ENABLE_TOOL_SEARCH= ${ process . env . ENABLE_TOOL_SEARCH } , 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
}
if ( ! loggedOptimistic ) {
loggedOptimistic = true
logForDebugging (
` [ToolSearch:optimistic] mode= ${ mode } , ENABLE_TOOL_SEARCH= ${ process . env . ENABLE_TOOL_SEARCH } , result=true ` ,
)
}
return true
}
/ * *
* Check if ToolSearchTool is available in the provided tools list .
* If ToolSearchTool 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
* /
export function isToolSearchToolAvailable (
tools : readonly { name : string } [ ] ,
) : boolean {
return tools . some ( tool = > toolMatchesName ( tool , TOOL_SEARCH_TOOL_NAME ) )
}
/ * *
* Calculate total deferred tool description size in characters .
* Includes name , description text , and input schema to match what ' s actually sent to the API .
* /
async function calculateDeferredToolDescriptionChars (
tools : Tools ,
getToolPermissionContext : ( ) = > Promise < ToolPermissionContext > ,
agents : AgentDefinition [ ] ,
) : Promise < number > {
const deferredTools = tools . filter ( t = > isDeferredTool ( t ) )
if ( deferredTools . length === 0 ) return 0
const sizes = await Promise . all (
deferredTools . map ( async tool = > {
const description = await tool . prompt ( {
getToolPermissionContext ,
tools ,
agents ,
} )
const inputSchema = tool . inputJSONSchema
? jsonStringify ( tool . inputJSONSchema )
: tool . inputSchema
? jsonStringify ( zodToJsonSchema ( tool . inputSchema ) )
: ''
return tool . name . length + description . length + inputSchema . length
} ) ,
)
return sizes . reduce ( ( total , size ) = > total + size , 0 )
}
/ * *
* Check if tool search ( MCP tool deferral with tool_reference ) is enabled for a specific request .
*
* 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 )
* - 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 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 (
model : string ,
tools : Tools ,
getToolPermissionContext : ( ) = > Promise < ToolPermissionContext > ,
agents : AgentDefinition [ ] ,
source? : string ,
) : Promise < boolean > {
const mcpToolCount = count ( tools , t = > t . isMcp )
// Helper to log the mode decision event
function logModeDecision (
enabled : boolean ,
mode : ToolSearchMode ,
reason : string ,
extraProps? : Record < string , number > ,
) : void {
logEvent ( 'tengu_tool_search_mode_decision' , {
enabled ,
mode : mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS ,
reason :
reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS ,
// Log the actual model being checked, not the session's main model.
// This is important for debugging subagent tool search decisions where
// the subagent model (e.g., haiku) differs from the session model (e.g., opus).
checkedModel :
model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS ,
mcpToolCount ,
userType : ( process . env . USER_TYPE ? ?
'external' ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS ,
. . . extraProps ,
} )
}
// 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
}
// Check if ToolSearchTool is available (respects disallowedTools)
if ( ! isToolSearchToolAvailable ( tools ) ) {
logForDebugging (
` Tool search disabled: ToolSearchTool is not available (may have been disallowed via disallowedTools). ` ,
)
logModeDecision ( false , 'standard' , 'mcp_search_unavailable' )
return false
}
const mode = getToolSearchMode ( )
switch ( mode ) {
case 'tst' :
logModeDecision ( true , mode , 'tst_enabled' )
return true
case 'tst-auto' : {
const { enabled , debugDescription , metrics } = await checkAutoThreshold (
tools ,
getToolPermissionContext ,
agents ,
model ,
)
if ( enabled ) {
logForDebugging (
` Auto tool search enabled: ${ debugDescription } ` +
( source ? ` [source: ${ source } ] ` : '' ) ,
)
logModeDecision ( true , mode , 'auto_above_threshold' , metrics )
return true
}
logForDebugging (
` Auto tool search disabled: ${ debugDescription } ` +
( source ? ` [source: ${ source } ] ` : '' ) ,
)
logModeDecision ( false , mode , 'auto_below_threshold' , metrics )
return false
}
case 'standard' :
logModeDecision ( false , mode , 'standard_mode' )
return false
}
}
/ * *
* Check if an object is a tool_reference block .
* tool_reference is a beta feature not in the SDK types , so we need runtime checks .
* /
export function isToolReferenceBlock ( obj : unknown ) : boolean {
return (
typeof obj === 'object' &&
obj !== null &&
'type' in obj &&
( obj as { type : unknown } ) . type === 'tool_reference'
)
}
/ * *
* Type guard for tool_reference block with tool_name .
* /
function isToolReferenceWithName (
obj : unknown ,
) : obj is { type : 'tool_reference' ; tool_name : string } {
return (
isToolReferenceBlock ( obj ) &&
'tool_name' in ( obj as object ) &&
typeof ( obj as { tool_name : unknown } ) . tool_name === 'string'
)
}
/ * *
* Type representing a tool_result block with array content .
* Used for extracting tool_reference blocks from ToolSearchTool results .
* /
type ToolResultBlock = {
type : 'tool_result'
content : unknown [ ]
}
/ * *
* Type guard for tool_result blocks with array content .
* /
function isToolResultBlockWithContent ( obj : unknown ) : obj is ToolResultBlock {
return (
typeof obj === 'object' &&
obj !== null &&
'type' in obj &&
( obj as { type : unknown } ) . type === 'tool_result' &&
'content' in obj &&
Array . isArray ( ( obj as { content : unknown } ) . content )
)
}
/ * *
* Extract tool names from tool_reference blocks 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 .
*
* This approach :
* - Eliminates the need to predeclare all MCP tools upfront
* - Removes limits on total quantity of MCP tools
*
* 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 .
*
* @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
* /
export function extractDiscoveredToolNames ( messages : Message [ ] ) : Set < string > {
const discoveredTools = new Set < string > ( )
let carriedFromBoundary = 0
for ( const msg of messages ) {
// Compact boundary carries the pre-compact discovered set. Inline type
// check rather than isCompactBoundaryMessage — utils/messages.ts imports
// from this file, so importing back would be circular.
if ( msg . type === 'system' && msg . subtype === 'compact_boundary' ) {
2026-03-31 23:32:58 +08:00
const carried = ( msg as any ) . compactMetadata ? . preCompactDiscoveredTools as string [ ] | undefined
2026-03-31 19:22:47 +08:00
if ( carried ) {
for ( const name of carried ) discoveredTools . add ( name )
carriedFromBoundary += carried . length
}
continue
}
// Only user messages contain tool_result blocks (responses to tool_use)
if ( msg . type !== 'user' ) continue
const content = msg . message ? . content
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.
if ( isToolResultBlockWithContent ( block ) ) {
for ( const item of block . content ) {
if ( isToolReferenceWithName ( item ) ) {
discoveredTools . add ( item . tool_name )
}
}
}
}
}
if ( discoveredTools . size > 0 ) {
logForDebugging (
` Dynamic tool loading: found ${ discoveredTools . size } discovered tools in message history ` +
( carriedFromBoundary > 0
? ` ( ${ carriedFromBoundary } carried from compact boundary) `
: '' ) ,
)
}
return discoveredTools
}
export type DeferredToolsDelta = {
addedNames : string [ ]
/** Rendered lines for addedNames; the scan reconstructs from names. */
addedLines : string [ ]
removedNames : string [ ]
}
/ * *
* Call - site discriminator for the tengu_deferred_tools_pool_change event .
* The scan runs from several sites with different expected - prior semantics
* ( inc - 4747 ) :
* - attachments_main : main - thread getAttachments → prior = 0 is a BUG on fire - 2 +
* - attachments_subagent : subagent getAttachments → prior = 0 is EXPECTED
* ( fresh conversation , initialMessages has no DTD )
* - compact_full : compact.ts passes [ ] → prior = 0 is EXPECTED
* - compact_partial : compact.ts passes messagesToKeep → depends on what survived
* - reactive_compact : reactiveCompact.ts passes preservedMessages → same
* Without this the 96 % - prior = 0 stat is dominated by EXPECTED buckets and
* the real main - thread cross - turn bug ( if any ) is invisible in BQ .
* /
export type DeferredToolsDeltaScanContext = {
callSite :
| 'attachments_main'
| 'attachments_subagent'
| 'compact_full'
| 'compact_partial'
| 'reactive_compact'
querySource? : string
}
/ * *
* True → announce deferred tools via persisted delta attachments .
* False → claude . ts keeps its per - call < available - deferred - tools >
* header prepend ( the attachment does not fire ) .
* /
export function isDeferredToolsDeltaEnabled ( ) : boolean {
return (
process . env . USER_TYPE === 'ant' ||
getFeatureValue_CACHED_MAY_BE_STALE ( 'tengu_glacier_2xr' , false )
)
}
/ * *
* Diff the current deferred - tool pool against what ' s already been
* announced in this conversation ( reconstructed by scanning for prior
* deferred_tools_delta attachments ) . Returns null if nothing changed .
*
* A name that was announced but has since stopped being deferred — yet
* is still in the base pool — is NOT reported as removed . It ' s now
* loaded directly , so telling the model "no longer available" would be
* wrong .
* /
export function getDeferredToolsDelta (
tools : Tools ,
messages : Message [ ] ,
scanContext? : DeferredToolsDeltaScanContext ,
) : DeferredToolsDelta | null {
const announced = new Set < string > ( )
let attachmentCount = 0
let dtdCount = 0
const attachmentTypesSeen = new Set < string > ( )
for ( const msg of messages ) {
if ( msg . type !== 'attachment' ) continue
attachmentCount ++
attachmentTypesSeen . add ( msg . attachment . type )
if ( msg . attachment . type !== 'deferred_tools_delta' ) continue
dtdCount ++
2026-03-31 23:32:58 +08:00
for ( const n of ( msg . attachment as any ) . addedNames ) announced . add ( n )
for ( const n of ( msg . attachment as any ) . removedNames ) announced . delete ( n )
2026-03-31 19:22:47 +08:00
}
const deferred : Tool [ ] = tools . filter ( isDeferredTool )
const deferredNames = new Set ( deferred . map ( t = > t . name ) )
const poolNames = new Set ( tools . map ( t = > t . name ) )
const added = deferred . filter ( t = > ! announced . has ( t . name ) )
const removed : string [ ] = [ ]
for ( const n of announced ) {
if ( deferredNames . has ( n ) ) continue
if ( ! poolNames . has ( n ) ) removed . push ( n )
// else: undeferred — silent
}
if ( added . length === 0 && removed . length === 0 ) return null
// Diagnostic for the inc-4747 scan-finds-nothing bug. Round-1 fields
// (messagesLength/attachmentCount/dtdCount from #23167) showed 45.6% of
// events have attachments-but-no-DTD, but those numbers are confounded:
// subagent first-fires and compact-path scans have EXPECTED prior=0 and
// dominate the stat. callSite/querySource/attachmentTypesSeen split the
// buckets so the real main-thread cross-turn failure is isolable in BQ.
logEvent ( 'tengu_deferred_tools_pool_change' , {
addedCount : added.length ,
removedCount : removed.length ,
priorAnnouncedCount : announced.size ,
messagesLength : messages.length ,
attachmentCount ,
dtdCount ,
callSite : ( scanContext ? . callSite ? ?
'unknown' ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS ,
querySource : ( scanContext ? . querySource ? ?
'unknown' ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS ,
attachmentTypesSeen : [ . . . attachmentTypesSeen ]
. sort ( )
. join ( ',' ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS ,
} )
return {
addedNames : added.map ( t = > t . name ) . sort ( ) ,
addedLines : added.map ( formatDeferredToolLine ) . sort ( ) ,
removedNames : removed.sort ( ) ,
}
}
/ * *
* Check whether deferred tools exceed the auto - threshold for enabling TST .
* Tries exact token count first ; falls back to character - based heuristic .
* /
async function checkAutoThreshold (
tools : Tools ,
getToolPermissionContext : ( ) = > Promise < ToolPermissionContext > ,
agents : AgentDefinition [ ] ,
model : string ,
) : Promise < {
enabled : boolean
debugDescription : string
metrics : Record < string , number >
} > {
// Try exact token count first (cached, one API call per toolset change)
const deferredToolTokens = await getDeferredToolTokenCount (
tools ,
getToolPermissionContext ,
agents ,
model ,
)
if ( deferredToolTokens !== null ) {
const threshold = getAutoToolSearchTokenThreshold ( model )
return {
enabled : deferredToolTokens >= threshold ,
debugDescription :
` ${ deferredToolTokens } tokens (threshold: ${ threshold } , ` +
` ${ getAutoToolSearchPercentage ( ) } % of context) ` ,
metrics : { deferredToolTokens , threshold } ,
}
}
// Fallback: character-based heuristic when token API is unavailable
const deferredToolDescriptionChars =
await calculateDeferredToolDescriptionChars (
tools ,
getToolPermissionContext ,
agents ,
)
const charThreshold = getAutoToolSearchCharThreshold ( model )
return {
enabled : deferredToolDescriptionChars >= charThreshold ,
debugDescription :
` ${ deferredToolDescriptionChars } chars (threshold: ${ charThreshold } , ` +
` ${ getAutoToolSearchPercentage ( ) } % of context) (char fallback) ` ,
metrics : { deferredToolDescriptionChars , charThreshold } ,
}
}