| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756 |
- /**
- * 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') {
- const carried = msg.compactMetadata?.preCompactDiscoveredTools
- 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++
- for (const n of msg.attachment.addedNames) announced.add(n)
- for (const n of msg.attachment.removedNames) announced.delete(n)
- }
- 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 },
- }
- }
|