forkedAgent.ts
1 /** 2 * Helper for running forked agent query loops with usage tracking. 3 * 4 * This utility ensures forked agents: 5 * 1. Share identical cache-critical params with the parent to guarantee prompt cache hits 6 * 2. Track full usage metrics across the entire query loop 7 * 3. Log metrics via the tengu_fork_agent_query event when complete 8 * 4. Isolate mutable state to prevent interference with the main agent loop 9 */ 10 11 import type { UUID } from 'crypto' 12 import { randomUUID } from 'crypto' 13 import type { PromptCommand } from '../commands.js' 14 import type { QuerySource } from '../constants/querySource.js' 15 import type { CanUseToolFn } from '../hooks/useCanUseTool.js' 16 import { query } from '../query.js' 17 import { 18 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 19 logEvent, 20 } from '../services/analytics/index.js' 21 import { accumulateUsage, updateUsage } from '../services/api/claude.js' 22 import { EMPTY_USAGE, type NonNullableUsage } from '../services/api/logging.js' 23 import type { ToolUseContext } from '../Tool.js' 24 import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js' 25 import type { AgentId } from '../types/ids.js' 26 import type { Message } from '../types/message.js' 27 import { createChildAbortController } from './abortController.js' 28 import { logForDebugging } from './debug.js' 29 import { cloneFileStateCache } from './fileStateCache.js' 30 import type { REPLHookContext } from './hooks/postSamplingHooks.js' 31 import { 32 createUserMessage, 33 extractTextContent, 34 getLastAssistantMessage, 35 } from './messages.js' 36 import { createDenialTrackingState } from './permissions/denialTracking.js' 37 import { parseToolListFromCLI } from './permissions/permissionSetup.js' 38 import { recordSidechainTranscript } from './sessionStorage.js' 39 import type { SystemPrompt } from './systemPromptType.js' 40 import { 41 type ContentReplacementState, 42 cloneContentReplacementState, 43 } from './toolResultStorage.js' 44 import { createAgentId } from './uuid.js' 45 46 /** 47 * Parameters that must be identical between the fork and parent API requests 48 * to share the parent's prompt cache. The Anthropic API cache key is composed of: 49 * system prompt, tools, model, messages (prefix), and thinking config. 50 * 51 * CacheSafeParams carries the first five. Thinking config is derived from the 52 * inherited toolUseContext.options.thinkingConfig — but can be inadvertently 53 * changed if the fork sets maxOutputTokens, which clamps budget_tokens in 54 * claude.ts (but only for older models that do not use adaptive thinking). 55 * See the maxOutputTokens doc on ForkedAgentParams. 56 */ 57 export type CacheSafeParams = { 58 /** System prompt - must match parent for cache hits */ 59 systemPrompt: SystemPrompt 60 /** User context - prepended to messages, affects cache */ 61 userContext: { [k: string]: string } 62 /** System context - appended to system prompt, affects cache */ 63 systemContext: { [k: string]: string } 64 /** Tool use context containing tools, model, and other options */ 65 toolUseContext: ToolUseContext 66 /** Parent context messages for prompt cache sharing */ 67 forkContextMessages: Message[] 68 } 69 70 // Slot written by handleStopHooks after each turn so post-turn forks 71 // (promptSuggestion, postTurnSummary, /btw) can share the main loop's 72 // prompt cache without each caller threading params through. 73 let lastCacheSafeParams: CacheSafeParams | null = null 74 75 export function saveCacheSafeParams(params: CacheSafeParams | null): void { 76 lastCacheSafeParams = params 77 } 78 79 export function getLastCacheSafeParams(): CacheSafeParams | null { 80 return lastCacheSafeParams 81 } 82 83 export type ForkedAgentParams = { 84 /** Messages to start the forked query loop with */ 85 promptMessages: Message[] 86 /** Cache-safe parameters that must match the parent query */ 87 cacheSafeParams: CacheSafeParams 88 /** Permission check function for the forked agent */ 89 canUseTool: CanUseToolFn 90 /** Source identifier for tracking */ 91 querySource: QuerySource 92 /** Label for analytics (e.g., 'session_memory', 'supervisor') */ 93 forkLabel: string 94 /** Optional overrides for the subagent context (e.g., readFileState from setup phase) */ 95 overrides?: SubagentContextOverrides 96 /** 97 * Optional cap on output tokens. CAUTION: setting this changes both max_tokens 98 * AND budget_tokens (via clamping in claude.ts). If the fork uses cacheSafeParams 99 * to share the parent's prompt cache, a different budget_tokens will invalidate 100 * the cache — thinking config is part of the cache key. Only set this when cache 101 * sharing is not a goal (e.g., compact summaries). 102 */ 103 maxOutputTokens?: number 104 /** Optional cap on number of turns (API round-trips) */ 105 maxTurns?: number 106 /** Optional callback invoked for each message as it arrives (for streaming UI) */ 107 onMessage?: (message: Message) => void 108 /** Skip sidechain transcript recording (e.g., for ephemeral work like speculation) */ 109 skipTranscript?: boolean 110 /** Skip writing new prompt cache entries on the last message. For 111 * fire-and-forget forks where no future request will read from this prefix. */ 112 skipCacheWrite?: boolean 113 } 114 115 export type ForkedAgentResult = { 116 /** All messages yielded during the query loop */ 117 messages: Message[] 118 /** Accumulated usage across all API calls in the loop */ 119 totalUsage: NonNullableUsage 120 } 121 122 /** 123 * Creates CacheSafeParams from REPLHookContext. 124 * Use this helper when forking from a post-sampling hook context. 125 * 126 * To override specific fields (e.g., toolUseContext with cloned file state), 127 * spread the result and override: `{ ...createCacheSafeParams(context), toolUseContext: clonedContext }` 128 * 129 * @param context - The REPLHookContext from the post-sampling hook 130 */ 131 export function createCacheSafeParams( 132 context: REPLHookContext, 133 ): CacheSafeParams { 134 return { 135 systemPrompt: context.systemPrompt, 136 userContext: context.userContext, 137 systemContext: context.systemContext, 138 toolUseContext: context.toolUseContext, 139 forkContextMessages: context.messages, 140 } 141 } 142 143 /** 144 * Creates a modified getAppState that adds allowed tools to the permission context. 145 * This is used by forked skill/command execution to grant tool permissions. 146 */ 147 export function createGetAppStateWithAllowedTools( 148 baseGetAppState: ToolUseContext['getAppState'], 149 allowedTools: string[], 150 ): ToolUseContext['getAppState'] { 151 if (allowedTools.length === 0) return baseGetAppState 152 return () => { 153 const appState = baseGetAppState() 154 return { 155 ...appState, 156 toolPermissionContext: { 157 ...appState.toolPermissionContext, 158 alwaysAllowRules: { 159 ...appState.toolPermissionContext.alwaysAllowRules, 160 command: [ 161 ...new Set([ 162 ...(appState.toolPermissionContext.alwaysAllowRules.command || 163 []), 164 ...allowedTools, 165 ]), 166 ], 167 }, 168 }, 169 } 170 } 171 } 172 173 /** 174 * Result from preparing a forked command context. 175 */ 176 export type PreparedForkedContext = { 177 /** Skill content with args replaced */ 178 skillContent: string 179 /** Modified getAppState with allowed tools */ 180 modifiedGetAppState: ToolUseContext['getAppState'] 181 /** The general-purpose agent to use */ 182 baseAgent: AgentDefinition 183 /** Initial prompt messages */ 184 promptMessages: Message[] 185 } 186 187 /** 188 * Prepares the context for executing a forked command/skill. 189 * This handles the common setup that both SkillTool and slash commands need. 190 */ 191 export async function prepareForkedCommandContext( 192 command: PromptCommand, 193 args: string, 194 context: ToolUseContext, 195 ): Promise<PreparedForkedContext> { 196 // Get skill content with $ARGUMENTS replaced 197 const skillPrompt = await command.getPromptForCommand(args, context) 198 const skillContent = skillPrompt 199 .map(block => (block.type === 'text' ? block.text : '')) 200 .join('\n') 201 202 // Parse and prepare allowed tools 203 const allowedTools = parseToolListFromCLI(command.allowedTools ?? []) 204 205 // Create modified context with allowed tools 206 const modifiedGetAppState = createGetAppStateWithAllowedTools( 207 context.getAppState, 208 allowedTools, 209 ) 210 211 // Use command.agent if specified, otherwise 'general-purpose' 212 const agentTypeName = command.agent ?? 'general-purpose' 213 const agents = context.options.agentDefinitions.activeAgents 214 const baseAgent = 215 agents.find(a => a.agentType === agentTypeName) ?? 216 agents.find(a => a.agentType === 'general-purpose') ?? 217 agents[0] 218 219 if (!baseAgent) { 220 throw new Error('No agent available for forked execution') 221 } 222 223 // Prepare prompt messages 224 const promptMessages = [createUserMessage({ content: skillContent })] 225 226 return { 227 skillContent, 228 modifiedGetAppState, 229 baseAgent, 230 promptMessages, 231 } 232 } 233 234 /** 235 * Extracts result text from agent messages. 236 */ 237 export function extractResultText( 238 agentMessages: Message[], 239 defaultText = 'Execution completed', 240 ): string { 241 const lastAssistantMessage = getLastAssistantMessage(agentMessages) 242 if (!lastAssistantMessage) return defaultText 243 244 const textContent = extractTextContent( 245 lastAssistantMessage.message.content, 246 '\n', 247 ) 248 249 return textContent || defaultText 250 } 251 252 /** 253 * Options for creating a subagent context. 254 * 255 * By default, all mutable state is isolated to prevent interference with the parent. 256 * Use these options to: 257 * - Override specific fields (e.g., custom options, agentId, messages) 258 * - Explicitly opt-in to sharing specific callbacks (for interactive subagents) 259 */ 260 export type SubagentContextOverrides = { 261 /** Override the options object (e.g., custom tools, model) */ 262 options?: ToolUseContext['options'] 263 /** Override the agentId (for subagents with their own ID) */ 264 agentId?: AgentId 265 /** Override the agentType (for subagents with a specific type) */ 266 agentType?: string 267 /** Override the messages array */ 268 messages?: Message[] 269 /** Override the readFileState (e.g., fresh cache instead of clone) */ 270 readFileState?: ToolUseContext['readFileState'] 271 /** Override the abortController */ 272 abortController?: AbortController 273 /** Override the getAppState function */ 274 getAppState?: ToolUseContext['getAppState'] 275 276 /** 277 * Explicit opt-in to share parent's setAppState callback. 278 * Use for interactive subagents that need to update shared state. 279 * @default false (isolated no-op) 280 */ 281 shareSetAppState?: boolean 282 /** 283 * Explicit opt-in to share parent's setResponseLength callback. 284 * Use for subagents that contribute to parent's response metrics. 285 * @default false (isolated no-op) 286 */ 287 shareSetResponseLength?: boolean 288 /** 289 * Explicit opt-in to share parent's abortController. 290 * Use for interactive subagents that should abort with parent. 291 * Note: Only applies if abortController override is not provided. 292 * @default false (new controller linked to parent) 293 */ 294 shareAbortController?: boolean 295 /** Critical system reminder to re-inject at every user turn */ 296 criticalSystemReminder_EXPERIMENTAL?: string 297 /** When true, canUseTool must always be called even when hooks auto-approve. 298 * Used by speculation for overlay file path rewriting. */ 299 requireCanUseTool?: boolean 300 /** Override replacement state — used by resumeAgentBackground to thread 301 * state reconstructed from the resumed sidechain so the same results 302 * are re-replaced (prompt cache stability). */ 303 contentReplacementState?: ContentReplacementState 304 } 305 306 /** 307 * Creates an isolated ToolUseContext for subagents. 308 * 309 * By default, ALL mutable state is isolated to prevent interference: 310 * - readFileState: cloned from parent 311 * - abortController: new controller linked to parent (parent abort propagates) 312 * - getAppState: wrapped to set shouldAvoidPermissionPrompts 313 * - All mutation callbacks (setAppState, etc.): no-op 314 * - Fresh collections: nestedMemoryAttachmentTriggers, toolDecisions 315 * 316 * Callers can: 317 * - Override specific fields via the overrides parameter 318 * - Explicitly opt-in to sharing specific callbacks (shareSetAppState, etc.) 319 * 320 * @param parentContext - The parent's ToolUseContext to create subagent context from 321 * @param overrides - Optional overrides and sharing options 322 * 323 * @example 324 * // Full isolation (for background agents like session memory) 325 * const ctx = createSubagentContext(parentContext) 326 * 327 * @example 328 * // Custom options and agentId (for AgentTool async agents) 329 * const ctx = createSubagentContext(parentContext, { 330 * options: customOptions, 331 * agentId: newAgentId, 332 * messages: initialMessages, 333 * }) 334 * 335 * @example 336 * // Interactive subagent that shares some state 337 * const ctx = createSubagentContext(parentContext, { 338 * options: customOptions, 339 * agentId: newAgentId, 340 * shareSetAppState: true, 341 * shareSetResponseLength: true, 342 * shareAbortController: true, 343 * }) 344 */ 345 export function createSubagentContext( 346 parentContext: ToolUseContext, 347 overrides?: SubagentContextOverrides, 348 ): ToolUseContext { 349 // Determine abortController: explicit override > share parent's > new child 350 const abortController = 351 overrides?.abortController ?? 352 (overrides?.shareAbortController 353 ? parentContext.abortController 354 : createChildAbortController(parentContext.abortController)) 355 356 // Determine getAppState - wrap to set shouldAvoidPermissionPrompts unless sharing abortController 357 // (if sharing abortController, it's an interactive agent that CAN show UI) 358 const getAppState: ToolUseContext['getAppState'] = overrides?.getAppState 359 ? overrides.getAppState 360 : overrides?.shareAbortController 361 ? parentContext.getAppState 362 : () => { 363 const state = parentContext.getAppState() 364 if (state.toolPermissionContext.shouldAvoidPermissionPrompts) { 365 return state 366 } 367 return { 368 ...state, 369 toolPermissionContext: { 370 ...state.toolPermissionContext, 371 shouldAvoidPermissionPrompts: true, 372 }, 373 } 374 } 375 376 return { 377 // Mutable state - cloned by default to maintain isolation 378 // Clone overrides.readFileState if provided, otherwise clone from parent 379 readFileState: cloneFileStateCache( 380 overrides?.readFileState ?? parentContext.readFileState, 381 ), 382 nestedMemoryAttachmentTriggers: new Set<string>(), 383 loadedNestedMemoryPaths: new Set<string>(), 384 dynamicSkillDirTriggers: new Set<string>(), 385 // Per-subagent: tracks skills surfaced by discovery for was_discovered telemetry (SkillTool.ts:116) 386 discoveredSkillNames: new Set<string>(), 387 toolDecisions: undefined, 388 // Budget decisions: override > clone of parent > undefined (feature off). 389 // 390 // Clone by default (not fresh): cache-sharing forks process parent 391 // messages containing parent tool_use_ids. A fresh state would see 392 // them as unseen and make divergent replacement decisions → wire 393 // prefix differs → cache miss. A clone makes identical decisions → 394 // cache hit. For non-forking subagents the parent UUIDs never match 395 // — clone is a harmless no-op. 396 // 397 // Override: AgentTool resume (reconstructed from sidechain records) 398 // and inProcessRunner (per-teammate persistent loop state). 399 contentReplacementState: 400 overrides?.contentReplacementState ?? 401 (parentContext.contentReplacementState 402 ? cloneContentReplacementState(parentContext.contentReplacementState) 403 : undefined), 404 405 // AbortController 406 abortController, 407 408 // AppState access 409 getAppState, 410 setAppState: overrides?.shareSetAppState 411 ? parentContext.setAppState 412 : () => {}, 413 // Task registration/kill must always reach the root store, even when 414 // setAppState is a no-op — otherwise async agents' background bash tasks 415 // are never registered and never killed (PPID=1 zombie). 416 setAppStateForTasks: 417 parentContext.setAppStateForTasks ?? parentContext.setAppState, 418 // Async subagents whose setAppState is a no-op need local denial tracking 419 // so the denial counter actually accumulates across retries. 420 localDenialTracking: overrides?.shareSetAppState 421 ? parentContext.localDenialTracking 422 : createDenialTrackingState(), 423 424 // Mutation callbacks - no-op by default 425 setInProgressToolUseIDs: () => {}, 426 setResponseLength: overrides?.shareSetResponseLength 427 ? parentContext.setResponseLength 428 : () => {}, 429 pushApiMetricsEntry: overrides?.shareSetResponseLength 430 ? parentContext.pushApiMetricsEntry 431 : undefined, 432 updateFileHistoryState: () => {}, 433 // Attribution is scoped and functional (prev => next) — safe to share even 434 // when setAppState is stubbed. Concurrent calls compose via React's state queue. 435 updateAttributionState: parentContext.updateAttributionState, 436 437 // UI callbacks - undefined for subagents (can't control parent UI) 438 addNotification: undefined, 439 setToolJSX: undefined, 440 setStreamMode: undefined, 441 setSDKStatus: undefined, 442 openMessageSelector: undefined, 443 444 // Fields that can be overridden or copied from parent 445 options: overrides?.options ?? parentContext.options, 446 messages: overrides?.messages ?? parentContext.messages, 447 // Generate new agentId for subagents (each subagent should have its own ID) 448 agentId: overrides?.agentId ?? createAgentId(), 449 agentType: overrides?.agentType, 450 451 // Create new query tracking chain for subagent with incremented depth 452 queryTracking: { 453 chainId: randomUUID(), 454 depth: (parentContext.queryTracking?.depth ?? -1) + 1, 455 }, 456 fileReadingLimits: parentContext.fileReadingLimits, 457 userModified: parentContext.userModified, 458 criticalSystemReminder_EXPERIMENTAL: 459 overrides?.criticalSystemReminder_EXPERIMENTAL, 460 requireCanUseTool: overrides?.requireCanUseTool, 461 } 462 } 463 464 /** 465 * Runs a forked agent query loop and tracks cache hit metrics. 466 * 467 * This function: 468 * 1. Uses identical cache-safe params from parent to enable prompt caching 469 * 2. Accumulates usage across all query iterations 470 * 3. Logs tengu_fork_agent_query with full usage when complete 471 * 472 * @example 473 * ```typescript 474 * const result = await runForkedAgent({ 475 * promptMessages: [createUserMessage({ content: userPrompt })], 476 * cacheSafeParams: { 477 * systemPrompt, 478 * userContext, 479 * systemContext, 480 * toolUseContext: clonedToolUseContext, 481 * forkContextMessages: messages, 482 * }, 483 * canUseTool, 484 * querySource: 'session_memory', 485 * forkLabel: 'session_memory', 486 * }) 487 * ``` 488 */ 489 export async function runForkedAgent({ 490 promptMessages, 491 cacheSafeParams, 492 canUseTool, 493 querySource, 494 forkLabel, 495 overrides, 496 maxOutputTokens, 497 maxTurns, 498 onMessage, 499 skipTranscript, 500 skipCacheWrite, 501 }: ForkedAgentParams): Promise<ForkedAgentResult> { 502 const startTime = Date.now() 503 const outputMessages: Message[] = [] 504 let totalUsage: NonNullableUsage = { ...EMPTY_USAGE } 505 506 const { 507 systemPrompt, 508 userContext, 509 systemContext, 510 toolUseContext, 511 forkContextMessages, 512 } = cacheSafeParams 513 514 // Create isolated context to prevent mutation of parent state 515 const isolatedToolUseContext = createSubagentContext( 516 toolUseContext, 517 overrides, 518 ) 519 520 // Do NOT filterIncompleteToolCalls here — it drops the whole assistant on 521 // partial tool batches, orphaning the paired results (API 400). Dangling 522 // tool_uses are repaired downstream by ensureToolResultPairing in claude.ts, 523 // same as the main thread — identical post-repair prefix keeps the cache hit. 524 const initialMessages: Message[] = [...forkContextMessages, ...promptMessages] 525 526 // Generate agent ID and record initial messages for transcript 527 // When skipTranscript is set, skip agent ID creation and all transcript I/O 528 const agentId = skipTranscript ? undefined : createAgentId(forkLabel) 529 let lastRecordedUuid: UUID | null = null 530 if (agentId) { 531 await recordSidechainTranscript(initialMessages, agentId).catch(err => 532 logForDebugging( 533 `Forked agent [${forkLabel}] failed to record initial transcript: ${err}`, 534 ), 535 ) 536 // Track the last recorded message UUID for parent chain continuity 537 lastRecordedUuid = 538 initialMessages.length > 0 539 ? initialMessages[initialMessages.length - 1]!.uuid 540 : null 541 } 542 543 // Run the query loop with isolated context (cache-safe params preserved) 544 try { 545 for await (const message of query({ 546 messages: initialMessages, 547 systemPrompt, 548 userContext, 549 systemContext, 550 canUseTool, 551 toolUseContext: isolatedToolUseContext, 552 querySource, 553 maxOutputTokensOverride: maxOutputTokens, 554 maxTurns, 555 skipCacheWrite, 556 })) { 557 // Extract real usage from message_delta stream events (final usage per API call) 558 if (message.type === 'stream_event') { 559 if ( 560 'event' in message && 561 message.event?.type === 'message_delta' && 562 message.event.usage 563 ) { 564 const turnUsage = updateUsage({ ...EMPTY_USAGE }, message.event.usage) 565 totalUsage = accumulateUsage(totalUsage, turnUsage) 566 } 567 continue 568 } 569 if (message.type === 'stream_request_start') { 570 continue 571 } 572 573 logForDebugging( 574 `Forked agent [${forkLabel}] received message: type=${message.type}`, 575 ) 576 577 outputMessages.push(message as Message) 578 onMessage?.(message as Message) 579 580 // Record transcript for recordable message types (same pattern as runAgent.ts) 581 const msg = message as Message 582 if ( 583 agentId && 584 (msg.type === 'assistant' || 585 msg.type === 'user' || 586 msg.type === 'progress') 587 ) { 588 await recordSidechainTranscript([msg], agentId, lastRecordedUuid).catch( 589 err => 590 logForDebugging( 591 `Forked agent [${forkLabel}] failed to record transcript: ${err}`, 592 ), 593 ) 594 if (msg.type !== 'progress') { 595 lastRecordedUuid = msg.uuid 596 } 597 } 598 } 599 } finally { 600 // Release cloned file state cache memory (same pattern as runAgent.ts) 601 isolatedToolUseContext.readFileState.clear() 602 // Release the cloned fork context messages 603 initialMessages.length = 0 604 } 605 606 logForDebugging( 607 `Forked agent [${forkLabel}] finished: ${outputMessages.length} messages, types=[${outputMessages.map(m => m.type).join(', ')}], totalUsage: input=${totalUsage.input_tokens} output=${totalUsage.output_tokens} cacheRead=${totalUsage.cache_read_input_tokens} cacheCreate=${totalUsage.cache_creation_input_tokens}`, 608 ) 609 610 const durationMs = Date.now() - startTime 611 612 // Log the fork query metrics with full NonNullableUsage 613 logForkAgentQueryEvent({ 614 forkLabel, 615 querySource, 616 durationMs, 617 messageCount: outputMessages.length, 618 totalUsage, 619 queryTracking: toolUseContext.queryTracking, 620 }) 621 622 return { 623 messages: outputMessages, 624 totalUsage, 625 } 626 } 627 628 /** 629 * Logs the tengu_fork_agent_query event with full NonNullableUsage fields. 630 */ 631 function logForkAgentQueryEvent({ 632 forkLabel, 633 querySource, 634 durationMs, 635 messageCount, 636 totalUsage, 637 queryTracking, 638 }: { 639 forkLabel: string 640 querySource: QuerySource 641 durationMs: number 642 messageCount: number 643 totalUsage: NonNullableUsage 644 queryTracking?: { chainId: string; depth: number } 645 }): void { 646 // Calculate cache hit rate 647 const totalInputTokens = 648 totalUsage.input_tokens + 649 totalUsage.cache_creation_input_tokens + 650 totalUsage.cache_read_input_tokens 651 const cacheHitRate = 652 totalInputTokens > 0 653 ? totalUsage.cache_read_input_tokens / totalInputTokens 654 : 0 655 656 logEvent('tengu_fork_agent_query', { 657 // Metadata 658 forkLabel: 659 forkLabel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 660 querySource: 661 querySource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 662 durationMs, 663 messageCount, 664 665 // NonNullableUsage fields 666 inputTokens: totalUsage.input_tokens, 667 outputTokens: totalUsage.output_tokens, 668 cacheReadInputTokens: totalUsage.cache_read_input_tokens, 669 cacheCreationInputTokens: totalUsage.cache_creation_input_tokens, 670 serviceTier: 671 totalUsage.service_tier as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 672 cacheCreationEphemeral1hTokens: 673 totalUsage.cache_creation.ephemeral_1h_input_tokens, 674 cacheCreationEphemeral5mTokens: 675 totalUsage.cache_creation.ephemeral_5m_input_tokens, 676 677 // Derived metrics 678 cacheHitRate, 679 680 // Query tracking 681 ...(queryTracking 682 ? { 683 queryChainId: 684 queryTracking.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 685 queryDepth: queryTracking.depth, 686 } 687 : {}), 688 }) 689 }