source dump of claude code
at main 718 lines 26 kB view raw
1import type Anthropic from '@anthropic-ai/sdk' 2import type { 3 BetaTool, 4 BetaToolUnion, 5} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' 6import { createHash } from 'crypto' 7import { SYSTEM_PROMPT_DYNAMIC_BOUNDARY } from 'src/constants/prompts.js' 8import { getSystemContext, getUserContext } from 'src/context.js' 9import { isAnalyticsDisabled } from 'src/services/analytics/config.js' 10import { 11 checkStatsigFeatureGate_CACHED_MAY_BE_STALE, 12 getFeatureValue_CACHED_MAY_BE_STALE, 13} from 'src/services/analytics/growthbook.js' 14import { 15 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 16 logEvent, 17} from 'src/services/analytics/index.js' 18import { prefetchAllMcpResources } from 'src/services/mcp/client.js' 19import type { ScopedMcpServerConfig } from 'src/services/mcp/types.js' 20import { BashTool } from 'src/tools/BashTool/BashTool.js' 21import { FileEditTool } from 'src/tools/FileEditTool/FileEditTool.js' 22import { 23 normalizeFileEditInput, 24 stripTrailingWhitespace, 25} from 'src/tools/FileEditTool/utils.js' 26import { FileWriteTool } from 'src/tools/FileWriteTool/FileWriteTool.js' 27import { getTools } from 'src/tools.js' 28import type { AgentId } from 'src/types/ids.js' 29import type { z } from 'zod/v4' 30import { CLI_SYSPROMPT_PREFIXES } from '../constants/system.js' 31import { roughTokenCountEstimation } from '../services/tokenEstimation.js' 32import type { Tool, ToolPermissionContext, Tools } from '../Tool.js' 33import { AGENT_TOOL_NAME } from '../tools/AgentTool/constants.js' 34import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js' 35import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../tools/ExitPlanModeTool/constants.js' 36import { TASK_OUTPUT_TOOL_NAME } from '../tools/TaskOutputTool/constants.js' 37import type { Message } from '../types/message.js' 38import { isAgentSwarmsEnabled } from './agentSwarmsEnabled.js' 39import { 40 modelSupportsStructuredOutputs, 41 shouldUseGlobalCacheScope, 42} from './betas.js' 43import { getCwd } from './cwd.js' 44import { logForDebugging } from './debug.js' 45import { isEnvTruthy } from './envUtils.js' 46import { createUserMessage } from './messages.js' 47import { 48 getAPIProvider, 49 isFirstPartyAnthropicBaseUrl, 50} from './model/providers.js' 51import { 52 getFileReadIgnorePatterns, 53 normalizePatternsToPath, 54} from './permissions/filesystem.js' 55import { 56 getPlan, 57 getPlanFilePath, 58 persistFileSnapshotIfRemote, 59} from './plans.js' 60import { getPlatform } from './platform.js' 61import { countFilesRoundedRg } from './ripgrep.js' 62import { jsonStringify } from './slowOperations.js' 63import type { SystemPrompt } from './systemPromptType.js' 64import { getToolSchemaCache } from './toolSchemaCache.js' 65import { windowsPathToPosixPath } from './windowsPaths.js' 66import { zodToJsonSchema } from './zodToJsonSchema.js' 67 68// Extended BetaTool type with strict mode and defer_loading support 69type 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 80export type CacheScope = 'global' | 'org' 81export type SystemPromptBlock = { 82 text: string 83 cacheScope: CacheScope | null 84} 85 86// Fields to filter from tool schemas when swarms are not enabled 87const 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 */ 96function 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 119export 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 268let loggedStrip = false 269function 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 */ 281export 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 */ 321export 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 437export 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 449export 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 */ 479export 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 566export 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) 685export 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}