diff --git a/CLAUDE.md b/CLAUDE.md index 6604b9357..838a2061d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,9 @@ bun run health # Check unused exports bun run check:unused +# Full check (typecheck + lint + test) — run after completing any task +bun run test:all + bun run typecheck # Remote Control Server diff --git a/build.ts b/build.ts index 9b7817cc0..6a4c9321e 100644 --- a/build.ts +++ b/build.ts @@ -1,6 +1,7 @@ import { readdir, readFile, writeFile, cp } from 'fs/promises' import { join } from 'path' import { getMacroDefines } from './scripts/defines.ts' +import { DEFAULT_BUILD_FEATURES } from './scripts/defines.ts' const outdir = 'dist' @@ -8,48 +9,6 @@ const outdir = 'dist' const { rmSync } = await import('fs') rmSync(outdir, { recursive: true, force: true }) -// Default features that match the official CLI build. -// Additional features can be enabled via FEATURE_=1 env vars. -const DEFAULT_BUILD_FEATURES = [ - 'BUDDY', 'TRANSCRIPT_CLASSIFIER', 'BRIDGE_MODE', - 'AGENT_TRIGGERS_REMOTE', - 'CHICAGO_MCP', - 'VOICE_MODE', - 'SHOT_STATS', - 'PROMPT_CACHE_BREAK_DETECTION', - 'TOKEN_BUDGET', - // P0: local features - 'AGENT_TRIGGERS', - 'ULTRATHINK', - 'BUILTIN_EXPLORE_PLAN_AGENTS', - 'LODESTONE', - // P1: API-dependent features - 'EXTRACT_MEMORIES', - 'VERIFICATION_AGENT', - 'KAIROS_BRIEF', - 'AWAY_SUMMARY', - 'ULTRAPLAN', - // P2: daemon + remote control server - 'DAEMON', - // ACP (Agent Client Protocol) agent mode - 'ACP', - // PR-package restored features - 'WORKFLOW_SCRIPTS', - 'HISTORY_SNIP', - 'CONTEXT_COLLAPSE', - 'MONITOR_TOOL', - 'FORK_SUBAGENT', -// 'UDS_INBOX', - 'KAIROS', - 'COORDINATOR_MODE', - 'LAN_PIPES', - 'BG_SESSIONS', - 'TEMPLATES', - // 'REVIEW_ARTIFACT', // API 请求无响应,需进一步排查 schema 兼容性 - // P3: poor mode (disable extract_memories + prompt_suggestion) - 'POOR', -] - // Collect FEATURE_* env vars → Bun.build features const envFeatures = Object.keys(process.env) .filter(k => k.startsWith('FEATURE_')) diff --git a/package.json b/package.json index f86da6a01..833a9bb9b 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "postinstall": "node scripts/run-parallel.mjs scripts/postinstall.cjs scripts/setup-chrome-mcp.mjs", "docs:dev": "npx mintlify dev", "typecheck": "tsc --noEmit", + "test:all": "bun run typecheck && bun test", "rcs": "bun run scripts/rcs.ts" }, "dependencies": { diff --git a/packages/audio-capture-napi/src/index.ts b/packages/audio-capture-napi/src/index.ts index d511f924d..78b161604 100644 --- a/packages/audio-capture-napi/src/index.ts +++ b/packages/audio-capture-napi/src/index.ts @@ -1,3 +1,9 @@ +import { createRequire } from 'node:module' + +// createRequire works in both Bun and Node.js ESM contexts. +// Needed because this package is "type": "module" but uses require() for +// loading native .node addons — bare require is not available in Node.js ESM. +const nodeRequire = createRequire(import.meta.url) type AudioCaptureNapi = { startRecording( @@ -41,7 +47,7 @@ function loadModule(): AudioCaptureNapi | null { if (process.env.AUDIO_CAPTURE_NODE_PATH) { try { // eslint-disable-next-line @typescript-eslint/no-require-imports - cachedModule = require( + cachedModule = nodeRequire( process.env.AUDIO_CAPTURE_NODE_PATH, ) as AudioCaptureNapi return cachedModule @@ -63,7 +69,7 @@ function loadModule(): AudioCaptureNapi | null { for (const p of fallbacks) { try { // eslint-disable-next-line @typescript-eslint/no-require-imports - cachedModule = require(p) as AudioCaptureNapi + cachedModule = nodeRequire(p) as AudioCaptureNapi return cachedModule } catch { // try next diff --git a/packages/color-diff-napi/src/index.ts b/packages/color-diff-napi/src/index.ts index afaf924ea..9b662b6d1 100644 --- a/packages/color-diff-napi/src/index.ts +++ b/packages/color-diff-napi/src/index.ts @@ -17,10 +17,16 @@ * getSyntaxTheme always returns the default for the given Claude theme. */ +import { createRequire } from 'node:module' import { diffArrays } from 'diff' import type * as hljsNamespace from 'highlight.js' import { basename, extname } from 'path' +// createRequire works in both Bun and Node.js ESM contexts. +// Needed because this package is "type": "module" but uses require() for +// lazy loading — bare require is not available in Node.js ESM. +const nodeRequire = createRequire(import.meta.url) + // Lazy: defers loading highlight.js until first render. The full bundle // registers 190+ language grammars at require time (~50MB, 100-200ms on // macOS, several× that on Windows). With a top-level import, any caller @@ -34,8 +40,7 @@ type HLJSApi = typeof hljsNamespace.default let cachedHljs: HLJSApi | null = null function hljs(): HLJSApi { if (cachedHljs) return cachedHljs - // eslint-disable-next-line @typescript-eslint/no-require-imports - const mod = require('highlight.js') + const mod = nodeRequire('highlight.js') // highlight.js uses `export =` (CJS). Under bun/ESM the interop wraps it // in .default; under node CJS the module IS the API. Check at runtime. cachedHljs = 'default' in mod && mod.default ? mod.default : mod diff --git a/packages/image-processor-napi/src/index.ts b/packages/image-processor-napi/src/index.ts index ee90c3a2b..657701f0b 100644 --- a/packages/image-processor-napi/src/index.ts +++ b/packages/image-processor-napi/src/index.ts @@ -1,3 +1,4 @@ +import { readFileSync, unlinkSync } from 'node:fs' import sharpModule from 'sharp' export const sharp = sharpModule @@ -62,13 +63,11 @@ return "${tmpPath}" } const file = Bun.file(tmpPath) - // Use synchronous read via Node compat - const fs = require('fs') - const buffer: Buffer = fs.readFileSync(tmpPath) + const buffer: Buffer = readFileSync(tmpPath) // Clean up temp file try { - fs.unlinkSync(tmpPath) + unlinkSync(tmpPath) } catch { // ignore cleanup errors } diff --git a/scripts/defines.ts b/scripts/defines.ts index 33ed2b564..156773db7 100644 --- a/scripts/defines.ts +++ b/scripts/defines.ts @@ -16,3 +16,52 @@ export function getMacroDefines(): Record { "MACRO.VERSION_CHANGELOG": JSON.stringify(""), }; } + +/** + * Default feature flags enabled in both Bun.build and Vite builds. + * Additional features can be enabled via FEATURE_=1 env vars. + * + * Used by: + * - build.ts (Bun.build) + * - scripts/vite-plugin-feature-flags.ts (Vite/Rollup) + * - scripts/dev.ts (bun run dev) + */ +export const DEFAULT_BUILD_FEATURES = [ + 'BUDDY', 'TRANSCRIPT_CLASSIFIER', 'BRIDGE_MODE', + 'AGENT_TRIGGERS_REMOTE', + 'CHICAGO_MCP', + 'VOICE_MODE', + 'SHOT_STATS', + 'PROMPT_CACHE_BREAK_DETECTION', + 'TOKEN_BUDGET', + // P0: local features + 'AGENT_TRIGGERS', + 'ULTRATHINK', + 'BUILTIN_EXPLORE_PLAN_AGENTS', + 'LODESTONE', + // P1: API-dependent features + 'EXTRACT_MEMORIES', + 'VERIFICATION_AGENT', + 'KAIROS_BRIEF', + 'AWAY_SUMMARY', + 'ULTRAPLAN', + // P2: daemon + remote control server + 'DAEMON', + // ACP (Agent Client Protocol) agent mode + 'ACP', + // PR-package restored features + 'WORKFLOW_SCRIPTS', + 'HISTORY_SNIP', + 'CONTEXT_COLLAPSE', + 'MONITOR_TOOL', + 'FORK_SUBAGENT', + // 'UDS_INBOX', + 'KAIROS', + 'COORDINATOR_MODE', + 'LAN_PIPES', + 'BG_SESSIONS', + 'TEMPLATES', + // 'REVIEW_ARTIFACT', // API 请求无响应,需进一步排查 schema 兼容性 + // P3: poor mode (disable extract_memories + prompt_suggestion) + 'POOR', +] as const; diff --git a/scripts/dev.ts b/scripts/dev.ts index 954274833..d11d0f6a3 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -6,7 +6,7 @@ */ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { getMacroDefines } from "./defines.ts"; +import { getMacroDefines, DEFAULT_BUILD_FEATURES } from "./defines.ts"; // Resolve project root from this script's location const __filename = fileURLToPath(import.meta.url); @@ -22,39 +22,7 @@ const defineArgs = Object.entries(defines).flatMap(([k, v]) => [ ]); // Bun --feature flags: enable feature() gates at runtime. -// Default features enabled in dev mode. -const DEFAULT_FEATURES = [ - "BUDDY", "TRANSCRIPT_CLASSIFIER", "BRIDGE_MODE", - "AGENT_TRIGGERS_REMOTE", "CHICAGO_MCP", "VOICE_MODE", - "SHOT_STATS", "PROMPT_CACHE_BREAK_DETECTION", "TOKEN_BUDGET", - // P0: local features - "AGENT_TRIGGERS", - "ULTRATHINK", - "BUILTIN_EXPLORE_PLAN_AGENTS", - "LODESTONE", - // P1: API-dependent features - "EXTRACT_MEMORIES", "VERIFICATION_AGENT", - "KAIROS_BRIEF", "AWAY_SUMMARY", "ULTRAPLAN", - // P2: daemon + remote control server - "DAEMON", - // ACP (Agent Client Protocol) agent mode - "ACP", - // PR-package restored features - "WORKFLOW_SCRIPTS", - "HISTORY_SNIP", - "CONTEXT_COLLAPSE", - "MONITOR_TOOL", - "FORK_SUBAGENT", - "UDS_INBOX", - "KAIROS", - "COORDINATOR_MODE", - "LAN_PIPES", - "BG_SESSIONS", - "TEMPLATES", - // "REVIEW_ARTIFACT", // API 请求无响应,需进一步排查 schema 兼容性 - // P3: poor mode (disable extract_memories + prompt_suggestion) - "POOR", -]; +// Uses the shared DEFAULT_BUILD_FEATURES list from defines.ts. // Any env var matching FEATURE_=1 will also enable that feature. // e.g. FEATURE_PROACTIVE=1 bun run dev @@ -62,7 +30,7 @@ const envFeatures = Object.entries(process.env) .filter(([k]) => k.startsWith("FEATURE_")) .map(([k]) => k.replace("FEATURE_", "")); -const allFeatures = [...new Set([...DEFAULT_FEATURES, ...envFeatures])]; +const allFeatures = [...new Set([...DEFAULT_BUILD_FEATURES, ...envFeatures])]; const featureArgs = allFeatures.flatMap((name) => ["--feature", name]); // If BUN_INSPECT is set, pass --inspect-wait to the child process diff --git a/scripts/vite-plugin-feature-flags.ts b/scripts/vite-plugin-feature-flags.ts index 3769a57de..b401a09b6 100644 --- a/scripts/vite-plugin-feature-flags.ts +++ b/scripts/vite-plugin-feature-flags.ts @@ -1,41 +1,5 @@ import type { Plugin } from "rollup"; - -/** - * Default features that match the official CLI build. - * Additional features can be enabled via FEATURE_=1 env vars. - */ -const DEFAULT_BUILD_FEATURES = [ - "AGENT_TRIGGERS_REMOTE", - "CHICAGO_MCP", - "VOICE_MODE", - "SHOT_STATS", - "PROMPT_CACHE_BREAK_DETECTION", - "TOKEN_BUDGET", - // P0: local features - "AGENT_TRIGGERS", - "ULTRATHINK", - "BUILTIN_EXPLORE_PLAN_AGENTS", - "LODESTONE", - // P1: API-dependent features - "EXTRACT_MEMORIES", - "VERIFICATION_AGENT", - "KAIROS_BRIEF", - "AWAY_SUMMARY", - "ULTRAPLAN", - // P2: daemon + remote control server - "DAEMON", - // PR-package restored features - "WORKFLOW_SCRIPTS", - "HISTORY_SNIP", - "CONTEXT_COLLAPSE", - "MONITOR_TOOL", - "FORK_SUBAGENT", - "KAIROS", - "COORDINATOR_MODE", - "LAN_PIPES", - // P3: poor mode - "POOR", -]; +import { DEFAULT_BUILD_FEATURES } from "./defines.ts"; /** * Collect enabled feature flags from defaults + env vars. diff --git a/src/memdir/findRelevantMemories.ts b/src/memdir/findRelevantMemories.ts index c239e0a3e..843ede3af 100644 --- a/src/memdir/findRelevantMemories.ts +++ b/src/memdir/findRelevantMemories.ts @@ -3,6 +3,7 @@ import { logForDebugging } from '../utils/debug.js' import { errorMessage } from '../utils/errors.js' import { getDefaultSonnetModel } from '../utils/model/model.js' import { sideQuery } from '../utils/sideQuery.js' +import type { LangfuseSpan } from '../services/langfuse/index.js' import { jsonParse } from '../utils/slowOperations.js' import { formatMemoryManifest, @@ -42,6 +43,7 @@ export async function findRelevantMemories( signal: AbortSignal, recentTools: readonly string[] = [], alreadySurfaced: ReadonlySet = new Set(), + parentSpan?: LangfuseSpan | null, ): Promise { const memories = (await scanMemoryFiles(memoryDir, signal)).filter( m => !alreadySurfaced.has(m.filePath), @@ -55,6 +57,7 @@ export async function findRelevantMemories( memories, signal, recentTools, + parentSpan, ) const byFilename = new Map(memories.map(m => [m.filename, m])) const selected = selectedFilenames @@ -79,6 +82,7 @@ async function selectRelevantMemories( memories: MemoryHeader[], signal: AbortSignal, recentTools: readonly string[], + parentSpan?: LangfuseSpan | null, ): Promise { const validFilenames = new Set(memories.map(m => m.filename)) @@ -119,6 +123,8 @@ async function selectRelevantMemories( }, signal, querySource: 'memdir_relevance', + optional: true, + parentSpan, }) const textBlock = result.content.find(block => block.type === 'text') diff --git a/src/services/langfuse/index.ts b/src/services/langfuse/index.ts index 6d044fa5a..d4f3e70f2 100644 --- a/src/services/langfuse/index.ts +++ b/src/services/langfuse/index.ts @@ -1,4 +1,4 @@ export { initLangfuse, shutdownLangfuse, isLangfuseEnabled, getLangfuseProcessor } from './client.js' -export { createTrace, createSubagentTrace, recordLLMObservation, recordToolObservation, endTrace, createToolBatchSpan, endToolBatchSpan } from './tracing.js' +export { createTrace, createSubagentTrace, createChildSpan, recordLLMObservation, recordToolObservation, endTrace, createToolBatchSpan, endToolBatchSpan } from './tracing.js' export type { LangfuseSpan } from './tracing.js' export { sanitizeToolInput, sanitizeToolOutput, sanitizeGlobal } from './sanitize.js' diff --git a/src/services/langfuse/tracing.ts b/src/services/langfuse/tracing.ts index a61acbff1..da6ed00d1 100644 --- a/src/services/langfuse/tracing.ts +++ b/src/services/langfuse/tracing.ts @@ -282,6 +282,60 @@ export function createSubagentTrace(params: { } } +/** + * Create a child span under a parent trace — used for side queries + * that should be nested under the main agent trace in Langfuse. + */ +export function createChildSpan( + parentSpan: LangfuseSpan | null, + params: { + name: string + sessionId: string + model: string + provider: string + input?: unknown + querySource?: string + username?: string + }, +): LangfuseSpan | null { + if (!parentSpan || !isLangfuseEnabled()) return null + try { + const span = startObservation( + params.name, + { + input: params.input, + metadata: { + provider: params.provider, + model: params.model, + querySource: params.querySource, + }, + }, + { + asType: 'span', + parentSpanContext: parentSpan.otelSpan.spanContext(), + }, + ) as LangfuseSpan + + // Propagate session ID and user ID from parent + const parent = parentSpan as unknown as RootTrace + const sessionId = parent._sessionId ?? params.sessionId + if (sessionId) { + span.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, sessionId) + ;(span as unknown as RootTrace)._sessionId = sessionId + } + const userId = parent._userId ?? resolveLangfuseUserId(params.username) + if (userId) { + span.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_USER_ID, userId) + ;(span as unknown as RootTrace)._userId = userId + } + logForDebugging(`[langfuse] Child span created: ${span.id} (parent=${parentSpan.id})`) + return span + } catch (e) { + logForDebugging(`[langfuse] createChildSpan failed: ${e}`, { level: 'error' }) + return null + } +} + export function endTrace( rootSpan: LangfuseSpan | null, output?: unknown, diff --git a/src/utils/attachments.ts b/src/utils/attachments.ts index eccd59ebd..e0da3c1a3 100644 --- a/src/utils/attachments.ts +++ b/src/utils/attachments.ts @@ -2201,6 +2201,7 @@ async function getRelevantMemoryAttachments( recentTools: readonly string[], signal: AbortSignal, alreadySurfaced: ReadonlySet, + parentSpan?: unknown, ): Promise { // If an agent is @-mentioned, search only its memory dir (isolation). // Otherwise search the auto-memory dir. @@ -2221,6 +2222,7 @@ async function getRelevantMemoryAttachments( signal, recentTools, alreadySurfaced, + parentSpan as Parameters[5], ).catch(() => []), ), ) @@ -2370,6 +2372,12 @@ export function startRelevantMemoryPrefetch( return undefined } + // Poor mode: skip the side-query to save tokens + const { isPoorModeActive } = require('../commands/poor/poorMode.js') as typeof import('../commands/poor/poorMode.js') + if (isPoorModeActive()) { + return undefined + } + const lastUserMessage = messages.findLast(m => m.type === 'user' && !m.isMeta) if (!lastUserMessage) { return undefined @@ -2397,6 +2405,7 @@ export function startRelevantMemoryPrefetch( collectRecentSuccessfulTools(messages, lastUserMessage), controller.signal, surfaced.paths, + toolUseContext.langfuseTrace, ).catch(e => { if (!isAbortError(e)) { logError(e) diff --git a/src/utils/context.ts b/src/utils/context.ts index 5ec51871c..51ea548d2 100644 --- a/src/utils/context.ts +++ b/src/utils/context.ts @@ -133,6 +133,12 @@ export function calculateContextPercentages( currentUsage.cache_creation_input_tokens + currentUsage.cache_read_input_tokens + // Treat zero input tokens the same as no usage data — avoids flashing + // "ctx:0%" when a third-party API omits usage from message_start. + if (totalInputTokens === 0) { + return { used: null, remaining: null } + } + const usedPercentage = Math.round( (totalInputTokens / contextWindowSize) * 100, ) diff --git a/src/utils/model/model.ts b/src/utils/model/model.ts index 791daeb6b..a7e93098a 100644 --- a/src/utils/model/model.ts +++ b/src/utils/model/model.ts @@ -126,6 +126,12 @@ export function getDefaultOpusModel(): ModelName { if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL) { return process.env.ANTHROPIC_DEFAULT_OPUS_MODEL } + // Fall back to user's configured model — custom providers may not + // recognize hardcoded Anthropic model IDs. + const userSpecifiedOpus = getUserSpecifiedModelSetting() + if (userSpecifiedOpus) { + return parseUserSpecifiedModel(userSpecifiedOpus) + } // 3P providers (Bedrock, Vertex, Foundry) — kept as a separate branch // even when values match, since 3P availability lags firstParty and // these will diverge again at the next model launch. @@ -153,6 +159,13 @@ export function getDefaultSonnetModel(): ModelName { if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL) { return process.env.ANTHROPIC_DEFAULT_SONNET_MODEL } + // Fall back to user's configured model (ANTHROPIC_MODEL / settings) — + // custom providers (proxies, national clouds) may not recognize the + // hardcoded Anthropic model IDs. + const userSpecified = getUserSpecifiedModelSetting() + if (userSpecified) { + return parseUserSpecifiedModel(userSpecified) + } // Default to Sonnet 4.5 for 3P since they may not have 4.6 yet if (provider !== 'firstParty') { return getModelStrings().sonnet45 @@ -175,6 +188,12 @@ export function getDefaultHaikuModel(): ModelName { if (process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) { return process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL } + // Fall back to user's configured model — custom providers may not + // recognize hardcoded Anthropic model IDs. + const userSpecifiedHaiku = getUserSpecifiedModelSetting() + if (userSpecifiedHaiku) { + return parseUserSpecifiedModel(userSpecifiedHaiku) + } // Haiku 4.5 is available on all platforms (first-party, Foundry, Bedrock, Vertex) return getModelStrings().haiku45 diff --git a/src/utils/permissions/permissions.ts b/src/utils/permissions/permissions.ts index e023a4eb7..a5ceaa34f 100644 --- a/src/utils/permissions/permissions.ts +++ b/src/utils/permissions/permissions.ts @@ -696,6 +696,7 @@ export const hasPermissionsToUseTool: CanUseToolFn = async ( context.options.tools, appState.toolPermissionContext, context.abortController.signal, + context.langfuseTrace, ) } finally { clearClassifierChecking(toolUseID) diff --git a/src/utils/permissions/yoloClassifier.ts b/src/utils/permissions/yoloClassifier.ts index 8f0b3cc4f..d11637a4e 100644 --- a/src/utils/permissions/yoloClassifier.ts +++ b/src/utils/permissions/yoloClassifier.ts @@ -31,6 +31,7 @@ import { resolveAntModel } from '../model/antModels.js' import { getMainLoopModel } from '../model/model.js' import { getAutoModeConfig } from '../settings/settings.js' import { sideQuery } from '../sideQuery.js' +import type { LangfuseSpan } from '../../services/langfuse/index.js' import { jsonStringify } from '../slowOperations.js' import { tokenCountWithEstimation } from '../tokens.js' import { @@ -731,6 +732,7 @@ async function classifyYoloActionXml( action: string }, mode: TwoStageMode, + parentSpan?: LangfuseSpan | null, ): Promise { const classifierType = mode === 'both' @@ -791,6 +793,7 @@ async function classifyYoloActionXml( signal, ...(mode !== 'fast' && { stop_sequences: [''] }), querySource: 'auto_mode', + parentSpan, } const stage1Raw = await sideQuery(stage1Opts) stage1DurationMs = Date.now() - stage1Start @@ -877,6 +880,7 @@ async function classifyYoloActionXml( maxRetries: getDefaultMaxRetries(), signal, querySource: 'auto_mode' as const, + parentSpan, } const stage2Raw = await sideQuery(stage2Opts) const stage2DurationMs = Date.now() - stage2Start @@ -1015,6 +1019,7 @@ export async function classifyYoloAction( tools: Tools, context: ToolPermissionContext, signal: AbortSignal, + parentSpan?: LangfuseSpan | null, ): Promise { const lookup = buildToolLookup(tools) const actionCompact = toCompact(action, lookup) @@ -1126,6 +1131,7 @@ export async function classifyYoloAction( action: actionCompact, }, getTwoStageMode(), + parentSpan, ) } const [disableThinking, thinkingPadding] = getClassifierThinkingConfig(model) @@ -1156,6 +1162,7 @@ export async function classifyYoloAction( maxRetries: getDefaultMaxRetries(), signal, querySource: 'auto_mode' as const, + parentSpan, } const result = await sideQuery(sideQueryOpts) void maybeDumpAutoMode(sideQueryOpts, result, start) diff --git a/src/utils/sideQuery.ts b/src/utils/sideQuery.ts index 9455a83e2..3e4c6b8d8 100644 --- a/src/utils/sideQuery.ts +++ b/src/utils/sideQuery.ts @@ -15,8 +15,11 @@ import { logEvent } from '../services/analytics/index.js' import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../services/analytics/metadata.js' import { getAPIMetadata } from '../services/api/claude.js' import { getAnthropicClient } from '../services/api/client.js' -import { createTrace, endTrace, recordLLMObservation } from '../services/langfuse/index.js' +import { createTrace, createChildSpan, endTrace, recordLLMObservation } from '../services/langfuse/index.js' +import type { LangfuseSpan } from '../services/langfuse/index.js' +import { convertMessagesToLangfuse, convertOutputToLangfuse, convertToolsToLangfuse } from '../services/langfuse/convert.js' import { getModelBetas, modelSupportsStructuredOutputs } from './betas.js' +import { errorMessage } from './errors.js' import { computeFingerprint } from './fingerprint.js' import { getAPIProvider } from './model/providers.js' import { normalizeModelStringForAPI } from './model/model.js' @@ -64,6 +67,11 @@ export type SideQueryOptions = { stop_sequences?: string[] /** Attributes this call in tengu_api_success for COGS joining against reporting.sampling_calls. */ querySource: QuerySource + /** Parent Langfuse span to nest this side query under the main agent trace. */ + parentSpan?: LangfuseSpan | null + /** When true, API failures are recorded as WARNING instead of ERROR in Langfuse. + * Use for optional/best-effort queries where failure is expected and handled gracefully. */ + optional?: boolean } /** @@ -182,13 +190,25 @@ export async function sideQuery(opts: SideQueryOptions): Promise { const normalizedModel = normalizeModelStringForAPI(model) const provider = getAPIProvider() const start = Date.now() - const langfuseTrace = createTrace({ - sessionId: getSessionId(), - model: normalizedModel, - provider, - name: `side-query:${opts.querySource}`, - querySource: opts.querySource, - }) + const traceName = `side-query:${opts.querySource}` + + // When parentSpan is provided, create a child span nested under the + // main agent trace; otherwise create a standalone root trace. + const langfuseTrace = opts.parentSpan + ? createChildSpan(opts.parentSpan, { + name: traceName, + sessionId: getSessionId(), + model: normalizedModel, + provider, + querySource: opts.querySource, + }) + : createTrace({ + sessionId: getSessionId(), + model: normalizedModel, + provider, + name: traceName, + querySource: opts.querySource, + }) let response: BetaMessage try { @@ -210,7 +230,7 @@ export async function sideQuery(opts: SideQueryOptions): Promise { { signal }, ) } catch (error) { - endTrace(langfuseTrace, undefined, 'error') + endTrace(langfuseTrace, { error: errorMessage(error) }, opts.optional ? 'interrupted' : 'error') throw error } @@ -235,12 +255,21 @@ export async function sideQuery(opts: SideQueryOptions): Promise { }) setLastApiCompletionTimestamp(now) - // Record LLM observation in Langfuse (no-op if not configured) + // Record LLM observation in Langfuse (no-op if not configured). + // Wrap SDK types into the internal message format expected by converters. + const wrappedInput = messages.map(m => ({ + type: m.role === 'assistant' ? 'assistant' as const : 'user' as const, + message: { role: m.role, content: m.content }, + })) as unknown as Parameters[0] + const wrappedOutput = [{ + type: 'assistant' as const, + message: { role: 'assistant' as const, content: response.content }, + }] as unknown as Parameters[0] recordLLMObservation(langfuseTrace, { model: normalizedModel, provider, - input: messages, - output: response.content, + input: convertMessagesToLangfuse(wrappedInput, systemBlocks.length > 0 ? systemBlocks.map(b => b.text) : undefined), + output: convertOutputToLangfuse(wrappedOutput), usage: { input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens, @@ -249,6 +278,7 @@ export async function sideQuery(opts: SideQueryOptions): Promise { }, startTime: new Date(start), endTime: new Date(), + ...(tools && { tools: convertToolsToLangfuse(tools as unknown[]) }), }) endTrace(langfuseTrace) diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts index 8c156ab0f..9cadb24ef 100644 --- a/src/utils/tokens.ts +++ b/src/utils/tokens.ts @@ -150,9 +150,17 @@ export function getCurrentUsage(messages: Message[]): { const message = messages[i] const usage = message ? getTokenUsage(message) : undefined if (usage) { + const inputTokens = + (usage.input_tokens ?? 0) + + (usage.cache_creation_input_tokens ?? 0) + + (usage.cache_read_input_tokens ?? 0) + // Skip placeholder usage (all zeros) — third-party APIs may emit + // message_start without real usage data, causing the context counter + // to flash to 0. Fall through to the previous message instead. + if (inputTokens === 0 && (usage.output_tokens ?? 0) === 0) continue return { - input_tokens: usage.input_tokens, - output_tokens: usage.output_tokens, + input_tokens: usage.input_tokens ?? 0, + output_tokens: usage.output_tokens ?? 0, cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0, cache_read_input_tokens: usage.cache_read_input_tokens ?? 0, }