Query lifecycle 深度解析
这一页拆 QueryEngine 如何把 prompt、tools、commands、MCP、memory 和 provider profile 组合成可复用 SDK 运行时。
导入链与契约
▸QueryEngine 配置
QueryEngine 的构造参数在 src/QueryEngine.ts:132 到 src/QueryEngine.ts:176 展开,显示它不是 UI 组件,而是把工具、命令、MCP、agents、budget 和 schema 都收敛进 headless runtime。
132export type QueryEngineConfig = { 133 cwd: string 134 tools: Tools 135 commands: Command[] 136 mcpClients: MCPServerConnection[] 137 agents: AgentDefinition[] 138 canUseTool: CanUseToolFn 139 getAppState: () => AppState 140 setAppState: (f: (prev: AppState) => AppState) => void 141 initialMessages?: Message[] 142 readFileCache: FileStateCache 143 customSystemPrompt?: string 144 appendSystemPrompt?: string 145 userSpecifiedModel?: string 146 fallbackModel?: string 147 thinkingConfig?: ThinkingConfig 148 maxTurns?: number 149 maxBudgetUsd?: number 150 taskBudget?: { total: number } 151 jsonSchema?: Record<string, unknown> 152 verbose?: boolean 153 replayUserMessages?: boolean 154 /** Handler for URL elicitations triggered by MCP tool -32042 errors. */ 155 handleElicitation?: ToolUseContext['handleElicitation'] 156 includePartialMessages?: boolean 157 setSDKStatus?: (status: SDKStatus) => void 158 abortController?: AbortController 159 orphanedPermission?: OrphanedPermission 160 /** 161 * Snip-boundary handler: receives each yielded system message plus the 162 * current mutableMessages store. Returns undefined if the message is not a 163 * snip boundary; otherwise returns the replayed snip result. Injected by 164 * ask() when HISTORY_SNIP is enabled so feature-gated strings stay inside 165 * the gated module (keeps QueryEngine free of excluded strings and testable 166 * despite feature() returning false under bun test). SDK-only: the REPL 167 * keeps full history for UI scrollback and projects on demand via 168 * projectSnippedView; QueryEngine truncates here to bound memory in long 169 * headless sessions (no UI to preserve). 170 */ 171 snipReplay?: ( 172 yieldedSystemMsg: Message, 173 store: Message[], 174 ) => { messages: Message[]; executed: boolean } | undefined 175 query?: typeof defaultQuery 176} 177 178/** 179 * QueryEngine owns the query lifecycle and session state for a conversation. 180 * It extracts the core logic from ask() into a standalone class that can be 181 * used by both the headless/SDK path and (in a future phase) the REPL. 182 * 183 * One QueryEngine per conversation. Each submitMessage() call starts a new 184 * turn within the same conversation. State (messages, file cache, usage, etc.) 185 * persists across turns. 186 */ 187export class QueryEngine { 188 private config: QueryEngineConfig 189 private mutableMessages: Message[] 190 private abortController: AbortController 191 private permissionDenials: SDKPermissionDenial[] 192 private totalUsage: NonNullableUsage 193 private hasHandledOrphanedPermission = false 194 private readFileState: FileStateCache 195 private autoCompactTracking: AutoCompactTrackingState | undefined 196 // Turn-scoped skill discovery tracking (feeds was_discovered on 197 // tengu_skill_tool_invocation). Must persist across the two 198 // processUserInputContext rebuilds inside submitMessage, but is cleared 199 // at the start of each submitMessage to avoid unbounded growth across 200 // many turns in SDK mode. 201 private discoveredSkillNames = new Set<string>() 202 private loadedNestedMemoryPaths = new Set<string>() 203 204 constructor(config: QueryEngineConfig) { 205 this.config = config 206 this.mutableMessages = config.initialMessages ?? [] 207 this.abortController = config.abortController ?? createAbortController() 208 this.permissionDenials = [] 209 this.readFileState = config.readFileCache 210 this.totalUsage = EMPTY_USAGE 211 }
▸一轮消息如何开始
每次 submitMessage 从 src/QueryEngine.ts:213 进入,先读取配置,再在 src/QueryEngine.ts:242 清掉本轮 skill discovery;这让长会话保留历史消息,但不会把本轮发现的 skill 无限增长到后续轮次。
213 async *submitMessage( 214 prompt: string | ContentBlockParam[], 215 options?: { uuid?: string; isMeta?: boolean }, 216 ): AsyncGenerator<SDKMessage, void, unknown> { 217 const { 218 cwd, 219 commands, 220 tools, 221 mcpClients, 222 verbose = false, 223 thinkingConfig, 224 maxTurns, 225 maxBudgetUsd, 226 taskBudget, 227 canUseTool, 228 customSystemPrompt, 229 appendSystemPrompt, 230 userSpecifiedModel, 231 fallbackModel, 232 jsonSchema, 233 getAppState, 234 setAppState, 235 replayUserMessages = false, 236 includePartialMessages = false, 237 agents = [], 238 setSDKStatus, 239 orphanedPermission, 240 } = this.config 241 242 this.discoveredSkillNames.clear() 243 setCwd(cwd) 244 const persistSession = !isSessionPersistenceDisabled() 245 const startTime = Date.now() 246 247 // Wrap canUseTool to track permission denials 248 const wrappedCanUseTool: CanUseToolFn = async ( 249 tool, 250 input, 251 toolUseContext, 252 assistantMessage, 253 toolUseID, 254 forceDecision, 255 ) => { 256 const result = await canUseTool( 257 tool, 258 input, 259 toolUseContext, 260 assistantMessage, 261 toolUseID, 262 forceDecision, 263 ) 264 265 // Track denials for SDK reporting 266 if (result.behavior !== 'allow') { 267 this.permissionDenials.push({ 268 tool_name: sdkCompatToolName(tool.name), 269 tool_use_id: toolUseID, 270 tool_input: input, 271 }) 272 } 273 274 return result 275 } 276 277 const initialAppState = getAppState() 278 const initialMainLoopModel = userSpecifiedModel 279 ? parseUserSpecifiedModel(userSpecifiedModel) 280 : getMainLoopModel() 281 282 const initialThinkingConfig: ThinkingConfig = thinkingConfig 283 ? thinkingConfig 284 : shouldEnableThinkingByDefault() !== false 285 ? { type: 'adaptive' } 286 : { type: 'disabled' } 287 288 headlessProfilerCheckpoint('before_getSystemPrompt') 289 // Narrow once so TS tracks the type through the conditionals below. 290 const customPrompt = 291 typeof customSystemPrompt === 'string' ? customSystemPrompt : undefined 292 const { 293 defaultSystemPrompt, 294 userContext: baseUserContext, 295 systemContext, 296 } = await fetchSystemPromptParts({ 297 tools, 298 mainLoopModel: initialMainLoopModel, 299 additionalWorkingDirectories: Array.from( 300 initialAppState.toolPermissionContext.additionalWorkingDirectories.keys(), 301 ), 302 mcpClients, 303 customSystemPrompt: customPrompt, 304 }) 305 headlessProfilerCheckpoint('after_getSystemPrompt') 306 const userContext = { 307 ...baseUserContext, 308 ...getCoordinatorUserContext( 309 mcpClients, 310 isScratchpadEnabled() ? getScratchpadDir() : undefined, 311 ), 312 } 313 314 // When an SDK caller provides a custom system prompt AND has set 315 // CLAUDE_COWORK_MEMORY_PATH_OVERRIDE, inject the memory-mechanics prompt. 316 // The env var is an explicit opt-in signal — the caller has wired up 317 // a memory directory and needs Claude to know how to use it (which 318 // Write/Edit tools to call, MEMORY.md filename, loading semantics). 319 // The caller can layer their own policy text via appendSystemPrompt. 320 const memoryMechanicsPrompt = 321 customPrompt !== undefined && hasAutoMemPathOverride() 322 ? await loadMemoryPrompt() 323 : null 324 325 const systemPrompt = asSystemPrompt([ 326 ...(customPrompt !== undefined ? [customPrompt] : defaultSystemPrompt), 327 ...(memoryMechanicsPrompt ? [memoryMechanicsPrompt] : []), 328 ...(appendSystemPrompt ? [appendSystemPrompt] : []), 329 ]) 330 331 // Register function hook for structured output enforcement 332 const hasStructuredOutputTool = tools.some(t => 333 toolMatchesName(t, SYNTHETIC_OUTPUT_TOOL_NAME), 334 ) 335 if (jsonSchema && hasStructuredOutputTool) { 336 registerStructuredOutputEnforcement(setAppState, getSessionId()) 337 }
▸processUserInput 上下文
processUserInputContext 是核心胶水。src/QueryEngine.ts:339 绑定 mutableMessages,src/QueryEngine.ts:373 带入 readFileState,src/QueryEngine.ts:374 到 src/QueryEngine.ts:377 把 nested memory 与动态 skill triggers 一并暴露。
339 let processUserInputContext: ProcessUserInputContext = { 340 messages: this.mutableMessages, 341 // Slash commands that mutate the message array (e.g. /clear) 342 // call setMessages(fn). In interactive mode this writes back to 343 // AppState; in print mode we write back to mutableMessages so the 344 // rest of the query loop (push at :389, snapshot at :392) sees 345 // the result. The second processUserInputContext below (after 346 // slash-command processing) keeps the no-op — nothing else calls 347 // setMessages past that point. 348 setMessages: fn => { 349 this.mutableMessages = fn(this.mutableMessages) 350 }, 351 onChangeAPIKey: () => {}, 352 handleElicitation: this.config.handleElicitation, 353 options: { 354 commands, 355 debug: false, // we use stdout, so don't want to clobber it 356 tools, 357 verbose, 358 mainLoopModel: initialMainLoopModel, 359 thinkingConfig: initialThinkingConfig, 360 mcpClients, 361 mcpResources: {}, 362 ideInstallationStatus: null, 363 isNonInteractiveSession: true, 364 customSystemPrompt, 365 appendSystemPrompt, 366 agentDefinitions: { activeAgents: agents, allAgents: agents }, 367 theme: resolveThemeSetting(getGlobalConfig().theme), 368 maxBudgetUsd, 369 }, 370 getAppState, 371 setAppState, 372 abortController: this.abortController, 373 readFileState: this.readFileState, 374 nestedMemoryAttachmentTriggers: new Set<string>(), 375 loadedNestedMemoryPaths: this.loadedNestedMemoryPaths, 376 dynamicSkillDirTriggers: new Set<string>(), 377 discoveredSkillNames: this.discoveredSkillNames, 378 setInProgressToolUseIDs: () => {}, 379 setResponseLength: () => {}, 380 updateFileHistoryState: ( 381 updater: (prev: FileHistoryState) => FileHistoryState, 382 ) => { 383 setAppState(prev => { 384 const updated = updater(prev.fileHistory) 385 if (updated === prev.fileHistory) return prev 386 return { ...prev, fileHistory: updated } 387 }) 388 }, 389 updateAttributionState: ( 390 updater: (prev: AttributionState) => AttributionState, 391 ) => { 392 setAppState(prev => { 393 const updated = updater(prev.attribution) 394 if (updated === prev.attribution) return prev 395 return { ...prev, attribution: updated } 396 }) 397 }, 398 setSDKStatus, 399 } 400 401 // Handle orphaned permission (only once per engine lifetime) 402 if (orphanedPermission && !this.hasHandledOrphanedPermission) { 403 this.hasHandledOrphanedPermission = true 404 for await (const message of handleOrphanedPermission( 405 orphanedPermission, 406 tools, 407 this.mutableMessages, 408 processUserInputContext, 409 )) { 410 yield message 411 } 412 } 413 414 const { 415 messages: messagesFromUserInput, 416 shouldQuery, 417 allowedTools, 418 model: modelFromUserInput, 419 resultText, 420 } = await processUserInput({ 421 input: prompt, 422 mode: 'prompt', 423 setToolJSX: () => {}, 424 context: { 425 ...processUserInputContext, 426 messages: this.mutableMessages, 427 }, 428 messages: this.mutableMessages, 429 uuid: options?.uuid, 430 isMeta: options?.isMeta,
▸Provider profile 与 secret 面
多模型能力落到 provider profile。src/utils/providerProfile.ts:124 定义兼容 profile,src/utils/providerProfile.ts:152 定义各 provider env,src/utils/providerProfile.ts:215 则让新增 credential 也能参与脱敏。
124export type CompatibilityProfileMode = 125 | 'anthropic' 126 | 'openai' 127 | 'gemini' 128 | 'mistral' 129 | 'github' 130 | 'github-enterprise' 131 | 'bedrock' 132 | 'vertex' 133 134export type ProviderProfile = 135 | 'anthropic' 136 | 'openai' 137 | 'ollama' 138 | 'codex' 139 | 'gemini' 140 | 'atomic-chat' 141 | 'nvidia-nim' 142 | 'minimax' 143 | 'mistral' 144 | 'github' 145 | 'github-enterprise' 146 | 'bedrock' 147 | 'vertex' 148 | 'xai' 149 | 'opencode' 150 | 'clinepass' 151 152export type ProfileEnv = { 153 ANTHROPIC_BASE_URL?: string 154 ANTHROPIC_MODEL?: string 155 ANTHROPIC_API_KEY?: string 156 ANTHROPIC_AUTH_TOKEN?: string 157 ANTHROPIC_CUSTOM_HEADERS?: string 158 ANTHROPIC_BEDROCK_BASE_URL?: string 159 ANTHROPIC_VERTEX_BASE_URL?: string 160 OPENAI_BASE_URL?: string 161 OPENAI_API_BASE?: string 162 OPENAI_MODEL?: string 163 OPENAI_API_FORMAT?: 'chat_completions' | 'responses' | 'responses_compat' 164 OPENAI_AZURE_STYLE?: string 165 OPENAI_AUTH_HEADER?: string 166 OPENAI_AUTH_SCHEME?: 'bearer' | 'raw' 167 OPENAI_AUTH_HEADER_VALUE?: string 168 OPENAI_API_KEYS?: string 169 OPENAI_API_KEY?: string 170 GITHUB_COPILOT_KEY?: string 171 GITHUB_ENTERPRISE_URL?: string 172 CODEX_API_KEY?: string 173 CODEX_CREDENTIAL_SOURCE?: 'oauth' | 'existing' 174 CHATGPT_ACCOUNT_ID?: string 175 CODEX_ACCOUNT_ID?: string 176 GEMINI_API_KEY?: string 177 GEMINI_AUTH_MODE?: 'api-key' | 'access-token' | 'adc' 178 GEMINI_ACCESS_TOKEN?: string 179 GEMINI_MODEL?: string 180 GEMINI_BASE_URL?: string 181 GOOGLE_API_KEY?: string 182 NVIDIA_NIM?: string 183 NVIDIA_API_KEY?: string 184 MINIMAX_API_KEY?: string 185 MINIMAX_BASE_URL?: string 186 MINIMAX_MODEL?: string 187 MISTRAL_BASE_URL?: string 188 MISTRAL_API_KEY?: string 189 MISTRAL_MODEL?: string 190 BANKR_BASE_URL?: string 191 BNKR_API_KEY?: string 192 BANKR_MODEL?: string 193 XAI_API_KEY?: string 194 XAI_CREDENTIAL_SOURCE?: 'oauth' 195 AIMLAPI_API_KEY?: string 196 VENICE_API_KEY?: string 197 MIMO_API_KEY?: string 198 ATLAS_CLOUD_API_KEY?: string 199 CLINE_API_KEY?: string 200 NEARAI_API_KEY?: string 201 FIREWORKS_API_KEY?: string 202 LONGCAT_API_KEY?: string 203 OPENCODE_API_KEY?: string 204 CLOUDFLARE_API_TOKEN?: string 205 CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS?: string 206 CLAUDE_CODE_PROVIDER_ROUTE_ID?: string 207} 208 209export type ProfileFile = { 210 profile: ProviderProfile 211 env: ProfileEnv 212 createdAt: string 213} 214 215// SecretValueSource is intentionally open (Partial<Record<string, ...>>) so 216// that newly declared provider credential env vars are redactable without a 217// matching type update. See providerSecrets.ts for the canonical definition. 218export type { SecretValueSource } from './providerSecrets.js'
▸逐行证据
Query 子页面 的证据点 src/QueryEngine.ts:132 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/QueryEngine.ts:187 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/QueryEngine.ts:213 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/QueryEngine.ts:242 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/QueryEngine.ts:248 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/QueryEngine.ts:292 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/QueryEngine.ts:320 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/QueryEngine.ts:339 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/QueryEngine.ts:401 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/QueryEngine.ts:414 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/utils/providerProfile.ts:49 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/utils/providerProfile.ts:58 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/utils/providerProfile.ts:124 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/utils/providerProfile.ts:209 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/utils/providerProfile.ts:215 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/commands/install.tsx:91 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/commands/install.tsx:107 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/commands/install.tsx:131 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/commands/install.tsx:149 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/commands/install.tsx:177 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/bridge/workSecret.ts:5 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/bridge/workSecret.ts:44 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/bridge/workSecret.ts:64 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/bridge/workSecret.ts:81 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/bridge/workSecret.ts:101 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/bridge/workSecret.ts:136 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/bridge/sessionRunner.test.ts:13 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/bridge/sessionRunner.test.ts:34 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。
Query 子页面 的证据点 src/bridge/sessionRunner.test.ts:78 不是孤立行号:这一处把状态、权限或传输边界固定在源码里,和相邻模块形成可测试契约;阅读时应把它当成调用链的一段,而不是 README 里的营销描述。