toolSearch.ts 26 KB

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