本章总览
query/config.ts 与 query/deps.ts 是 query 循环的两条「测试与演进接缝」:QueryConfig 在 query() 入口一次性快照 session 与 Statsig/env 门控;QueryDeps 把 callModel、microcompact、autocompact、uuid 四类 I/O 收口,测试通过 QueryParams.deps 注入 fake 而无需 spyOn 整模块图。本章带源码走读,要求你能说明为何 feature() 门控不能进 config。
学完本章你应该能
- 解释 QueryConfig 字段与 buildQueryConfig 的调用时机
- 说明 gates 中 streamingToolExecution / fastModeEnabled 等在 loop 中的消费点
- 掌握 QueryDeps 四依赖与 productionDeps 工厂
- 能在测试中构造最小 deps 替身跑 queryLoop
- 理解 deps 与 config 分离的设计动机(immutable vs injectable I/O)
核心概念(先读懂这些)
Config 是 plain data,不是 service locator
buildQueryConfig() 在 queryLoop 开头调用一次,结果存入 const config。循环内只读 config.gates.* 与 config.sessionId。注释明确:未来 step() reducer 签名是 (state, event, config)。因此 config 不得持有函数或可变引用——否则 reducer 无法纯化。
feature() 门控必须留在 guarded block 内
QueryConfig 注释:intentionally excludes feature() gates — tree-shaking boundaries。Bun bundle 的 feature() 在编译期消除死分支;若把 feature 结果存进 config 对象,bundler 可能无法 DCE 整个分支。Statsig CACHED_MAY_BE_STALE 门控可以快照,因为已有「可能过期」契约。
Deps 模式:typeof fn 保持签名同步
QueryDeps 用 typeof queryModelWithStreaming 等标注,productionDeps 直接引用真实实现。deps.ts 注释:tests importing for typing already import query.ts — no new module-graph cost。范围刻意收窄为 4 项;后续 PR 可加 runTools、handleStopHooks 等。
建议学习步骤
- 阅读 QueryConfig 类型与 buildQueryConfig 源码块 A
- 在 query.ts 中 grep config.gates 与 deps. 的所有消费点
- 阅读 QueryDeps 类型与 productionDeps 源码块 B
- 阅读 queryLoop 入口:deps 默认与 config 快照源码块 C
- 对照 mod-services/compact 理解 autocompact 参数列表
常见误区
注意
不要把 fastMode.ts 整模块 import 进 config——注释说明会破坏 test shard 初始化顺序
注意
deps 省略时默认 productionDeps(),集成测试忘记注入会导致真实 API 调用
注意
config.sessionId 与 toolUseContext.agentId 不同:后者标识 subagent
在 queryLoop 中的位置
queryLoop 解构 params 后立即执行:
const deps = params.deps ?? productionDeps()
let state: State = { messages, toolUseContext, ... }
const config = buildQueryConfig()
while (true) {
// 每轮迭代顶部使用 deps.microcompact / deps.autocompact
// API 阶段 deps.callModel(...)
// queryTracking.chainId 首次用 deps.uuid()
}
QueryParams.deps 可选;生产路径不传,测试传入 { callModel: async function*(){...}, ... }。
config 每轮 query 调用只 build 一次,跨 while 迭代共享——Statsig 值在 turn 内视为常量,符合 CACHED_MAY_BE_STALE 语义。
QueryConfig 类型与 buildQueryConfig
config.ts 顶部注释是理解整个 query 模块化路线的关键文档:
- Immutable values snapshotted once at query() entry
- Separating from State and ToolUseContext makes step() extraction tractable
- Excludes feature() gates for dead-code elimination
gates 字段:
| 字段 | 来源 | loop 内用途 |
|---|---|---|
| streamingToolExecution | Statsig tengu_streaming_tool_execution2 | 是否用 StreamingToolExecutor |
| emitToolUseSummaries | CLAUDE_CODE_EMIT_TOOL_USE_SUMMARIES | 工具摘要生成 |
| isAnt | USER_TYPE === ant | createDumpPromptsFetch |
| fastModeEnabled | !DISABLE_FAST_MODE env | callModel fast mode 参数 |
sessionId 来自 getSessionId(),用于 analytics 与 dump prompts 路径。
源码引用: src/query/config.ts · 第 6–27 行(共 47 行)
6| // -- config
7|
8| // Immutable values snapshotted once at query() entry. Separating these from
9| // the per-iteration State struct and the mutable ToolUseContext makes future
10| // step() extraction tractable — a pure reducer can take (state, event, config)
11| // where config is plain data.
12| //
13| // Intentionally excludes feature() gates — those are tree-shaking boundaries
14| // and must stay inline at the guarded blocks for dead-code elimination.
15| export type QueryConfig = {
16| sessionId: SessionId
17|
18| // Runtime gates (env/statsig). NOT feature() gates — see above.
19| gates: {
20| // Statsig — CACHED_MAY_BE_STALE already admits staleness, so snapshotting
21| // once per query() call stays within the existing contract.
22| streamingToolExecution: boolean
23| emitToolUseSummaries: boolean
24| isAnt: boolean
25| fastModeEnabled: boolean
26| }
27| }
源码引用: src/query/config.ts · 第 29–46 行(共 47 行)
29| export function buildQueryConfig(): QueryConfig {
30| return {
31| sessionId: getSessionId(),
32| gates: {
33| streamingToolExecution: checkStatsigFeatureGate_CACHED_MAY_BE_STALE(
34| 'tengu_streaming_tool_execution2',
35| ),
36| emitToolUseSummaries: isEnvTruthy(
37| process.env.CLAUDE_CODE_EMIT_TOOL_USE_SUMMARIES,
38| ),
39| isAnt: process.env.USER_TYPE === 'ant',
40| // Inlined from fastMode.ts to avoid pulling its heavy module graph
41| // (axios, settings, auth, model, oauth, config) into test shards that
42| // didn't previously load it — changes init order and breaks unrelated tests.
43| fastModeEnabled: !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FAST_MODE),
44| },
45| }
46| }
config.gates 在 query.ts 的消费点
buildQueryConfig 本身无逻辑分支;价值在 query.ts 多处读取:
streamingToolExecution(约 L561):const useStreamingToolExecution = config.gates.streamingToolExecution 决定是否构造 StreamingToolExecutor,流式并行执行 tool_use block。
isAnt + sessionId(约 L588):Ant 内部 dogfood 时 createDumpPromptsFetch(agentId ?? config.sessionId) 把 prompt 落盘调试。
fastModeEnabled(约 L671):spread 进 callModel 参数,启用 fast mode sampling。
emitToolUseSummaries(约 L1416):assistant 无 further tool_use 时异步 generateToolUseSummary。
读 config 章时应在 IDE 对 query.ts 全局搜索 config.gates,确认没有遗漏 gate。
源码引用: src/query.ts · 第 293–296 行(共 1730 行)
293| // Snapshot immutable env/statsig/session state once at entry. See QueryConfig
294| // for what's included and why feature() gates are intentionally excluded.
295| const config = buildQueryConfig()
296|
源码引用: src/query.ts · 第 559–562 行(共 1730 行)
559|
560| queryCheckpoint('query_setup_start')
561| const useStreamingToolExecution = config.gates.streamingToolExecution
562| let streamingToolExecutor = useStreamingToolExecution
源码引用: src/query.ts · 第 657–675 行(共 1730 行)
657| let streamingFallbackOccured = false
658| queryCheckpoint('query_api_streaming_start')
659| for await (const message of deps.callModel({
660| messages: prependUserContext(messagesForQuery, userContext),
661| systemPrompt: fullSystemPrompt,
662| thinkingConfig: toolUseContext.options.thinkingConfig,
663| tools: toolUseContext.options.tools,
664| signal: toolUseContext.abortController.signal,
665| options: {
666| async getToolPermissionContext() {
667| const appState = toolUseContext.getAppState()
668| return appState.toolPermissionContext
669| },
670| model: currentModel,
671| ...(config.gates.fastModeEnabled && {
672| fastMode: appState.fastMode,
673| }),
674| toolChoice: undefined,
675| isNonInteractiveSession:
QueryDeps 与 productionDeps
deps.ts 定义四类依赖:
| 键 | 类型来源 | 职责 |
|---|---|---|
| callModel | queryModelWithStreaming | 流式 Anthropic API |
| microcompact | microcompactMessages | 轮前微压缩 |
| autocompact | autoCompactIfNeeded | 超阈值自动 compact |
| uuid | randomUUID | query chain id / turn id |
注释强调:today 6-8 个测试文件各自 spyOn callModel/autocompact——deps 注入可删除 boilerplate。
productionDeps() 是纯工厂,无 lazy init。测试典型写法:
await collectAsync(query({
...baseParams,
deps: {
...productionDeps(),
callModel: fakeStreamingModel,
},
}))
源码引用: src/query/deps.ts · 第 6–31 行(共 41 行)
6| // -- deps
7|
8| // I/O dependencies for query(). Passing a `deps` override into QueryParams
9| // lets tests inject fakes directly instead of spyOn-per-module — the most
10| // common mocks (callModel, autocompact) are each spied in 6-8 test files
11| // today with module-import-and-spy boilerplate.
12| //
13| // Using `typeof fn` keeps signatures in sync with the real implementations
14| // automatically. This file imports the real functions for both typing and
15| // the production factory — tests that import this file for typing are
16| // already importing query.ts (which imports everything), so there's no
17| // new module-graph cost.
18| //
19| // Scope is intentionally narrow (4 deps) to prove the pattern. Followup
20| // PRs can add runTools, handleStopHooks, logEvent, queue ops, etc.
21| export type QueryDeps = {
22| // -- model
23| callModel: typeof queryModelWithStreaming
24|
25| // -- compaction
26| microcompact: typeof microcompactMessages
27| autocompact: typeof autoCompactIfNeeded
28|
29| // -- platform
30| uuid: () => string
31| }
源码引用: src/query/deps.ts · 第 33–40 行(共 41 行)
33| export function productionDeps(): QueryDeps {
34| return {
35| callModel: queryModelWithStreaming,
36| microcompact: microcompactMessages,
37| autocompact: autoCompactIfNeeded,
38| uuid: randomUUID,
39| }
40| }
deps 在循环内的调用链
每轮 iteration 开头按固定顺序调用 deps:
- deps.uuid() — 初始化 queryTracking.chainId(depth 0 时)
- deps.microcompact(messages, ctx, querySource) — L414,在 snip 之后 autocompact 之前
- deps.autocompact(...) — L454,可能 yield compact 消息并 continue
- deps.callModel({...}) — L659,主 API 流
autocompact 返回 compactionResult 时 query.ts 会 buildPostCompactMessages、更新 taskBudgetRemaining、设置 transition reason 后 continue——compact 章有完整说明;此处只需知 deps.autocompact 是 loop 中唯一主动 mutate messages 的 deps 调用(callModel 产出新 assistant 消息经 yield 收集)。
microcompact 与 autocompact 分离的原因:microcompact cheap、可每轮跑;autocompact fork 子 Agent 昂贵且带 recursion guard。
源码引用: src/query.ts · 第 263–264 行(共 1730 行)
263| const deps = params.deps ?? productionDeps()
264|
源码引用: src/query.ts · 第 347–355 行(共 1730 行)
347| const queryTracking = toolUseContext.queryTracking
348| ? {
349| chainId: toolUseContext.queryTracking.chainId,
350| depth: toolUseContext.queryTracking.depth + 1,
351| }
352| : {
353| chainId: deps.uuid(),
354| depth: 0,
355| }
源码引用: src/query.ts · 第 412–419 行(共 1730 行)
412| // Apply microcompact before autocompact
413| queryCheckpoint('query_microcompact_start')
414| const microcompactResult = await deps.microcompact(
415| messagesForQuery,
416| toolUseContext,
417| querySource,
418| )
419| messagesForQuery = microcompactResult.messages
源码引用: src/query.ts · 第 453–468 行(共 1730 行)
453| queryCheckpoint('query_autocompact_start')
454| const { compactionResult, consecutiveFailures } = await deps.autocompact(
455| messagesForQuery,
456| toolUseContext,
457| {
458| systemPrompt,
459| userContext,
460| systemContext,
461| toolUseContext,
462| forkContextMessages: messagesForQuery,
463| },
464| querySource,
465| tracking,
466| snipTokensFreed,
467| )
468| queryCheckpoint('query_autocompact_end')
QueryParams 中的 deps 与 taskBudget
QueryParams(query.ts L181-199)除 deps 外还有 taskBudget、maxTurns、querySource 等。注意注释区分:
- taskBudget — API output_config.task_budget(beta),与 TOKEN_BUDGET feature 的 +500k 自动续跑无关
- deps — 仅 I/O 边界,不包含 canUseTool(仍走 QueryParams 顶层)
taskBudgetRemaining 是 queryLoop 局部变量(非 State 字段),刻意不放进 7 个 continue 站点,避免每次 state spread 都要维护。compact 后从 finalContextTokensFromLastResponse 扣减 remaining。
设计表:
| 概念 | 存储位置 | 可变? |
|---|---|---|
| QueryConfig | const config | turn 内不变 |
| QueryDeps | params.deps | turn 内不变 |
| State | let state | continue 站点替换 |
| taskBudgetRemaining | let 局部 | compact 时更新 |
源码引用: src/query.ts · 第 181–199 行(共 1730 行)
181| export type QueryParams = {
182| messages: Message[]
183| systemPrompt: SystemPrompt
184| userContext: { [k: string]: string }
185| systemContext: { [k: string]: string }
186| canUseTool: CanUseToolFn
187| toolUseContext: ToolUseContext
188| fallbackModel?: string
189| querySource: QuerySource
190| maxOutputTokensOverride?: number
191| maxTurns?: number
192| skipCacheWrite?: boolean
193| // API task_budget (output_config.task_budget, beta task-budgets-2026-03-13).
194| // Distinct from the tokenBudget +500k auto-continue feature. `total` is the
195| // budget for the whole agentic turn; `remaining` is computed per iteration
196| // from cumulative API usage. See configureTaskBudgetParams in claude.ts.
197| taskBudget?: { total: number }
198| deps?: QueryDeps
199| }
源码引用: src/query.ts · 第 282–291 行(共 1730 行)
282| // task_budget.remaining tracking across compaction boundaries. Undefined
283| // until first compact fires — while context is uncompacted the server can
284| // see the full history and handles the countdown from {total} itself (see
285| // api/api/sampling/prompt/renderer.py:292). After a compact, the server sees
286| // only the summary and would under-count spend; remaining tells it the
287| // pre-compact final window that got summarized away. Cumulative across
288| // multiple compacts: each subtracts the final context at that compact's
289| // trigger point. Loop-local (not on State) to avoid touching the 7 continue
290| // sites.
291| let taskBudgetRemaining: number | undefined = undefined
测试与演进:为何先拆 config/deps
query.ts 行数仍超 1700,团队采用「窄接缝优先」:
已提取: config、deps、tokenBudget、stopHooks、transitions 占位
仍 inline: tool 执行、413 reactive compact、max_output_tokens recovery、attachment 管线
extract step() 的前置条件是可注入 config + deps + 显式 State.transition。读源码时看到 state = next; continue 应联想到:此处本应是 state = reduce(state, event, config)。
测试策略:
- 单元测 checkTokenBudget / buildQueryConfig — 直接 import 子模块
- 集成测 query loop — 注入 deps.callModel 返回固定 assistant JSON
- 勿 mock buildQueryConfig 除非测 gate 组合——Statsig 应用 growthbook test helper
fastMode 内联原因: config.ts L40-43 把 fastModeEnabled 写成 env 检查而非 import fastMode.ts,避免 axios/settings/auth 链进入 previously-unloaded test shards。这是「配置模块也要控制 module graph」的典型案例。
源码目录(本主题)
query.ts 在树中根级高亮;config/deps 在 query/ 子目录。
动手练习
- 在 query.ts 列出所有
deps.与config.引用,制表对照本章 - 写一个 fake callModel generator 返回单条 text assistant message,跑通 query 无 tool
- 阅读 mod-services/compact 的 shouldAutoCompact guard,理解 deps.autocompact 何时 no-op
- 思考:若把 runTools 加入 QueryDeps,签名应如何 typedef 才能与 toolOrchestration 同步
本章小结与延伸
config = 每 turn immutable 快照;deps = 可替换 I/O。下一章读 transitions,理解 state 如何在 continue 站点批量更新。 继续学习: