source dump of claude code
at main 756 lines 27 kB view raw
1/** 2 * Tool Search utilities for dynamically discovering deferred tools. 3 * 4 * When enabled, deferred tools (MCP and shouldDefer tools) are sent with 5 * defer_loading: true and discovered via ToolSearchTool rather than being 6 * loaded upfront. 7 */ 8 9import memoize from 'lodash-es/memoize.js' 10import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' 11import { 12 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 13 logEvent, 14} from '../services/analytics/index.js' 15import type { Tool } from '../Tool.js' 16import { 17 type ToolPermissionContext, 18 type Tools, 19 toolMatchesName, 20} from '../Tool.js' 21import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js' 22import { 23 formatDeferredToolLine, 24 isDeferredTool, 25 TOOL_SEARCH_TOOL_NAME, 26} from '../tools/ToolSearchTool/prompt.js' 27import type { Message } from '../types/message.js' 28import { 29 countToolDefinitionTokens, 30 TOOL_TOKEN_COUNT_OVERHEAD, 31} from './analyzeContext.js' 32import { count } from './array.js' 33import { getMergedBetas } from './betas.js' 34import { getContextWindowForModel } from './context.js' 35import { logForDebugging } from './debug.js' 36import { isEnvDefinedFalsy, isEnvTruthy } from './envUtils.js' 37import { 38 getAPIProvider, 39 isFirstPartyAnthropicBaseUrl, 40} from './model/providers.js' 41import { jsonStringify } from './slowOperations.js' 42import { zodToJsonSchema } from './zodToJsonSchema.js' 43 44/** 45 * Default percentage of context window at which to auto-enable tool search. 46 * When MCP tool descriptions exceed this percentage (in tokens), tool search is enabled. 47 * Can be overridden via ENABLE_TOOL_SEARCH=auto:N where N is 0-100. 48 */ 49const DEFAULT_AUTO_TOOL_SEARCH_PERCENTAGE = 10 // 10% 50 51/** 52 * Parse auto:N syntax from ENABLE_TOOL_SEARCH env var. 53 * Returns the percentage clamped to 0-100, or null if not auto:N format or not a number. 54 */ 55function parseAutoPercentage(value: string): number | null { 56 if (!value.startsWith('auto:')) return null 57 58 const percentStr = value.slice(5) 59 const percent = parseInt(percentStr, 10) 60 61 if (isNaN(percent)) { 62 logForDebugging( 63 `Invalid ENABLE_TOOL_SEARCH value "${value}": expected auto:N where N is a number.`, 64 ) 65 return null 66 } 67 68 // Clamp to valid range 69 return Math.max(0, Math.min(100, percent)) 70} 71 72/** 73 * Check if ENABLE_TOOL_SEARCH is set to auto mode (auto or auto:N). 74 */ 75function isAutoToolSearchMode(value: string | undefined): boolean { 76 if (!value) return false 77 return value === 'auto' || value.startsWith('auto:') 78} 79 80/** 81 * Get the auto-enable percentage from env var or default. 82 */ 83function getAutoToolSearchPercentage(): number { 84 const value = process.env.ENABLE_TOOL_SEARCH 85 if (!value) return DEFAULT_AUTO_TOOL_SEARCH_PERCENTAGE 86 87 if (value === 'auto') return DEFAULT_AUTO_TOOL_SEARCH_PERCENTAGE 88 89 const parsed = parseAutoPercentage(value) 90 if (parsed !== null) return parsed 91 92 return DEFAULT_AUTO_TOOL_SEARCH_PERCENTAGE 93} 94 95/** 96 * Approximate chars per token for MCP tool definitions (name + description + input schema). 97 * Used as fallback when the token counting API is unavailable. 98 */ 99const CHARS_PER_TOKEN = 2.5 100 101/** 102 * Get the token threshold for auto-enabling tool search for a given model. 103 */ 104function getAutoToolSearchTokenThreshold(model: string): number { 105 const betas = getMergedBetas(model) 106 const contextWindow = getContextWindowForModel(model, betas) 107 const percentage = getAutoToolSearchPercentage() / 100 108 return Math.floor(contextWindow * percentage) 109} 110 111/** 112 * Get the character threshold for auto-enabling tool search for a given model. 113 * Used as fallback when the token counting API is unavailable. 114 */ 115export function getAutoToolSearchCharThreshold(model: string): number { 116 return Math.floor(getAutoToolSearchTokenThreshold(model) * CHARS_PER_TOKEN) 117} 118 119/** 120 * Get the total token count for all deferred tools using the token counting API. 121 * Memoized by deferred tool names — cache is invalidated when MCP servers connect/disconnect. 122 * Returns null if the API is unavailable (caller should fall back to char heuristic). 123 */ 124const getDeferredToolTokenCount = memoize( 125 async ( 126 tools: Tools, 127 getToolPermissionContext: () => Promise<ToolPermissionContext>, 128 agents: AgentDefinition[], 129 model: string, 130 ): Promise<number | null> => { 131 const deferredTools = tools.filter(t => isDeferredTool(t)) 132 if (deferredTools.length === 0) return 0 133 134 try { 135 const total = await countToolDefinitionTokens( 136 deferredTools, 137 getToolPermissionContext, 138 { activeAgents: agents, allAgents: agents }, 139 model, 140 ) 141 if (total === 0) return null // API unavailable 142 return Math.max(0, total - TOOL_TOKEN_COUNT_OVERHEAD) 143 } catch { 144 return null // Fall back to char heuristic 145 } 146 }, 147 (tools: Tools) => 148 tools 149 .filter(t => isDeferredTool(t)) 150 .map(t => t.name) 151 .join(','), 152) 153 154/** 155 * Tool search mode. Determines how deferrable tools (MCP + shouldDefer) are 156 * surfaced: 157 * - 'tst': Tool Search Tool — deferred tools discovered via ToolSearchTool (always enabled) 158 * - 'tst-auto': auto — tools deferred only when they exceed threshold 159 * - 'standard': tool search disabled — all tools exposed inline 160 */ 161export type ToolSearchMode = 'tst' | 'tst-auto' | 'standard' 162 163/** 164 * Determines the tool search mode from ENABLE_TOOL_SEARCH. 165 * 166 * ENABLE_TOOL_SEARCH Mode 167 * auto / auto:1-99 tst-auto 168 * true / auto:0 tst 169 * false / auto:100 standard 170 * (unset) tst (default: always defer MCP and shouldDefer tools) 171 */ 172export function getToolSearchMode(): ToolSearchMode { 173 // CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS is a kill switch for beta API 174 // features. Tool search emits defer_loading on tool definitions and 175 // tool_reference content blocks — both require the API to accept a beta 176 // header. When the kill switch is set, force 'standard' so no beta shapes 177 // reach the wire, even if ENABLE_TOOL_SEARCH is also set. This is the 178 // explicit escape hatch for proxy gateways that the heuristic in 179 // isToolSearchEnabledOptimistic doesn't cover. 180 // github.com/anthropics/claude-code/issues/20031 181 if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)) { 182 return 'standard' 183 } 184 185 const value = process.env.ENABLE_TOOL_SEARCH 186 187 // Handle auto:N syntax - check edge cases first 188 const autoPercent = value ? parseAutoPercentage(value) : null 189 if (autoPercent === 0) return 'tst' // auto:0 = always enabled 190 if (autoPercent === 100) return 'standard' 191 if (isAutoToolSearchMode(value)) { 192 return 'tst-auto' // auto or auto:1-99 193 } 194 195 if (isEnvTruthy(value)) return 'tst' 196 if (isEnvDefinedFalsy(process.env.ENABLE_TOOL_SEARCH)) return 'standard' 197 return 'tst' // default: always defer MCP and shouldDefer tools 198} 199 200/** 201 * Default patterns for models that do NOT support tool_reference. 202 * New models are assumed to support tool_reference unless explicitly listed here. 203 */ 204const DEFAULT_UNSUPPORTED_MODEL_PATTERNS = ['haiku'] 205 206/** 207 * Get the list of model patterns that do NOT support tool_reference. 208 * Can be configured via GrowthBook for live updates without code changes. 209 */ 210function getUnsupportedToolReferencePatterns(): string[] { 211 try { 212 // Try to get from GrowthBook for live configuration 213 const patterns = getFeatureValue_CACHED_MAY_BE_STALE<string[] | null>( 214 'tengu_tool_search_unsupported_models', 215 null, 216 ) 217 if (patterns && Array.isArray(patterns) && patterns.length > 0) { 218 return patterns 219 } 220 } catch { 221 // GrowthBook not ready, use defaults 222 } 223 return DEFAULT_UNSUPPORTED_MODEL_PATTERNS 224} 225 226/** 227 * Check if a model supports tool_reference blocks (required for tool search). 228 * 229 * This uses a negative test: models are assumed to support tool_reference 230 * UNLESS they match a pattern in the unsupported list. This ensures new 231 * models work by default without code changes. 232 * 233 * Currently, Haiku models do NOT support tool_reference. This can be 234 * updated via GrowthBook feature 'tengu_tool_search_unsupported_models'. 235 * 236 * @param model The model name to check 237 * @returns true if the model supports tool_reference, false otherwise 238 */ 239export function modelSupportsToolReference(model: string): boolean { 240 const normalizedModel = model.toLowerCase() 241 const unsupportedPatterns = getUnsupportedToolReferencePatterns() 242 243 // Check if model matches any unsupported pattern 244 for (const pattern of unsupportedPatterns) { 245 if (normalizedModel.includes(pattern.toLowerCase())) { 246 return false 247 } 248 } 249 250 // New models are assumed to support tool_reference 251 return true 252} 253 254/** 255 * Check if tool search *might* be enabled (optimistic check). 256 * 257 * Returns true if tool search could potentially be enabled, without checking 258 * dynamic factors like model support or threshold. Use this for: 259 * - Including ToolSearchTool in base tools (so it's available if needed) 260 * - Preserving tool_reference fields in messages (can be stripped later) 261 * - Checking if ToolSearchTool should report itself as enabled 262 * 263 * Returns false only when tool search is definitively disabled (standard mode). 264 * 265 * For the definitive check that includes model support and threshold, 266 * use isToolSearchEnabled(). 267 */ 268let loggedOptimistic = false 269 270export function isToolSearchEnabledOptimistic(): boolean { 271 const mode = getToolSearchMode() 272 if (mode === 'standard') { 273 if (!loggedOptimistic) { 274 loggedOptimistic = true 275 logForDebugging( 276 `[ToolSearch:optimistic] mode=${mode}, ENABLE_TOOL_SEARCH=${process.env.ENABLE_TOOL_SEARCH}, result=false`, 277 ) 278 } 279 return false 280 } 281 282 // tool_reference is a beta content type that third-party API gateways 283 // (ANTHROPIC_BASE_URL proxies) typically don't support. When the provider 284 // is 'firstParty' but the base URL points elsewhere, the proxy will reject 285 // tool_reference blocks with a 400. Vertex/Bedrock/Foundry are unaffected — 286 // they have their own endpoints and beta headers. 287 // https://github.com/anthropics/claude-code/issues/30912 288 // 289 // HOWEVER: some proxies DO support tool_reference (LiteLLM passthrough, 290 // Cloudflare AI Gateway, corp gateways that forward beta headers). The 291 // blanket disable breaks defer_loading for those users — all MCP tools 292 // loaded into main context instead of on-demand (gh-31936 / CC-457, 293 // likely the real cause of CC-330 "v2.1.70 defer_loading regression"). 294 // This gate only applies when ENABLE_TOOL_SEARCH is unset/empty (default 295 // behavior). Setting any non-empty value — 'true', 'auto', 'auto:N' — 296 // means the user is explicitly configuring tool search and asserts their 297 // setup supports it. The falsy check (rather than === undefined) aligns 298 // with getToolSearchMode(), which also treats "" as unset. 299 if ( 300 !process.env.ENABLE_TOOL_SEARCH && 301 getAPIProvider() === 'firstParty' && 302 !isFirstPartyAnthropicBaseUrl() 303 ) { 304 if (!loggedOptimistic) { 305 loggedOptimistic = true 306 logForDebugging( 307 `[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.`, 308 ) 309 } 310 return false 311 } 312 313 if (!loggedOptimistic) { 314 loggedOptimistic = true 315 logForDebugging( 316 `[ToolSearch:optimistic] mode=${mode}, ENABLE_TOOL_SEARCH=${process.env.ENABLE_TOOL_SEARCH}, result=true`, 317 ) 318 } 319 return true 320} 321 322/** 323 * Check if ToolSearchTool is available in the provided tools list. 324 * If ToolSearchTool is not available (e.g., disallowed via disallowedTools), 325 * tool search cannot function and should be disabled. 326 * 327 * @param tools Array of tools with a 'name' property 328 * @returns true if ToolSearchTool is in the tools list, false otherwise 329 */ 330export function isToolSearchToolAvailable( 331 tools: readonly { name: string }[], 332): boolean { 333 return tools.some(tool => toolMatchesName(tool, TOOL_SEARCH_TOOL_NAME)) 334} 335 336/** 337 * Calculate total deferred tool description size in characters. 338 * Includes name, description text, and input schema to match what's actually sent to the API. 339 */ 340async function calculateDeferredToolDescriptionChars( 341 tools: Tools, 342 getToolPermissionContext: () => Promise<ToolPermissionContext>, 343 agents: AgentDefinition[], 344): Promise<number> { 345 const deferredTools = tools.filter(t => isDeferredTool(t)) 346 if (deferredTools.length === 0) return 0 347 348 const sizes = await Promise.all( 349 deferredTools.map(async tool => { 350 const description = await tool.prompt({ 351 getToolPermissionContext, 352 tools, 353 agents, 354 }) 355 const inputSchema = tool.inputJSONSchema 356 ? jsonStringify(tool.inputJSONSchema) 357 : tool.inputSchema 358 ? jsonStringify(zodToJsonSchema(tool.inputSchema)) 359 : '' 360 return tool.name.length + description.length + inputSchema.length 361 }), 362 ) 363 364 return sizes.reduce((total, size) => total + size, 0) 365} 366 367/** 368 * Check if tool search (MCP tool deferral with tool_reference) is enabled for a specific request. 369 * 370 * This is the definitive check that includes: 371 * - MCP mode (Tst, TstAuto, McpCli, Standard) 372 * - Model compatibility (haiku doesn't support tool_reference) 373 * - ToolSearchTool availability (must be in tools list) 374 * - Threshold check for TstAuto mode 375 * 376 * Use this when making actual API calls where all context is available. 377 * 378 * @param model The model to check for tool_reference support 379 * @param tools Array of available tools (including MCP tools) 380 * @param getToolPermissionContext Function to get tool permission context 381 * @param agents Array of agent definitions 382 * @param source Optional identifier for the caller (for debugging) 383 * @returns true if tool search should be enabled for this request 384 */ 385export async function isToolSearchEnabled( 386 model: string, 387 tools: Tools, 388 getToolPermissionContext: () => Promise<ToolPermissionContext>, 389 agents: AgentDefinition[], 390 source?: string, 391): Promise<boolean> { 392 const mcpToolCount = count(tools, t => t.isMcp) 393 394 // Helper to log the mode decision event 395 function logModeDecision( 396 enabled: boolean, 397 mode: ToolSearchMode, 398 reason: string, 399 extraProps?: Record<string, number>, 400 ): void { 401 logEvent('tengu_tool_search_mode_decision', { 402 enabled, 403 mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 404 reason: 405 reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 406 // Log the actual model being checked, not the session's main model. 407 // This is important for debugging subagent tool search decisions where 408 // the subagent model (e.g., haiku) differs from the session model (e.g., opus). 409 checkedModel: 410 model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 411 mcpToolCount, 412 userType: (process.env.USER_TYPE ?? 413 'external') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 414 ...extraProps, 415 }) 416 } 417 418 // Check if model supports tool_reference 419 if (!modelSupportsToolReference(model)) { 420 logForDebugging( 421 `Tool search disabled for model '${model}': model does not support tool_reference blocks. ` + 422 `This feature is only available on Claude Sonnet 4+, Opus 4+, and newer models.`, 423 ) 424 logModeDecision(false, 'standard', 'model_unsupported') 425 return false 426 } 427 428 // Check if ToolSearchTool is available (respects disallowedTools) 429 if (!isToolSearchToolAvailable(tools)) { 430 logForDebugging( 431 `Tool search disabled: ToolSearchTool is not available (may have been disallowed via disallowedTools).`, 432 ) 433 logModeDecision(false, 'standard', 'mcp_search_unavailable') 434 return false 435 } 436 437 const mode = getToolSearchMode() 438 439 switch (mode) { 440 case 'tst': 441 logModeDecision(true, mode, 'tst_enabled') 442 return true 443 444 case 'tst-auto': { 445 const { enabled, debugDescription, metrics } = await checkAutoThreshold( 446 tools, 447 getToolPermissionContext, 448 agents, 449 model, 450 ) 451 452 if (enabled) { 453 logForDebugging( 454 `Auto tool search enabled: ${debugDescription}` + 455 (source ? ` [source: ${source}]` : ''), 456 ) 457 logModeDecision(true, mode, 'auto_above_threshold', metrics) 458 return true 459 } 460 461 logForDebugging( 462 `Auto tool search disabled: ${debugDescription}` + 463 (source ? ` [source: ${source}]` : ''), 464 ) 465 logModeDecision(false, mode, 'auto_below_threshold', metrics) 466 return false 467 } 468 469 case 'standard': 470 logModeDecision(false, mode, 'standard_mode') 471 return false 472 } 473} 474 475/** 476 * Check if an object is a tool_reference block. 477 * tool_reference is a beta feature not in the SDK types, so we need runtime checks. 478 */ 479export function isToolReferenceBlock(obj: unknown): boolean { 480 return ( 481 typeof obj === 'object' && 482 obj !== null && 483 'type' in obj && 484 (obj as { type: unknown }).type === 'tool_reference' 485 ) 486} 487 488/** 489 * Type guard for tool_reference block with tool_name. 490 */ 491function isToolReferenceWithName( 492 obj: unknown, 493): obj is { type: 'tool_reference'; tool_name: string } { 494 return ( 495 isToolReferenceBlock(obj) && 496 'tool_name' in (obj as object) && 497 typeof (obj as { tool_name: unknown }).tool_name === 'string' 498 ) 499} 500 501/** 502 * Type representing a tool_result block with array content. 503 * Used for extracting tool_reference blocks from ToolSearchTool results. 504 */ 505type ToolResultBlock = { 506 type: 'tool_result' 507 content: unknown[] 508} 509 510/** 511 * Type guard for tool_result blocks with array content. 512 */ 513function isToolResultBlockWithContent(obj: unknown): obj is ToolResultBlock { 514 return ( 515 typeof obj === 'object' && 516 obj !== null && 517 'type' in obj && 518 (obj as { type: unknown }).type === 'tool_result' && 519 'content' in obj && 520 Array.isArray((obj as { content: unknown }).content) 521 ) 522} 523 524/** 525 * Extract tool names from tool_reference blocks in message history. 526 * 527 * When dynamic tool loading is enabled, MCP tools are not predeclared in the 528 * tools array. Instead, they are discovered via ToolSearchTool which returns 529 * tool_reference blocks. This function scans the message history to find all 530 * tool names that have been referenced, so we can include only those tools 531 * in subsequent API requests. 532 * 533 * This approach: 534 * - Eliminates the need to predeclare all MCP tools upfront 535 * - Removes limits on total quantity of MCP tools 536 * 537 * Compaction replaces tool_reference-bearing messages with a summary, so it 538 * snapshots the discovered set onto compactMetadata.preCompactDiscoveredTools 539 * on the boundary marker; this scan reads it back. Snip instead protects the 540 * tool_reference-carrying messages from removal. 541 * 542 * @param messages Array of messages that may contain tool_result blocks with tool_reference content 543 * @returns Set of tool names that have been discovered via tool_reference blocks 544 */ 545export function extractDiscoveredToolNames(messages: Message[]): Set<string> { 546 const discoveredTools = new Set<string>() 547 let carriedFromBoundary = 0 548 549 for (const msg of messages) { 550 // Compact boundary carries the pre-compact discovered set. Inline type 551 // check rather than isCompactBoundaryMessage — utils/messages.ts imports 552 // from this file, so importing back would be circular. 553 if (msg.type === 'system' && msg.subtype === 'compact_boundary') { 554 const carried = msg.compactMetadata?.preCompactDiscoveredTools 555 if (carried) { 556 for (const name of carried) discoveredTools.add(name) 557 carriedFromBoundary += carried.length 558 } 559 continue 560 } 561 562 // Only user messages contain tool_result blocks (responses to tool_use) 563 if (msg.type !== 'user') continue 564 565 const content = msg.message?.content 566 if (!Array.isArray(content)) continue 567 568 for (const block of content) { 569 // tool_reference blocks only appear inside tool_result content, specifically 570 // in results from ToolSearchTool. The API expands these references into full 571 // tool definitions in the model's context. 572 if (isToolResultBlockWithContent(block)) { 573 for (const item of block.content) { 574 if (isToolReferenceWithName(item)) { 575 discoveredTools.add(item.tool_name) 576 } 577 } 578 } 579 } 580 } 581 582 if (discoveredTools.size > 0) { 583 logForDebugging( 584 `Dynamic tool loading: found ${discoveredTools.size} discovered tools in message history` + 585 (carriedFromBoundary > 0 586 ? ` (${carriedFromBoundary} carried from compact boundary)` 587 : ''), 588 ) 589 } 590 591 return discoveredTools 592} 593 594export type DeferredToolsDelta = { 595 addedNames: string[] 596 /** Rendered lines for addedNames; the scan reconstructs from names. */ 597 addedLines: string[] 598 removedNames: string[] 599} 600 601/** 602 * Call-site discriminator for the tengu_deferred_tools_pool_change event. 603 * The scan runs from several sites with different expected-prior semantics 604 * (inc-4747): 605 * - attachments_main: main-thread getAttachments → prior=0 is a BUG on fire-2+ 606 * - attachments_subagent: subagent getAttachments → prior=0 is EXPECTED 607 * (fresh conversation, initialMessages has no DTD) 608 * - compact_full: compact.ts passes [] → prior=0 is EXPECTED 609 * - compact_partial: compact.ts passes messagesToKeep → depends on what survived 610 * - reactive_compact: reactiveCompact.ts passes preservedMessages → same 611 * Without this the 96%-prior=0 stat is dominated by EXPECTED buckets and 612 * the real main-thread cross-turn bug (if any) is invisible in BQ. 613 */ 614export type DeferredToolsDeltaScanContext = { 615 callSite: 616 | 'attachments_main' 617 | 'attachments_subagent' 618 | 'compact_full' 619 | 'compact_partial' 620 | 'reactive_compact' 621 querySource?: string 622} 623 624/** 625 * True → announce deferred tools via persisted delta attachments. 626 * False → claude.ts keeps its per-call <available-deferred-tools> 627 * header prepend (the attachment does not fire). 628 */ 629export function isDeferredToolsDeltaEnabled(): boolean { 630 return ( 631 process.env.USER_TYPE === 'ant' || 632 getFeatureValue_CACHED_MAY_BE_STALE('tengu_glacier_2xr', false) 633 ) 634} 635 636/** 637 * Diff the current deferred-tool pool against what's already been 638 * announced in this conversation (reconstructed by scanning for prior 639 * deferred_tools_delta attachments). Returns null if nothing changed. 640 * 641 * A name that was announced but has since stopped being deferred — yet 642 * is still in the base pool — is NOT reported as removed. It's now 643 * loaded directly, so telling the model "no longer available" would be 644 * wrong. 645 */ 646export function getDeferredToolsDelta( 647 tools: Tools, 648 messages: Message[], 649 scanContext?: DeferredToolsDeltaScanContext, 650): DeferredToolsDelta | null { 651 const announced = new Set<string>() 652 let attachmentCount = 0 653 let dtdCount = 0 654 const attachmentTypesSeen = new Set<string>() 655 for (const msg of messages) { 656 if (msg.type !== 'attachment') continue 657 attachmentCount++ 658 attachmentTypesSeen.add(msg.attachment.type) 659 if (msg.attachment.type !== 'deferred_tools_delta') continue 660 dtdCount++ 661 for (const n of msg.attachment.addedNames) announced.add(n) 662 for (const n of msg.attachment.removedNames) announced.delete(n) 663 } 664 665 const deferred: Tool[] = tools.filter(isDeferredTool) 666 const deferredNames = new Set(deferred.map(t => t.name)) 667 const poolNames = new Set(tools.map(t => t.name)) 668 669 const added = deferred.filter(t => !announced.has(t.name)) 670 const removed: string[] = [] 671 for (const n of announced) { 672 if (deferredNames.has(n)) continue 673 if (!poolNames.has(n)) removed.push(n) 674 // else: undeferred — silent 675 } 676 677 if (added.length === 0 && removed.length === 0) return null 678 679 // Diagnostic for the inc-4747 scan-finds-nothing bug. Round-1 fields 680 // (messagesLength/attachmentCount/dtdCount from #23167) showed 45.6% of 681 // events have attachments-but-no-DTD, but those numbers are confounded: 682 // subagent first-fires and compact-path scans have EXPECTED prior=0 and 683 // dominate the stat. callSite/querySource/attachmentTypesSeen split the 684 // buckets so the real main-thread cross-turn failure is isolable in BQ. 685 logEvent('tengu_deferred_tools_pool_change', { 686 addedCount: added.length, 687 removedCount: removed.length, 688 priorAnnouncedCount: announced.size, 689 messagesLength: messages.length, 690 attachmentCount, 691 dtdCount, 692 callSite: (scanContext?.callSite ?? 693 'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 694 querySource: (scanContext?.querySource ?? 695 'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 696 attachmentTypesSeen: [...attachmentTypesSeen] 697 .sort() 698 .join(',') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 699 }) 700 701 return { 702 addedNames: added.map(t => t.name).sort(), 703 addedLines: added.map(formatDeferredToolLine).sort(), 704 removedNames: removed.sort(), 705 } 706} 707 708/** 709 * Check whether deferred tools exceed the auto-threshold for enabling TST. 710 * Tries exact token count first; falls back to character-based heuristic. 711 */ 712async function checkAutoThreshold( 713 tools: Tools, 714 getToolPermissionContext: () => Promise<ToolPermissionContext>, 715 agents: AgentDefinition[], 716 model: string, 717): Promise<{ 718 enabled: boolean 719 debugDescription: string 720 metrics: Record<string, number> 721}> { 722 // Try exact token count first (cached, one API call per toolset change) 723 const deferredToolTokens = await getDeferredToolTokenCount( 724 tools, 725 getToolPermissionContext, 726 agents, 727 model, 728 ) 729 730 if (deferredToolTokens !== null) { 731 const threshold = getAutoToolSearchTokenThreshold(model) 732 return { 733 enabled: deferredToolTokens >= threshold, 734 debugDescription: 735 `${deferredToolTokens} tokens (threshold: ${threshold}, ` + 736 `${getAutoToolSearchPercentage()}% of context)`, 737 metrics: { deferredToolTokens, threshold }, 738 } 739 } 740 741 // Fallback: character-based heuristic when token API is unavailable 742 const deferredToolDescriptionChars = 743 await calculateDeferredToolDescriptionChars( 744 tools, 745 getToolPermissionContext, 746 agents, 747 ) 748 const charThreshold = getAutoToolSearchCharThreshold(model) 749 return { 750 enabled: deferredToolDescriptionChars >= charThreshold, 751 debugDescription: 752 `${deferredToolDescriptionChars} chars (threshold: ${charThreshold}, ` + 753 `${getAutoToolSearchPercentage()}% of context) (char fallback)`, 754 metrics: { deferredToolDescriptionChars, charThreshold }, 755 } 756}