/ utils / api.ts
api.ts
  1  import type Anthropic from '@anthropic-ai/sdk'
  2  import type {
  3    BetaTool,
  4    BetaToolUnion,
  5  } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
  6  import { createHash } from 'crypto'
  7  import { SYSTEM_PROMPT_DYNAMIC_BOUNDARY } from 'src/constants/prompts.js'
  8  import { getSystemContext, getUserContext } from 'src/context.js'
  9  import { isAnalyticsDisabled } from 'src/services/analytics/config.js'
 10  import {
 11    checkStatsigFeatureGate_CACHED_MAY_BE_STALE,
 12    getFeatureValue_CACHED_MAY_BE_STALE,
 13  } from 'src/services/analytics/growthbook.js'
 14  import {
 15    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 16    logEvent,
 17  } from 'src/services/analytics/index.js'
 18  import { prefetchAllMcpResources } from 'src/services/mcp/client.js'
 19  import type { ScopedMcpServerConfig } from 'src/services/mcp/types.js'
 20  import { BashTool } from 'src/tools/BashTool/BashTool.js'
 21  import { FileEditTool } from 'src/tools/FileEditTool/FileEditTool.js'
 22  import {
 23    normalizeFileEditInput,
 24    stripTrailingWhitespace,
 25  } from 'src/tools/FileEditTool/utils.js'
 26  import { FileWriteTool } from 'src/tools/FileWriteTool/FileWriteTool.js'
 27  import { getTools } from 'src/tools.js'
 28  import type { AgentId } from 'src/types/ids.js'
 29  import type { z } from 'zod/v4'
 30  import { CLI_SYSPROMPT_PREFIXES } from '../constants/system.js'
 31  import { roughTokenCountEstimation } from '../services/tokenEstimation.js'
 32  import type { Tool, ToolPermissionContext, Tools } from '../Tool.js'
 33  import { AGENT_TOOL_NAME } from '../tools/AgentTool/constants.js'
 34  import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'
 35  import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../tools/ExitPlanModeTool/constants.js'
 36  import { TASK_OUTPUT_TOOL_NAME } from '../tools/TaskOutputTool/constants.js'
 37  import type { Message } from '../types/message.js'
 38  import { isAgentSwarmsEnabled } from './agentSwarmsEnabled.js'
 39  import {
 40    modelSupportsStructuredOutputs,
 41    shouldUseGlobalCacheScope,
 42  } from './betas.js'
 43  import { getCwd } from './cwd.js'
 44  import { logForDebugging } from './debug.js'
 45  import { isEnvTruthy } from './envUtils.js'
 46  import { createUserMessage } from './messages.js'
 47  import {
 48    getAPIProvider,
 49    isFirstPartyAnthropicBaseUrl,
 50  } from './model/providers.js'
 51  import {
 52    getFileReadIgnorePatterns,
 53    normalizePatternsToPath,
 54  } from './permissions/filesystem.js'
 55  import {
 56    getPlan,
 57    getPlanFilePath,
 58    persistFileSnapshotIfRemote,
 59  } from './plans.js'
 60  import { getPlatform } from './platform.js'
 61  import { countFilesRoundedRg } from './ripgrep.js'
 62  import { jsonStringify } from './slowOperations.js'
 63  import type { SystemPrompt } from './systemPromptType.js'
 64  import { getToolSchemaCache } from './toolSchemaCache.js'
 65  import { windowsPathToPosixPath } from './windowsPaths.js'
 66  import { zodToJsonSchema } from './zodToJsonSchema.js'
 67  
 68  // Extended BetaTool type with strict mode and defer_loading support
 69  type BetaToolWithExtras = BetaTool & {
 70    strict?: boolean
 71    defer_loading?: boolean
 72    cache_control?: {
 73      type: 'ephemeral'
 74      scope?: 'global' | 'org'
 75      ttl?: '5m' | '1h'
 76    }
 77    eager_input_streaming?: boolean
 78  }
 79  
 80  export type CacheScope = 'global' | 'org'
 81  export type SystemPromptBlock = {
 82    text: string
 83    cacheScope: CacheScope | null
 84  }
 85  
 86  // Fields to filter from tool schemas when swarms are not enabled
 87  const SWARM_FIELDS_BY_TOOL: Record<string, string[]> = {
 88    [EXIT_PLAN_MODE_V2_TOOL_NAME]: ['launchSwarm', 'teammateCount'],
 89    [AGENT_TOOL_NAME]: ['name', 'team_name', 'mode'],
 90  }
 91  
 92  /**
 93   * Filter swarm-related fields from a tool's input schema.
 94   * Called at runtime when isAgentSwarmsEnabled() returns false.
 95   */
 96  function filterSwarmFieldsFromSchema(
 97    toolName: string,
 98    schema: Anthropic.Tool.InputSchema,
 99  ): Anthropic.Tool.InputSchema {
100    const fieldsToRemove = SWARM_FIELDS_BY_TOOL[toolName]
101    if (!fieldsToRemove || fieldsToRemove.length === 0) {
102      return schema
103    }
104  
105    // Clone the schema to avoid mutating the original
106    const filtered = { ...schema }
107    const props = filtered.properties
108    if (props && typeof props === 'object') {
109      const filteredProps = { ...(props as Record<string, unknown>) }
110      for (const field of fieldsToRemove) {
111        delete filteredProps[field]
112      }
113      filtered.properties = filteredProps
114    }
115  
116    return filtered
117  }
118  
119  export async function toolToAPISchema(
120    tool: Tool,
121    options: {
122      getToolPermissionContext: () => Promise<ToolPermissionContext>
123      tools: Tools
124      agents: AgentDefinition[]
125      allowedAgentTypes?: string[]
126      model?: string
127      /** When true, mark this tool with defer_loading for tool search */
128      deferLoading?: boolean
129      cacheControl?: {
130        type: 'ephemeral'
131        scope?: 'global' | 'org'
132        ttl?: '5m' | '1h'
133      }
134    },
135  ): Promise<BetaToolUnion> {
136    // Session-stable base schema: name, description, input_schema, strict,
137    // eager_input_streaming. These are computed once per session and cached to
138    // prevent mid-session GrowthBook flips (tengu_tool_pear, tengu_fgts) or
139    // tool.prompt() drift from churning the serialized tool array bytes.
140    // See toolSchemaCache.ts for rationale.
141    //
142    // Cache key includes inputJSONSchema when present. StructuredOutput instances
143    // share the name 'StructuredOutput' but carry different schemas per workflow
144    // call — name-only keying returned a stale schema (5.4% → 51% err rate, see
145    // PR#25424). MCP tools also set inputJSONSchema but each has a stable schema,
146    // so including it preserves their GB-flip cache stability.
147    const cacheKey =
148      'inputJSONSchema' in tool && tool.inputJSONSchema
149        ? `${tool.name}:${jsonStringify(tool.inputJSONSchema)}`
150        : tool.name
151    const cache = getToolSchemaCache()
152    let base = cache.get(cacheKey)
153    if (!base) {
154      const strictToolsEnabled =
155        checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_tool_pear')
156      // Use tool's JSON schema directly if provided, otherwise convert Zod schema
157      let input_schema = (
158        'inputJSONSchema' in tool && tool.inputJSONSchema
159          ? tool.inputJSONSchema
160          : zodToJsonSchema(tool.inputSchema)
161      ) as Anthropic.Tool.InputSchema
162  
163      // Filter out swarm-related fields when swarms are not enabled
164      // This ensures external non-EAP users don't see swarm features in the schema
165      if (!isAgentSwarmsEnabled()) {
166        input_schema = filterSwarmFieldsFromSchema(tool.name, input_schema)
167      }
168  
169      base = {
170        name: tool.name,
171        description: await tool.prompt({
172          getToolPermissionContext: options.getToolPermissionContext,
173          tools: options.tools,
174          agents: options.agents,
175          allowedAgentTypes: options.allowedAgentTypes,
176        }),
177        input_schema,
178      }
179  
180      // Only add strict if:
181      // 1. Feature flag is enabled
182      // 2. Tool has strict: true
183      // 3. Model is provided and supports it (not all models support it right now)
184      //    (if model is not provided, assume we can't use strict tools)
185      if (
186        strictToolsEnabled &&
187        tool.strict === true &&
188        options.model &&
189        modelSupportsStructuredOutputs(options.model)
190      ) {
191        base.strict = true
192      }
193  
194      // Enable fine-grained tool streaming via per-tool API field.
195      // Without FGTS, the API buffers entire tool input parameters before sending
196      // input_json_delta events, causing multi-minute hangs on large tool inputs.
197      // Gated to direct api.anthropic.com: proxies (LiteLLM etc.) and Bedrock/Vertex
198      // with Claude 4.5 reject this field with 400. See GH#32742, PR #21729.
199      if (
200        getAPIProvider() === 'firstParty' &&
201        isFirstPartyAnthropicBaseUrl() &&
202        (getFeatureValue_CACHED_MAY_BE_STALE('tengu_fgts', false) ||
203          isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_FINE_GRAINED_TOOL_STREAMING))
204      ) {
205        base.eager_input_streaming = true
206      }
207  
208      cache.set(cacheKey, base)
209    }
210  
211    // Per-request overlay: defer_loading and cache_control vary by call
212    // (tool search defers different tools per turn; cache markers move).
213    // Explicit field copy avoids mutating the cached base and sidesteps
214    // BetaTool.cache_control's `| null` clashing with our narrower type.
215    const schema: BetaToolWithExtras = {
216      name: base.name,
217      description: base.description,
218      input_schema: base.input_schema,
219      ...(base.strict && { strict: true }),
220      ...(base.eager_input_streaming && { eager_input_streaming: true }),
221    }
222  
223    // Add defer_loading if requested (for tool search feature)
224    if (options.deferLoading) {
225      schema.defer_loading = true
226    }
227  
228    if (options.cacheControl) {
229      schema.cache_control = options.cacheControl
230    }
231  
232    // CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS is the kill switch for beta API
233    // shapes. Proxy gateways (ANTHROPIC_BASE_URL → LiteLLM → Bedrock) reject
234    // fields like defer_loading with "Extra inputs are not permitted". The gates
235    // above each field are scattered and not all provider-aware, so this strips
236    // everything not in the base-tool allowlist at the one choke point all tool
237    // schemas pass through — including fields added in the future.
238    // cache_control is allowlisted: the base {type: 'ephemeral'} shape is
239    // standard prompt caching (Bedrock/Vertex supported); the beta sub-fields
240    // (scope, ttl) are already gated upstream by shouldIncludeFirstPartyOnlyBetas
241    // which independently respects this kill switch.
242    // github.com/anthropics/claude-code/issues/20031
243    if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)) {
244      const allowed = new Set([
245        'name',
246        'description',
247        'input_schema',
248        'cache_control',
249      ])
250      const stripped = Object.keys(schema).filter(k => !allowed.has(k))
251      if (stripped.length > 0) {
252        logStripOnce(stripped)
253        return {
254          name: schema.name,
255          description: schema.description,
256          input_schema: schema.input_schema,
257          ...(schema.cache_control && { cache_control: schema.cache_control }),
258        }
259      }
260    }
261  
262    // Note: We cast to BetaTool but the extra fields are still present at runtime
263    // and will be serialized in the API request, even though they're not in the SDK's
264    // BetaTool type definition. This is intentional for beta features.
265    return schema as BetaTool
266  }
267  
268  let loggedStrip = false
269  function logStripOnce(stripped: string[]): void {
270    if (loggedStrip) return
271    loggedStrip = true
272    logForDebugging(
273      `[betas] Stripped from tool schemas: [${stripped.join(', ')}] (CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1)`,
274    )
275  }
276  
277  /**
278   * Log stats about first block for analyzing prefix matching config
279   * (see https://console.statsig.com/4aF3Ewatb6xPVpCwxb5nA3/dynamic_configs/claude_cli_system_prompt_prefixes)
280   */
281  export function logAPIPrefix(systemPrompt: SystemPrompt): void {
282    const [firstSyspromptBlock] = splitSysPromptPrefix(systemPrompt)
283    const firstSystemPrompt = firstSyspromptBlock?.text
284    logEvent('tengu_sysprompt_block', {
285      snippet: firstSystemPrompt?.slice(
286        0,
287        20,
288      ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
289      length: firstSystemPrompt?.length ?? 0,
290      hash: (firstSystemPrompt
291        ? createHash('sha256').update(firstSystemPrompt).digest('hex')
292        : '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
293    })
294  }
295  
296  /**
297   * Split system prompt blocks by content type for API matching and cache control.
298   * See https://console.statsig.com/4aF3Ewatb6xPVpCwxb5nA3/dynamic_configs/claude_cli_system_prompt_prefixes
299   *
300   * Behavior depends on feature flags and options:
301   *
302   * 1. MCP tools present (skipGlobalCacheForSystemPrompt=true):
303   *    Returns up to 3 blocks with org-level caching (no global cache on system prompt):
304   *    - Attribution header (cacheScope=null)
305   *    - System prompt prefix (cacheScope='org')
306   *    - Everything else concatenated (cacheScope='org')
307   *
308   * 2. Global cache mode with boundary marker (1P only, boundary found):
309   *    Returns up to 4 blocks:
310   *    - Attribution header (cacheScope=null)
311   *    - System prompt prefix (cacheScope=null)
312   *    - Static content before boundary (cacheScope='global')
313   *    - Dynamic content after boundary (cacheScope=null)
314   *
315   * 3. Default mode (3P providers, or boundary missing):
316   *    Returns up to 3 blocks with org-level caching:
317   *    - Attribution header (cacheScope=null)
318   *    - System prompt prefix (cacheScope='org')
319   *    - Everything else concatenated (cacheScope='org')
320   */
321  export function splitSysPromptPrefix(
322    systemPrompt: SystemPrompt,
323    options?: { skipGlobalCacheForSystemPrompt?: boolean },
324  ): SystemPromptBlock[] {
325    const useGlobalCacheFeature = shouldUseGlobalCacheScope()
326    if (useGlobalCacheFeature && options?.skipGlobalCacheForSystemPrompt) {
327      logEvent('tengu_sysprompt_using_tool_based_cache', {
328        promptBlockCount: systemPrompt.length,
329      })
330  
331      // Filter out boundary marker, return blocks without global scope
332      let attributionHeader: string | undefined
333      let systemPromptPrefix: string | undefined
334      const rest: string[] = []
335  
336      for (const prompt of systemPrompt) {
337        if (!prompt) continue
338        if (prompt === SYSTEM_PROMPT_DYNAMIC_BOUNDARY) continue // Skip boundary
339        if (prompt.startsWith('x-anthropic-billing-header')) {
340          attributionHeader = prompt
341        } else if (CLI_SYSPROMPT_PREFIXES.has(prompt)) {
342          systemPromptPrefix = prompt
343        } else {
344          rest.push(prompt)
345        }
346      }
347  
348      const result: SystemPromptBlock[] = []
349      if (attributionHeader) {
350        result.push({ text: attributionHeader, cacheScope: null })
351      }
352      if (systemPromptPrefix) {
353        result.push({ text: systemPromptPrefix, cacheScope: 'org' })
354      }
355      const restJoined = rest.join('\n\n')
356      if (restJoined) {
357        result.push({ text: restJoined, cacheScope: 'org' })
358      }
359      return result
360    }
361  
362    if (useGlobalCacheFeature) {
363      const boundaryIndex = systemPrompt.findIndex(
364        s => s === SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
365      )
366      if (boundaryIndex !== -1) {
367        let attributionHeader: string | undefined
368        let systemPromptPrefix: string | undefined
369        const staticBlocks: string[] = []
370        const dynamicBlocks: string[] = []
371  
372        for (let i = 0; i < systemPrompt.length; i++) {
373          const block = systemPrompt[i]
374          if (!block || block === SYSTEM_PROMPT_DYNAMIC_BOUNDARY) continue
375  
376          if (block.startsWith('x-anthropic-billing-header')) {
377            attributionHeader = block
378          } else if (CLI_SYSPROMPT_PREFIXES.has(block)) {
379            systemPromptPrefix = block
380          } else if (i < boundaryIndex) {
381            staticBlocks.push(block)
382          } else {
383            dynamicBlocks.push(block)
384          }
385        }
386  
387        const result: SystemPromptBlock[] = []
388        if (attributionHeader)
389          result.push({ text: attributionHeader, cacheScope: null })
390        if (systemPromptPrefix)
391          result.push({ text: systemPromptPrefix, cacheScope: null })
392        const staticJoined = staticBlocks.join('\n\n')
393        if (staticJoined)
394          result.push({ text: staticJoined, cacheScope: 'global' })
395        const dynamicJoined = dynamicBlocks.join('\n\n')
396        if (dynamicJoined) result.push({ text: dynamicJoined, cacheScope: null })
397  
398        logEvent('tengu_sysprompt_boundary_found', {
399          blockCount: result.length,
400          staticBlockLength: staticJoined.length,
401          dynamicBlockLength: dynamicJoined.length,
402        })
403  
404        return result
405      } else {
406        logEvent('tengu_sysprompt_missing_boundary_marker', {
407          promptBlockCount: systemPrompt.length,
408        })
409      }
410    }
411    let attributionHeader: string | undefined
412    let systemPromptPrefix: string | undefined
413    const rest: string[] = []
414  
415    for (const block of systemPrompt) {
416      if (!block) continue
417  
418      if (block.startsWith('x-anthropic-billing-header')) {
419        attributionHeader = block
420      } else if (CLI_SYSPROMPT_PREFIXES.has(block)) {
421        systemPromptPrefix = block
422      } else {
423        rest.push(block)
424      }
425    }
426  
427    const result: SystemPromptBlock[] = []
428    if (attributionHeader)
429      result.push({ text: attributionHeader, cacheScope: null })
430    if (systemPromptPrefix)
431      result.push({ text: systemPromptPrefix, cacheScope: 'org' })
432    const restJoined = rest.join('\n\n')
433    if (restJoined) result.push({ text: restJoined, cacheScope: 'org' })
434    return result
435  }
436  
437  export function appendSystemContext(
438    systemPrompt: SystemPrompt,
439    context: { [k: string]: string },
440  ): string[] {
441    return [
442      ...systemPrompt,
443      Object.entries(context)
444        .map(([key, value]) => `${key}: ${value}`)
445        .join('\n'),
446    ].filter(Boolean)
447  }
448  
449  export function prependUserContext(
450    messages: Message[],
451    context: { [k: string]: string },
452  ): Message[] {
453    if (process.env.NODE_ENV === 'test') {
454      return messages
455    }
456  
457    if (Object.entries(context).length === 0) {
458      return messages
459    }
460  
461    return [
462      createUserMessage({
463        content: `<system-reminder>\nAs you answer the user's questions, you can use the following context:\n${Object.entries(
464          context,
465        )
466          .map(([key, value]) => `# ${key}\n${value}`)
467          .join('\n')}
468  
469        IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.\n</system-reminder>\n`,
470        isMeta: true,
471      }),
472      ...messages,
473    ]
474  }
475  
476  /**
477   * Log metrics about context and system prompt size
478   */
479  export async function logContextMetrics(
480    mcpConfigs: Record<string, ScopedMcpServerConfig>,
481    toolPermissionContext: ToolPermissionContext,
482  ): Promise<void> {
483    // Early return if logging is disabled
484    if (isAnalyticsDisabled()) {
485      return
486    }
487    const [{ tools: mcpTools }, tools, userContext, systemContext] =
488      await Promise.all([
489        prefetchAllMcpResources(mcpConfigs),
490        getTools(toolPermissionContext),
491        getUserContext(),
492        getSystemContext(),
493      ])
494    // Extract individual context sizes and calculate total
495    const gitStatusSize = systemContext.gitStatus?.length ?? 0
496    const claudeMdSize = userContext.claudeMd?.length ?? 0
497  
498    // Calculate total context size
499    const totalContextSize = gitStatusSize + claudeMdSize
500  
501    // Get file count using ripgrep (rounded to nearest power of 10 for privacy)
502    const currentDir = getCwd()
503    const ignorePatternsByRoot = getFileReadIgnorePatterns(toolPermissionContext)
504    const normalizedIgnorePatterns = normalizePatternsToPath(
505      ignorePatternsByRoot,
506      currentDir,
507    )
508    const fileCount = await countFilesRoundedRg(
509      currentDir,
510      AbortSignal.timeout(1000),
511      normalizedIgnorePatterns,
512    )
513  
514    // Calculate tool metrics
515    let mcpToolsCount = 0
516    let mcpServersCount = 0
517    let mcpToolsTokens = 0
518    let nonMcpToolsCount = 0
519    let nonMcpToolsTokens = 0
520  
521    const nonMcpTools = tools.filter(tool => !tool.isMcp)
522    mcpToolsCount = mcpTools.length
523    nonMcpToolsCount = nonMcpTools.length
524  
525    // Extract unique server names from MCP tool names (format: mcp__servername__toolname)
526    const serverNames = new Set<string>()
527    for (const tool of mcpTools) {
528      const parts = tool.name.split('__')
529      if (parts.length >= 3 && parts[1]) {
530        serverNames.add(parts[1])
531      }
532    }
533    mcpServersCount = serverNames.size
534  
535    // Estimate tool tokens locally for analytics (avoids N API calls per session)
536    // Use inputJSONSchema (plain JSON Schema) when available, otherwise convert Zod schema
537    for (const tool of mcpTools) {
538      const schema =
539        'inputJSONSchema' in tool && tool.inputJSONSchema
540          ? tool.inputJSONSchema
541          : zodToJsonSchema(tool.inputSchema)
542      mcpToolsTokens += roughTokenCountEstimation(jsonStringify(schema))
543    }
544    for (const tool of nonMcpTools) {
545      const schema =
546        'inputJSONSchema' in tool && tool.inputJSONSchema
547          ? tool.inputJSONSchema
548          : zodToJsonSchema(tool.inputSchema)
549      nonMcpToolsTokens += roughTokenCountEstimation(jsonStringify(schema))
550    }
551  
552    logEvent('tengu_context_size', {
553      git_status_size: gitStatusSize,
554      claude_md_size: claudeMdSize,
555      total_context_size: totalContextSize,
556      project_file_count_rounded: fileCount,
557      mcp_tools_count: mcpToolsCount,
558      mcp_servers_count: mcpServersCount,
559      mcp_tools_tokens: mcpToolsTokens,
560      non_mcp_tools_count: nonMcpToolsCount,
561      non_mcp_tools_tokens: nonMcpToolsTokens,
562    })
563  }
564  
565  // TODO: Generalize this to all tools
566  export function normalizeToolInput<T extends Tool>(
567    tool: T,
568    input: z.infer<T['inputSchema']>,
569    agentId?: AgentId,
570  ): z.infer<T['inputSchema']> {
571    switch (tool.name) {
572      case EXIT_PLAN_MODE_V2_TOOL_NAME: {
573        // Always inject plan content and file path for ExitPlanModeV2 so hooks/SDK get the plan.
574        // The V2 tool reads plan from file instead of input, but hooks/SDK
575        const plan = getPlan(agentId)
576        const planFilePath = getPlanFilePath(agentId)
577        // Persist file snapshot for CCR sessions so the plan survives pod recycling
578        void persistFileSnapshotIfRemote()
579        return plan !== null ? { ...input, plan, planFilePath } : input
580      }
581      case BashTool.name: {
582        // Validated upstream, won't throw
583        const parsed = BashTool.inputSchema.parse(input)
584        const { command, timeout, description } = parsed
585        const cwd = getCwd()
586        let normalizedCommand = command.replace(`cd ${cwd} && `, '')
587        if (getPlatform() === 'windows') {
588          normalizedCommand = normalizedCommand.replace(
589            `cd ${windowsPathToPosixPath(cwd)} && `,
590            '',
591          )
592        }
593  
594        // Replace \\; with \; (commonly needed for find -exec commands)
595        normalizedCommand = normalizedCommand.replace(/\\\\;/g, '\\;')
596  
597        // Logging for commands that are only echoing a string. This is to help us understand how often  Claude talks via bash
598        if (/^echo\s+["']?[^|&;><]*["']?$/i.test(normalizedCommand.trim())) {
599          logEvent('tengu_bash_tool_simple_echo', {})
600        }
601  
602        // Check for run_in_background (may not exist in schema if CLAUDE_CODE_DISABLE_BACKGROUND_TASKS is set)
603        const run_in_background =
604          'run_in_background' in parsed ? parsed.run_in_background : undefined
605  
606        // SAFETY: Cast is safe because input was validated by .parse() above.
607        // TypeScript can't narrow the generic T based on switch(tool.name), so it
608        // doesn't know the return type matches T['inputSchema']. This is a fundamental
609        // TS limitation with generics, not bypassable without major refactoring.
610        return {
611          command: normalizedCommand,
612          description,
613          ...(timeout !== undefined && { timeout }),
614          ...(description !== undefined && { description }),
615          ...(run_in_background !== undefined && { run_in_background }),
616          ...('dangerouslyDisableSandbox' in parsed &&
617            parsed.dangerouslyDisableSandbox !== undefined && {
618              dangerouslyDisableSandbox: parsed.dangerouslyDisableSandbox,
619            }),
620        } as z.infer<T['inputSchema']>
621      }
622      case FileEditTool.name: {
623        // Validated upstream, won't throw
624        const parsedInput = FileEditTool.inputSchema.parse(input)
625  
626        // This is a workaround for tokens claude can't see
627        const { file_path, edits } = normalizeFileEditInput({
628          file_path: parsedInput.file_path,
629          edits: [
630            {
631              old_string: parsedInput.old_string,
632              new_string: parsedInput.new_string,
633              replace_all: parsedInput.replace_all,
634            },
635          ],
636        })
637  
638        // SAFETY: See comment in BashTool case above
639        return {
640          replace_all: edits[0]!.replace_all,
641          file_path,
642          old_string: edits[0]!.old_string,
643          new_string: edits[0]!.new_string,
644        } as z.infer<T['inputSchema']>
645      }
646      case FileWriteTool.name: {
647        // Validated upstream, won't throw
648        const parsedInput = FileWriteTool.inputSchema.parse(input)
649  
650        // Markdown uses two trailing spaces as a hard line break — don't strip.
651        const isMarkdown = /\.(md|mdx)$/i.test(parsedInput.file_path)
652  
653        // SAFETY: See comment in BashTool case above
654        return {
655          file_path: parsedInput.file_path,
656          content: isMarkdown
657            ? parsedInput.content
658            : stripTrailingWhitespace(parsedInput.content),
659        } as z.infer<T['inputSchema']>
660      }
661      case TASK_OUTPUT_TOOL_NAME: {
662        // Normalize legacy parameter names from AgentOutputTool/BashOutputTool
663        const legacyInput = input as Record<string, unknown>
664        const taskId =
665          legacyInput.task_id ?? legacyInput.agentId ?? legacyInput.bash_id
666        const timeout =
667          legacyInput.timeout ??
668          (typeof legacyInput.wait_up_to === 'number'
669            ? legacyInput.wait_up_to * 1000
670            : undefined)
671        // SAFETY: See comment in BashTool case above
672        return {
673          task_id: taskId ?? '',
674          block: legacyInput.block ?? true,
675          timeout: timeout ?? 30000,
676        } as z.infer<T['inputSchema']>
677      }
678      default:
679        return input
680    }
681  }
682  
683  // Strips fields that were added by normalizeToolInput before sending to API
684  // (e.g., plan field from ExitPlanModeV2 which has an empty input schema)
685  export function normalizeToolInputForAPI<T extends Tool>(
686    tool: T,
687    input: z.infer<T['inputSchema']>,
688  ): z.infer<T['inputSchema']> {
689    switch (tool.name) {
690      case EXIT_PLAN_MODE_V2_TOOL_NAME: {
691        // Strip injected fields before sending to API (schema expects empty object)
692        if (
693          input &&
694          typeof input === 'object' &&
695          ('plan' in input || 'planFilePath' in input)
696        ) {
697          const { plan, planFilePath, ...rest } = input as Record<string, unknown>
698          return rest as z.infer<T['inputSchema']>
699        }
700        return input
701      }
702      case FileEditTool.name: {
703        // Strip synthetic old_string/new_string/replace_all from OLD sessions
704        // that were resumed from transcripts written before PR #20357, where
705        // normalizeToolInput used to synthesize these. Needed so old --resume'd
706        // transcripts don't send whole-file copies to the API. New sessions
707        // don't need this (synthesis moved to emission time).
708        if (input && typeof input === 'object' && 'edits' in input) {
709          const { old_string, new_string, replace_all, ...rest } =
710            input as Record<string, unknown>
711          return rest as z.infer<T['inputSchema']>
712        }
713        return input
714      }
715      default:
716        return input
717    }
718  }