init claude-code
This commit is contained in:
@@ -0,0 +1,689 @@
|
||||
/**
|
||||
* Helper for running forked agent query loops with usage tracking.
|
||||
*
|
||||
* This utility ensures forked agents:
|
||||
* 1. Share identical cache-critical params with the parent to guarantee prompt cache hits
|
||||
* 2. Track full usage metrics across the entire query loop
|
||||
* 3. Log metrics via the tengu_fork_agent_query event when complete
|
||||
* 4. Isolate mutable state to prevent interference with the main agent loop
|
||||
*/
|
||||
|
||||
import type { UUID } from 'crypto'
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { PromptCommand } from '../commands.js'
|
||||
import type { QuerySource } from '../constants/querySource.js'
|
||||
import type { CanUseToolFn } from '../hooks/useCanUseTool.js'
|
||||
import { query } from '../query.js'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../services/analytics/index.js'
|
||||
import { accumulateUsage, updateUsage } from '../services/api/claude.js'
|
||||
import { EMPTY_USAGE, type NonNullableUsage } from '../services/api/logging.js'
|
||||
import type { ToolUseContext } from '../Tool.js'
|
||||
import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'
|
||||
import type { AgentId } from '../types/ids.js'
|
||||
import type { Message } from '../types/message.js'
|
||||
import { createChildAbortController } from './abortController.js'
|
||||
import { logForDebugging } from './debug.js'
|
||||
import { cloneFileStateCache } from './fileStateCache.js'
|
||||
import type { REPLHookContext } from './hooks/postSamplingHooks.js'
|
||||
import {
|
||||
createUserMessage,
|
||||
extractTextContent,
|
||||
getLastAssistantMessage,
|
||||
} from './messages.js'
|
||||
import { createDenialTrackingState } from './permissions/denialTracking.js'
|
||||
import { parseToolListFromCLI } from './permissions/permissionSetup.js'
|
||||
import { recordSidechainTranscript } from './sessionStorage.js'
|
||||
import type { SystemPrompt } from './systemPromptType.js'
|
||||
import {
|
||||
type ContentReplacementState,
|
||||
cloneContentReplacementState,
|
||||
} from './toolResultStorage.js'
|
||||
import { createAgentId } from './uuid.js'
|
||||
|
||||
/**
|
||||
* Parameters that must be identical between the fork and parent API requests
|
||||
* to share the parent's prompt cache. The Anthropic API cache key is composed of:
|
||||
* system prompt, tools, model, messages (prefix), and thinking config.
|
||||
*
|
||||
* CacheSafeParams carries the first five. Thinking config is derived from the
|
||||
* inherited toolUseContext.options.thinkingConfig — but can be inadvertently
|
||||
* changed if the fork sets maxOutputTokens, which clamps budget_tokens in
|
||||
* claude.ts (but only for older models that do not use adaptive thinking).
|
||||
* See the maxOutputTokens doc on ForkedAgentParams.
|
||||
*/
|
||||
export type CacheSafeParams = {
|
||||
/** System prompt - must match parent for cache hits */
|
||||
systemPrompt: SystemPrompt
|
||||
/** User context - prepended to messages, affects cache */
|
||||
userContext: { [k: string]: string }
|
||||
/** System context - appended to system prompt, affects cache */
|
||||
systemContext: { [k: string]: string }
|
||||
/** Tool use context containing tools, model, and other options */
|
||||
toolUseContext: ToolUseContext
|
||||
/** Parent context messages for prompt cache sharing */
|
||||
forkContextMessages: Message[]
|
||||
}
|
||||
|
||||
// Slot written by handleStopHooks after each turn so post-turn forks
|
||||
// (promptSuggestion, postTurnSummary, /btw) can share the main loop's
|
||||
// prompt cache without each caller threading params through.
|
||||
let lastCacheSafeParams: CacheSafeParams | null = null
|
||||
|
||||
export function saveCacheSafeParams(params: CacheSafeParams | null): void {
|
||||
lastCacheSafeParams = params
|
||||
}
|
||||
|
||||
export function getLastCacheSafeParams(): CacheSafeParams | null {
|
||||
return lastCacheSafeParams
|
||||
}
|
||||
|
||||
export type ForkedAgentParams = {
|
||||
/** Messages to start the forked query loop with */
|
||||
promptMessages: Message[]
|
||||
/** Cache-safe parameters that must match the parent query */
|
||||
cacheSafeParams: CacheSafeParams
|
||||
/** Permission check function for the forked agent */
|
||||
canUseTool: CanUseToolFn
|
||||
/** Source identifier for tracking */
|
||||
querySource: QuerySource
|
||||
/** Label for analytics (e.g., 'session_memory', 'supervisor') */
|
||||
forkLabel: string
|
||||
/** Optional overrides for the subagent context (e.g., readFileState from setup phase) */
|
||||
overrides?: SubagentContextOverrides
|
||||
/**
|
||||
* Optional cap on output tokens. CAUTION: setting this changes both max_tokens
|
||||
* AND budget_tokens (via clamping in claude.ts). If the fork uses cacheSafeParams
|
||||
* to share the parent's prompt cache, a different budget_tokens will invalidate
|
||||
* the cache — thinking config is part of the cache key. Only set this when cache
|
||||
* sharing is not a goal (e.g., compact summaries).
|
||||
*/
|
||||
maxOutputTokens?: number
|
||||
/** Optional cap on number of turns (API round-trips) */
|
||||
maxTurns?: number
|
||||
/** Optional callback invoked for each message as it arrives (for streaming UI) */
|
||||
onMessage?: (message: Message) => void
|
||||
/** Skip sidechain transcript recording (e.g., for ephemeral work like speculation) */
|
||||
skipTranscript?: boolean
|
||||
/** Skip writing new prompt cache entries on the last message. For
|
||||
* fire-and-forget forks where no future request will read from this prefix. */
|
||||
skipCacheWrite?: boolean
|
||||
}
|
||||
|
||||
export type ForkedAgentResult = {
|
||||
/** All messages yielded during the query loop */
|
||||
messages: Message[]
|
||||
/** Accumulated usage across all API calls in the loop */
|
||||
totalUsage: NonNullableUsage
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates CacheSafeParams from REPLHookContext.
|
||||
* Use this helper when forking from a post-sampling hook context.
|
||||
*
|
||||
* To override specific fields (e.g., toolUseContext with cloned file state),
|
||||
* spread the result and override: `{ ...createCacheSafeParams(context), toolUseContext: clonedContext }`
|
||||
*
|
||||
* @param context - The REPLHookContext from the post-sampling hook
|
||||
*/
|
||||
export function createCacheSafeParams(
|
||||
context: REPLHookContext,
|
||||
): CacheSafeParams {
|
||||
return {
|
||||
systemPrompt: context.systemPrompt,
|
||||
userContext: context.userContext,
|
||||
systemContext: context.systemContext,
|
||||
toolUseContext: context.toolUseContext,
|
||||
forkContextMessages: context.messages,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a modified getAppState that adds allowed tools to the permission context.
|
||||
* This is used by forked skill/command execution to grant tool permissions.
|
||||
*/
|
||||
export function createGetAppStateWithAllowedTools(
|
||||
baseGetAppState: ToolUseContext['getAppState'],
|
||||
allowedTools: string[],
|
||||
): ToolUseContext['getAppState'] {
|
||||
if (allowedTools.length === 0) return baseGetAppState
|
||||
return () => {
|
||||
const appState = baseGetAppState()
|
||||
return {
|
||||
...appState,
|
||||
toolPermissionContext: {
|
||||
...appState.toolPermissionContext,
|
||||
alwaysAllowRules: {
|
||||
...appState.toolPermissionContext.alwaysAllowRules,
|
||||
command: [
|
||||
...new Set([
|
||||
...(appState.toolPermissionContext.alwaysAllowRules.command ||
|
||||
[]),
|
||||
...allowedTools,
|
||||
]),
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from preparing a forked command context.
|
||||
*/
|
||||
export type PreparedForkedContext = {
|
||||
/** Skill content with args replaced */
|
||||
skillContent: string
|
||||
/** Modified getAppState with allowed tools */
|
||||
modifiedGetAppState: ToolUseContext['getAppState']
|
||||
/** The general-purpose agent to use */
|
||||
baseAgent: AgentDefinition
|
||||
/** Initial prompt messages */
|
||||
promptMessages: Message[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the context for executing a forked command/skill.
|
||||
* This handles the common setup that both SkillTool and slash commands need.
|
||||
*/
|
||||
export async function prepareForkedCommandContext(
|
||||
command: PromptCommand,
|
||||
args: string,
|
||||
context: ToolUseContext,
|
||||
): Promise<PreparedForkedContext> {
|
||||
// Get skill content with $ARGUMENTS replaced
|
||||
const skillPrompt = await command.getPromptForCommand(args, context)
|
||||
const skillContent = skillPrompt
|
||||
.map(block => (block.type === 'text' ? block.text : ''))
|
||||
.join('\n')
|
||||
|
||||
// Parse and prepare allowed tools
|
||||
const allowedTools = parseToolListFromCLI(command.allowedTools ?? [])
|
||||
|
||||
// Create modified context with allowed tools
|
||||
const modifiedGetAppState = createGetAppStateWithAllowedTools(
|
||||
context.getAppState,
|
||||
allowedTools,
|
||||
)
|
||||
|
||||
// Use command.agent if specified, otherwise 'general-purpose'
|
||||
const agentTypeName = command.agent ?? 'general-purpose'
|
||||
const agents = context.options.agentDefinitions.activeAgents
|
||||
const baseAgent =
|
||||
agents.find(a => a.agentType === agentTypeName) ??
|
||||
agents.find(a => a.agentType === 'general-purpose') ??
|
||||
agents[0]
|
||||
|
||||
if (!baseAgent) {
|
||||
throw new Error('No agent available for forked execution')
|
||||
}
|
||||
|
||||
// Prepare prompt messages
|
||||
const promptMessages = [createUserMessage({ content: skillContent })]
|
||||
|
||||
return {
|
||||
skillContent,
|
||||
modifiedGetAppState,
|
||||
baseAgent,
|
||||
promptMessages,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts result text from agent messages.
|
||||
*/
|
||||
export function extractResultText(
|
||||
agentMessages: Message[],
|
||||
defaultText = 'Execution completed',
|
||||
): string {
|
||||
const lastAssistantMessage = getLastAssistantMessage(agentMessages)
|
||||
if (!lastAssistantMessage) return defaultText
|
||||
|
||||
const textContent = extractTextContent(
|
||||
lastAssistantMessage.message.content,
|
||||
'\n',
|
||||
)
|
||||
|
||||
return textContent || defaultText
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a subagent context.
|
||||
*
|
||||
* By default, all mutable state is isolated to prevent interference with the parent.
|
||||
* Use these options to:
|
||||
* - Override specific fields (e.g., custom options, agentId, messages)
|
||||
* - Explicitly opt-in to sharing specific callbacks (for interactive subagents)
|
||||
*/
|
||||
export type SubagentContextOverrides = {
|
||||
/** Override the options object (e.g., custom tools, model) */
|
||||
options?: ToolUseContext['options']
|
||||
/** Override the agentId (for subagents with their own ID) */
|
||||
agentId?: AgentId
|
||||
/** Override the agentType (for subagents with a specific type) */
|
||||
agentType?: string
|
||||
/** Override the messages array */
|
||||
messages?: Message[]
|
||||
/** Override the readFileState (e.g., fresh cache instead of clone) */
|
||||
readFileState?: ToolUseContext['readFileState']
|
||||
/** Override the abortController */
|
||||
abortController?: AbortController
|
||||
/** Override the getAppState function */
|
||||
getAppState?: ToolUseContext['getAppState']
|
||||
|
||||
/**
|
||||
* Explicit opt-in to share parent's setAppState callback.
|
||||
* Use for interactive subagents that need to update shared state.
|
||||
* @default false (isolated no-op)
|
||||
*/
|
||||
shareSetAppState?: boolean
|
||||
/**
|
||||
* Explicit opt-in to share parent's setResponseLength callback.
|
||||
* Use for subagents that contribute to parent's response metrics.
|
||||
* @default false (isolated no-op)
|
||||
*/
|
||||
shareSetResponseLength?: boolean
|
||||
/**
|
||||
* Explicit opt-in to share parent's abortController.
|
||||
* Use for interactive subagents that should abort with parent.
|
||||
* Note: Only applies if abortController override is not provided.
|
||||
* @default false (new controller linked to parent)
|
||||
*/
|
||||
shareAbortController?: boolean
|
||||
/** Critical system reminder to re-inject at every user turn */
|
||||
criticalSystemReminder_EXPERIMENTAL?: string
|
||||
/** When true, canUseTool must always be called even when hooks auto-approve.
|
||||
* Used by speculation for overlay file path rewriting. */
|
||||
requireCanUseTool?: boolean
|
||||
/** Override replacement state — used by resumeAgentBackground to thread
|
||||
* state reconstructed from the resumed sidechain so the same results
|
||||
* are re-replaced (prompt cache stability). */
|
||||
contentReplacementState?: ContentReplacementState
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an isolated ToolUseContext for subagents.
|
||||
*
|
||||
* By default, ALL mutable state is isolated to prevent interference:
|
||||
* - readFileState: cloned from parent
|
||||
* - abortController: new controller linked to parent (parent abort propagates)
|
||||
* - getAppState: wrapped to set shouldAvoidPermissionPrompts
|
||||
* - All mutation callbacks (setAppState, etc.): no-op
|
||||
* - Fresh collections: nestedMemoryAttachmentTriggers, toolDecisions
|
||||
*
|
||||
* Callers can:
|
||||
* - Override specific fields via the overrides parameter
|
||||
* - Explicitly opt-in to sharing specific callbacks (shareSetAppState, etc.)
|
||||
*
|
||||
* @param parentContext - The parent's ToolUseContext to create subagent context from
|
||||
* @param overrides - Optional overrides and sharing options
|
||||
*
|
||||
* @example
|
||||
* // Full isolation (for background agents like session memory)
|
||||
* const ctx = createSubagentContext(parentContext)
|
||||
*
|
||||
* @example
|
||||
* // Custom options and agentId (for AgentTool async agents)
|
||||
* const ctx = createSubagentContext(parentContext, {
|
||||
* options: customOptions,
|
||||
* agentId: newAgentId,
|
||||
* messages: initialMessages,
|
||||
* })
|
||||
*
|
||||
* @example
|
||||
* // Interactive subagent that shares some state
|
||||
* const ctx = createSubagentContext(parentContext, {
|
||||
* options: customOptions,
|
||||
* agentId: newAgentId,
|
||||
* shareSetAppState: true,
|
||||
* shareSetResponseLength: true,
|
||||
* shareAbortController: true,
|
||||
* })
|
||||
*/
|
||||
export function createSubagentContext(
|
||||
parentContext: ToolUseContext,
|
||||
overrides?: SubagentContextOverrides,
|
||||
): ToolUseContext {
|
||||
// Determine abortController: explicit override > share parent's > new child
|
||||
const abortController =
|
||||
overrides?.abortController ??
|
||||
(overrides?.shareAbortController
|
||||
? parentContext.abortController
|
||||
: createChildAbortController(parentContext.abortController))
|
||||
|
||||
// Determine getAppState - wrap to set shouldAvoidPermissionPrompts unless sharing abortController
|
||||
// (if sharing abortController, it's an interactive agent that CAN show UI)
|
||||
const getAppState: ToolUseContext['getAppState'] = overrides?.getAppState
|
||||
? overrides.getAppState
|
||||
: overrides?.shareAbortController
|
||||
? parentContext.getAppState
|
||||
: () => {
|
||||
const state = parentContext.getAppState()
|
||||
if (state.toolPermissionContext.shouldAvoidPermissionPrompts) {
|
||||
return state
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toolPermissionContext: {
|
||||
...state.toolPermissionContext,
|
||||
shouldAvoidPermissionPrompts: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// Mutable state - cloned by default to maintain isolation
|
||||
// Clone overrides.readFileState if provided, otherwise clone from parent
|
||||
readFileState: cloneFileStateCache(
|
||||
overrides?.readFileState ?? parentContext.readFileState,
|
||||
),
|
||||
nestedMemoryAttachmentTriggers: new Set<string>(),
|
||||
loadedNestedMemoryPaths: new Set<string>(),
|
||||
dynamicSkillDirTriggers: new Set<string>(),
|
||||
// Per-subagent: tracks skills surfaced by discovery for was_discovered telemetry (SkillTool.ts:116)
|
||||
discoveredSkillNames: new Set<string>(),
|
||||
toolDecisions: undefined,
|
||||
// Budget decisions: override > clone of parent > undefined (feature off).
|
||||
//
|
||||
// Clone by default (not fresh): cache-sharing forks process parent
|
||||
// messages containing parent tool_use_ids. A fresh state would see
|
||||
// them as unseen and make divergent replacement decisions → wire
|
||||
// prefix differs → cache miss. A clone makes identical decisions →
|
||||
// cache hit. For non-forking subagents the parent UUIDs never match
|
||||
// — clone is a harmless no-op.
|
||||
//
|
||||
// Override: AgentTool resume (reconstructed from sidechain records)
|
||||
// and inProcessRunner (per-teammate persistent loop state).
|
||||
contentReplacementState:
|
||||
overrides?.contentReplacementState ??
|
||||
(parentContext.contentReplacementState
|
||||
? cloneContentReplacementState(parentContext.contentReplacementState)
|
||||
: undefined),
|
||||
|
||||
// AbortController
|
||||
abortController,
|
||||
|
||||
// AppState access
|
||||
getAppState,
|
||||
setAppState: overrides?.shareSetAppState
|
||||
? parentContext.setAppState
|
||||
: () => {},
|
||||
// Task registration/kill must always reach the root store, even when
|
||||
// setAppState is a no-op — otherwise async agents' background bash tasks
|
||||
// are never registered and never killed (PPID=1 zombie).
|
||||
setAppStateForTasks:
|
||||
parentContext.setAppStateForTasks ?? parentContext.setAppState,
|
||||
// Async subagents whose setAppState is a no-op need local denial tracking
|
||||
// so the denial counter actually accumulates across retries.
|
||||
localDenialTracking: overrides?.shareSetAppState
|
||||
? parentContext.localDenialTracking
|
||||
: createDenialTrackingState(),
|
||||
|
||||
// Mutation callbacks - no-op by default
|
||||
setInProgressToolUseIDs: () => {},
|
||||
setResponseLength: overrides?.shareSetResponseLength
|
||||
? parentContext.setResponseLength
|
||||
: () => {},
|
||||
pushApiMetricsEntry: overrides?.shareSetResponseLength
|
||||
? parentContext.pushApiMetricsEntry
|
||||
: undefined,
|
||||
updateFileHistoryState: () => {},
|
||||
// Attribution is scoped and functional (prev => next) — safe to share even
|
||||
// when setAppState is stubbed. Concurrent calls compose via React's state queue.
|
||||
updateAttributionState: parentContext.updateAttributionState,
|
||||
|
||||
// UI callbacks - undefined for subagents (can't control parent UI)
|
||||
addNotification: undefined,
|
||||
setToolJSX: undefined,
|
||||
setStreamMode: undefined,
|
||||
setSDKStatus: undefined,
|
||||
openMessageSelector: undefined,
|
||||
|
||||
// Fields that can be overridden or copied from parent
|
||||
options: overrides?.options ?? parentContext.options,
|
||||
messages: overrides?.messages ?? parentContext.messages,
|
||||
// Generate new agentId for subagents (each subagent should have its own ID)
|
||||
agentId: overrides?.agentId ?? createAgentId(),
|
||||
agentType: overrides?.agentType,
|
||||
|
||||
// Create new query tracking chain for subagent with incremented depth
|
||||
queryTracking: {
|
||||
chainId: randomUUID(),
|
||||
depth: (parentContext.queryTracking?.depth ?? -1) + 1,
|
||||
},
|
||||
fileReadingLimits: parentContext.fileReadingLimits,
|
||||
userModified: parentContext.userModified,
|
||||
criticalSystemReminder_EXPERIMENTAL:
|
||||
overrides?.criticalSystemReminder_EXPERIMENTAL,
|
||||
requireCanUseTool: overrides?.requireCanUseTool,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a forked agent query loop and tracks cache hit metrics.
|
||||
*
|
||||
* This function:
|
||||
* 1. Uses identical cache-safe params from parent to enable prompt caching
|
||||
* 2. Accumulates usage across all query iterations
|
||||
* 3. Logs tengu_fork_agent_query with full usage when complete
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await runForkedAgent({
|
||||
* promptMessages: [createUserMessage({ content: userPrompt })],
|
||||
* cacheSafeParams: {
|
||||
* systemPrompt,
|
||||
* userContext,
|
||||
* systemContext,
|
||||
* toolUseContext: clonedToolUseContext,
|
||||
* forkContextMessages: messages,
|
||||
* },
|
||||
* canUseTool,
|
||||
* querySource: 'session_memory',
|
||||
* forkLabel: 'session_memory',
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export async function runForkedAgent({
|
||||
promptMessages,
|
||||
cacheSafeParams,
|
||||
canUseTool,
|
||||
querySource,
|
||||
forkLabel,
|
||||
overrides,
|
||||
maxOutputTokens,
|
||||
maxTurns,
|
||||
onMessage,
|
||||
skipTranscript,
|
||||
skipCacheWrite,
|
||||
}: ForkedAgentParams): Promise<ForkedAgentResult> {
|
||||
const startTime = Date.now()
|
||||
const outputMessages: Message[] = []
|
||||
let totalUsage: NonNullableUsage = { ...EMPTY_USAGE }
|
||||
|
||||
const {
|
||||
systemPrompt,
|
||||
userContext,
|
||||
systemContext,
|
||||
toolUseContext,
|
||||
forkContextMessages,
|
||||
} = cacheSafeParams
|
||||
|
||||
// Create isolated context to prevent mutation of parent state
|
||||
const isolatedToolUseContext = createSubagentContext(
|
||||
toolUseContext,
|
||||
overrides,
|
||||
)
|
||||
|
||||
// Do NOT filterIncompleteToolCalls here — it drops the whole assistant on
|
||||
// partial tool batches, orphaning the paired results (API 400). Dangling
|
||||
// tool_uses are repaired downstream by ensureToolResultPairing in claude.ts,
|
||||
// same as the main thread — identical post-repair prefix keeps the cache hit.
|
||||
const initialMessages: Message[] = [...forkContextMessages, ...promptMessages]
|
||||
|
||||
// Generate agent ID and record initial messages for transcript
|
||||
// When skipTranscript is set, skip agent ID creation and all transcript I/O
|
||||
const agentId = skipTranscript ? undefined : createAgentId(forkLabel)
|
||||
let lastRecordedUuid: UUID | null = null
|
||||
if (agentId) {
|
||||
await recordSidechainTranscript(initialMessages, agentId).catch(err =>
|
||||
logForDebugging(
|
||||
`Forked agent [${forkLabel}] failed to record initial transcript: ${err}`,
|
||||
),
|
||||
)
|
||||
// Track the last recorded message UUID for parent chain continuity
|
||||
lastRecordedUuid =
|
||||
initialMessages.length > 0
|
||||
? initialMessages[initialMessages.length - 1]!.uuid
|
||||
: null
|
||||
}
|
||||
|
||||
// Run the query loop with isolated context (cache-safe params preserved)
|
||||
try {
|
||||
for await (const message of query({
|
||||
messages: initialMessages,
|
||||
systemPrompt,
|
||||
userContext,
|
||||
systemContext,
|
||||
canUseTool,
|
||||
toolUseContext: isolatedToolUseContext,
|
||||
querySource,
|
||||
maxOutputTokensOverride: maxOutputTokens,
|
||||
maxTurns,
|
||||
skipCacheWrite,
|
||||
})) {
|
||||
// Extract real usage from message_delta stream events (final usage per API call)
|
||||
if (message.type === 'stream_event') {
|
||||
if (
|
||||
'event' in message &&
|
||||
message.event?.type === 'message_delta' &&
|
||||
message.event.usage
|
||||
) {
|
||||
const turnUsage = updateUsage({ ...EMPTY_USAGE }, message.event.usage)
|
||||
totalUsage = accumulateUsage(totalUsage, turnUsage)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (message.type === 'stream_request_start') {
|
||||
continue
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`Forked agent [${forkLabel}] received message: type=${message.type}`,
|
||||
)
|
||||
|
||||
outputMessages.push(message as Message)
|
||||
onMessage?.(message as Message)
|
||||
|
||||
// Record transcript for recordable message types (same pattern as runAgent.ts)
|
||||
const msg = message as Message
|
||||
if (
|
||||
agentId &&
|
||||
(msg.type === 'assistant' ||
|
||||
msg.type === 'user' ||
|
||||
msg.type === 'progress')
|
||||
) {
|
||||
await recordSidechainTranscript([msg], agentId, lastRecordedUuid).catch(
|
||||
err =>
|
||||
logForDebugging(
|
||||
`Forked agent [${forkLabel}] failed to record transcript: ${err}`,
|
||||
),
|
||||
)
|
||||
if (msg.type !== 'progress') {
|
||||
lastRecordedUuid = msg.uuid
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Release cloned file state cache memory (same pattern as runAgent.ts)
|
||||
isolatedToolUseContext.readFileState.clear()
|
||||
// Release the cloned fork context messages
|
||||
initialMessages.length = 0
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`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}`,
|
||||
)
|
||||
|
||||
const durationMs = Date.now() - startTime
|
||||
|
||||
// Log the fork query metrics with full NonNullableUsage
|
||||
logForkAgentQueryEvent({
|
||||
forkLabel,
|
||||
querySource,
|
||||
durationMs,
|
||||
messageCount: outputMessages.length,
|
||||
totalUsage,
|
||||
queryTracking: toolUseContext.queryTracking,
|
||||
})
|
||||
|
||||
return {
|
||||
messages: outputMessages,
|
||||
totalUsage,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs the tengu_fork_agent_query event with full NonNullableUsage fields.
|
||||
*/
|
||||
function logForkAgentQueryEvent({
|
||||
forkLabel,
|
||||
querySource,
|
||||
durationMs,
|
||||
messageCount,
|
||||
totalUsage,
|
||||
queryTracking,
|
||||
}: {
|
||||
forkLabel: string
|
||||
querySource: QuerySource
|
||||
durationMs: number
|
||||
messageCount: number
|
||||
totalUsage: NonNullableUsage
|
||||
queryTracking?: { chainId: string; depth: number }
|
||||
}): void {
|
||||
// Calculate cache hit rate
|
||||
const totalInputTokens =
|
||||
totalUsage.input_tokens +
|
||||
totalUsage.cache_creation_input_tokens +
|
||||
totalUsage.cache_read_input_tokens
|
||||
const cacheHitRate =
|
||||
totalInputTokens > 0
|
||||
? totalUsage.cache_read_input_tokens / totalInputTokens
|
||||
: 0
|
||||
|
||||
logEvent('tengu_fork_agent_query', {
|
||||
// Metadata
|
||||
forkLabel:
|
||||
forkLabel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
querySource:
|
||||
querySource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
durationMs,
|
||||
messageCount,
|
||||
|
||||
// NonNullableUsage fields
|
||||
inputTokens: totalUsage.input_tokens,
|
||||
outputTokens: totalUsage.output_tokens,
|
||||
cacheReadInputTokens: totalUsage.cache_read_input_tokens,
|
||||
cacheCreationInputTokens: totalUsage.cache_creation_input_tokens,
|
||||
serviceTier:
|
||||
totalUsage.service_tier as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
cacheCreationEphemeral1hTokens:
|
||||
totalUsage.cache_creation.ephemeral_1h_input_tokens,
|
||||
cacheCreationEphemeral5mTokens:
|
||||
totalUsage.cache_creation.ephemeral_5m_input_tokens,
|
||||
|
||||
// Derived metrics
|
||||
cacheHitRate,
|
||||
|
||||
// Query tracking
|
||||
...(queryTracking
|
||||
? {
|
||||
queryChainId:
|
||||
queryTracking.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
queryDepth: queryTracking.depth,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user