/ utils / forkedAgent.ts
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  }