forkedAgent.ts 24 KB

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