Selaa lähdekoodia

fix(token-meter): anchor usage after admitted prompt inputs

The loop appends step/start before system/message and the entered user
messages. Capturing nodes at step/start therefore omits inputs already
included in the provider's successful usage, then adds those inputs back
as a positive surface delta. Prompt replacement can also incorrectly add
or subtract the difference from the prior prompt on a completed call.

Snapshot the current priced surface immediately before assistant/message
commits. Keep provider output separate from the durable assistant node so
listener rewrites retain their signed delta. The invariant is zero delta
immediately after an unchanged successful output: provider usage already
includes every admitted prompt input. Later appends/replacements still
produce signed deltas, and low or absent usage keeps heuristic fallback.

Delete stepStart.nodes rather than adding prompt-specific corrections or
another request snapshot: the existing transactional surface fold already
contains the successful request inputs, including replacements made during
same-step retry recovery. Keep turn/step state and all overlap, mismatch,
and late-assistant lifecycle validation. Retry attempts are log-only and
request middleware changes configuration; injected messages remain queued
until admission. No loop, event format, projection, or retry policy changes.

Exercise the real loop with reported usage and initial, growing, shrinking,
and empty prompts; same-step failed attempt plus retry prompt replacement;
request middleware; eager observation and fresh seeded replay. The two
regressions fail before the fix with spurious deltas of +48 and +18 tokens.
Retain existing durable-output rewrite, route repricing, missing/low usage,
transactional failure, and lifecycle tests. Update README EN/ZH and the
existing system-prompt surface-node Agent Note, including pairing records.

Validation (dedicated worktree, no full unit suite):
- pnpm exec vitest run packages/llm/token-meter/tests packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts --coverage --coverage.include='packages/llm/token-meter/src/index.ts'
  118 passed; exact changed runtime file 100% statements/branches/functions/lines.
- pnpm exec vitest run packages/core/agent-loop/tests/request-reconstruction.spec.ts packages/compaction/compaction-basic/tests/compaction-basic.spec.ts packages/compaction/compaction-basic/tests/loader-composition.spec.ts
  118 passed, including retry reconstruction and real Loader composition.
- pnpm run doc-sync: 33 gates passed.
- pnpm run test:docs: 15 gates passed.
- pnpm run lint: passed, 0 warnings/errors.
- git diff --cached --check: passed.

Baseline normalize.ts comment conflict marker is intentionally untouched.
Tianyi Cui 1 kuukausi sitten
vanhempi
sitoutus
1adcd8203a

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md
-2026-09-02-system-prompt-as-surface-node.md: 0c79d557e54375bdada040488e30403c879c1170
-2026-09-02-system-prompt-as-surface-node.zh.md: b14d662cf2b41235d07da431b15d45156fe6c2da
+2026-09-02-system-prompt-as-surface-node.md: 996312a7ab7ed2a5fae442dfc4d6251d70356737
+2026-09-02-system-prompt-as-surface-node.zh.md: 537a39594cfa5e57053e73d74627a7126b301411

+ 3 - 0
.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md

@@ -39,6 +39,8 @@ The append row is exact about position: a prompt that first becomes non-empty af
 
 In `packages/core/agent-loop/src/agent.ts`, `preStep` renders the prompt with `renderPrompt(assembly)` and projects it; `turn()` commits the `system/message` immediately after `step/start` and before the step's `user/message` events, so log order is wire order. `buildRequest` sets no `system` on the request: the request is `header.config`, `session.deriveMessages()` (system message first), and `header.tools`. The loop step order is: claim inbox → `systemPrompt.assemble()` → project system prompt → project runtime context → `agent/pre-step` waterfall → `step/start` → commit `system/message` (when changed) → commit `user/message`s → `agent/request` waterfall → `request/header` → `request/context` → stream. The `dsh-agent-loop/invariant` companion (`packages/core/agent-loop/src/invariant.ts`) asserts that a loop-built request has `system === undefined` and `messages` equal to `deriveMessages()`.
 
+`dsh-token-meter` anchors usage to the priced surface immediately before the successful `assistant/message`, not to `step/start`. The loop admits the system prompt and user messages after step start, and retry recovery can replace nodes before rebuilding the request. Capturing that current surface includes every admitted input once; the embedded provider output remains separately priced so durable assistant rewrites retain their signed delta. The open step stores only turn and step for lifecycle validation, not a second node snapshot.
+
 ### Consumers
 
 | Consumer | Reads |
@@ -77,6 +79,7 @@ In `packages/core/agent-loop/src/agent.ts`, `preStep` renders the prompt with `r
 
 ## Testing
 
+- `packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts` pins zero post-call surface delta with provider usage through initial, growing, shrinking, and empty prompts, same-step retry replacement, request middleware, and fresh replay.
 - `packages/core/session/tests/surface.spec.ts` (`system/message surface node` block) pins the leading system-role projection, the empty-content `null` projection, `assertSystemHeadRewrite`'s acceptance and rejection paths, the unprotected later system nodes, and the rejection of a seeded `system/message` with a non-system role or non-plugin source.
 - `packages/core/agent-loop/tests/system-prompt-projection.spec.ts` pins the append on first render, the no-op on an unchanged prompt, the replacement of the retained node on change, restoration from the log, and the tail append after a replacement shadowed a non-head system node.
 - `packages/core/agent-loop/tests/request-reconstruction.spec.ts` (`a system-prompt change replaces surface node 0 and starts a new series under the same header`) pins the `series` header that follows a prompt replacement.

+ 3 - 0
.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md

@@ -39,6 +39,8 @@ Status: implemented
 
 在 `packages/core/agent-loop/src/agent.ts` 中,`preStep` 用 `renderPrompt(assembly)` 渲染提示词并投影它;`turn()` 紧接在 `step/start` 之后、该步骤的 `user/message` 事件之前提交 `system/message`,因此日志顺序即协议顺序。`buildRequest` 不在请求上设置 `system`:请求由 `header.config`、`session.deriveMessages()`(系统消息在先)和 `header.tools` 构成。循环步骤顺序为:领取收件箱 → `systemPrompt.assemble()` → 投影系统提示词 → 投影运行时上下文 → `agent/pre-step` waterfall → `step/start` → 提交 `system/message`(有变化时) → 提交各条 `user/message` → `agent/request` waterfall → `request/header` → `request/context` → 流式请求。`dsh-agent-loop/invariant` 伴随组件(`packages/core/agent-loop/src/invariant.ts`)断言循环构建的请求满足 `system === undefined` 且 `messages` 等于 `deriveMessages()`。
 
+`dsh-token-meter` 把用量锚定到成功的 `assistant/message` 之前的已计价 surface,而不是 `step/start`。循环在步骤开始之后接纳系统提示词与用户消息,重试恢复还可能在重建请求之前替换节点。捕获当前 surface 会让每个已接纳输入恰好计入一次;内嵌的提供方输出仍单独计价,因此持久 assistant 改写保留其带符号增量。开放步骤只保存 turn 与 step 以验证生命周期,不保存第二份节点快照。
+
 ### 消费方
 
 | 消费方 | 读取内容 |
@@ -77,6 +79,7 @@ Status: implemented
 
 ## Testing
 
+- `packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts` 钉住提供方用量下调用后的表面增量为零,覆盖初始、增长、缩短与空提示词、同一步骤中的重试替换、请求中间件和全新回放。
 - `packages/core/session/tests/surface.spec.ts`(`system/message surface node` 块)钉住开头 system 角色的投影、空内容的 `null` 投影、`assertSystemHeadRewrite` 的接受与拒绝路径、更后位置系统节点不受保护,以及对 seed 中非 system 角色或非插件 source 的 `system/message` 的拒绝。
 - `packages/core/agent-loop/tests/system-prompt-projection.spec.ts` 钉住首次渲染时的追加、提示词未变时的无操作、变更时对所保留节点的替换、从日志恢复,以及替换遮蔽了非头部系统节点之后的尾部追加。
 - `packages/core/agent-loop/tests/request-reconstruction.spec.ts`(`a system-prompt change replaces surface node 0 and starts a new series under the same header`)钉住提示词替换之后跟随的 `series` header。

+ 78 - 1
packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts

@@ -1,7 +1,7 @@
 import { describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compaction'
-import { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, resolveRetryPolicy , createMessage } from '@deepseek-ai/dsh-llm'
+import { createUserMessage, createSystemMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, resolveRetryPolicy , createMessage } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock, GenerateOptions, LlmResolvedModelInfo, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm'
 import { ToolCallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
 import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
@@ -309,6 +309,83 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
   })
 })
 
+describe('token pressure after loop-admitted system prompts', () => {
+  it.each([false, true])('counts initial and replaced prompts once with retry=%s', async (retry) => {
+    const ctx = new Context()
+    const requests: GenerateOptions[] = []
+    const usage = { inputTokens: 1000, cacheReadTokens: 100, outputTokens: 10 }
+    const adapter = new class extends StepwiseToolAdapter {
+      override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
+        requests.push(options)
+        yield { type: 'block-start', index: 0, blockType: 'text' }
+        yield { type: 'block-end', index: 0, block: { type: 'text', text: 'answer' } }
+        yield { type: 'usage', usage }
+        yield retry && requests.length === 1
+          ? { type: 'finish', reason: { kind: 'error', failure: { code: 'SERVER', message: 'retry me' } } }
+          : { type: 'finish', reason: { kind: 'stop' } }
+      }
+    }(0)
+    try {
+      await mountAgentLoopTestDependencies(ctx)
+      await ctx.plugin(SessionProjectionRegistry)
+      await ctx.plugin(AgentLoop, { agents: [] })
+      await ctx.plugin(TokenMeter)
+      ctx.llm.registerAdapter(['mock'], adapter)
+      let prompt = 'initial guidance '.repeat(8)
+      ctx.systemPrompt.section({ name: 'meter-test', order: 0, complete: true, text: () => prompt })
+      ctx.on('agent/request', async (_payload, next) => ({ ...await next(), temperature: 0.5 }))
+      ctx.on('agent/request-error', async ({ agent, turn, step }, next) => {
+        const action = await next()
+        if (action !== undefined) return action
+        const node = agent.session.surface.nodes[0]!
+        agent.session.append('system/message', {
+          turn,
+          step,
+          message: createSystemMessage('retry guidance', '@deepseek-ai/dsh-system-prompt'),
+        }, { surfaceOp: { op: 'replace', start: node, end: node }, sourceEventSeqs: [node] })
+        return { kind: 'retry' }
+      })
+      const agent = await ctx.agentLoop.create(SessionId('prompt-pressure'), { provider: 'mock', model: 'mock' })
+      // Eager replay must observe the same anchor as a fresh reader of the finished log.
+      ctx.tokenMeter.measure(agent.session)
+      for (const nextPrompt of [prompt, 'expanded guidance '.repeat(20), 'short', '']) {
+        prompt = nextPrompt
+        agent.followup(createUserMessage({ content: [{ type: 'text', text: 'question' }], source: { kind: 'user' } }))
+        await agent.whenIdle()
+        expect(agent.session.snapshotEvents().at(-1)).toMatchObject({
+          type: 'turn/end', data: { reason: { kind: 'completed' } },
+        })
+        const measured = ctx.tokenMeter.measure(agent.session)
+        expect(measured.baseline).toEqual({ kind: 'usage', tokens: 1110, usage })
+        expect(measured.surfaceDeltaTokens).toBe(0)
+        expect(measured.totalTokens).toBe(1110)
+        const replay = Session.create(SessionId('prompt-pressure-replay'), agent.session.snapshotEvents())
+        expect(ctx.tokenMeter.measure(replay)).toMatchObject({
+          baseline: measured.baseline, surfaceDeltaTokens: 0, totalTokens: 1110, nodes: measured.nodes,
+        })
+      }
+      const events = agent.session.snapshotEvents()
+      const prompts = events.filter(event => event.type === 'system/message')
+      expect(prompts[0]?.surfaceOp).toBe('append')
+      expect(prompts.slice(1).every(event => typeof event.surfaceOp === 'object')).toBe(true)
+      for (const event of prompts) {
+        expect(events.find(start => start.type === 'step/start'
+          && start.data.turn === event.data.turn && start.data.step === event.data.step)?.seq)
+          .toBeLessThan(event.seq)
+      }
+      expect(requests).toHaveLength(retry ? 5 : 4)
+      expect(requests.every(request => request.temperature === 0.5)).toBe(true)
+      expect(events.filter(event => event.type === 'assistant/attempt')).toHaveLength(retry ? 1 : 0)
+      expect(events.filter(event => event.type === 'step/start')).toHaveLength(4)
+      expect(requests[0]?.messages[0]?.content).toEqual([{ type: 'text', text: 'initial guidance '.repeat(8) }])
+      if (retry) expect(requests[1]?.messages[0]?.content).toEqual([{ type: 'text', text: 'retry guidance' }])
+      expect(requests.at(-1)?.messages.some(message => message.role === 'system')).toBe(false)
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+})
+
 describe('context-overflow recovery across the real loop and compaction-basic', () => {
   it.each(['thrown', 'in-band'] as const)(
     'force-compacts a %s overflow within the retried step',

+ 2 - 2
packages/llm/token-meter/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/llm/token-meter/README.md
-README.md: 6852ccdc419a76078d8446892bc3a27270e059e4
-README.zh.md: fbe7790aea17f1ba806fd6e6369d19d454317904
+README.md: c635fa15ae2916dfbaf78067b7ddc23cfef5b444
+README.zh.md: 81b5b9439bd1aa4b6f4698c409d37913a63e919e

+ 3 - 1
packages/llm/token-meter/README.md

@@ -42,6 +42,8 @@ const price = ctx.tokenMeter.estimateMessage(message)
 
 Each measurement resolves the effective envelope's provider/model through the optional `llm` service. Image occurrences use the routed request's visual-token price plus model-visible text when the adapter declares pricing; other routes keep the fixed heuristic. File occurrences use the exact route-independent handle text that the same `llm` service resolves for adapter dispatch, including its current execution-world path or explicit no-path message. Each node also carries route-independent `heuristicTokens` for replacement shadow prices. Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope and its total is no lower than that call's full route-priced anchor; otherwise the complete current envelope and surface are estimated. Surface changes stay signed relative to a matching anchor repriced under the same route, including negative deltas after shrinking replacements.
 
+The measurement anchor includes the priced surface immediately before the successful `assistant/message`, including system and user messages admitted after `step/start` and replacements made before a retry. With unchanged durable output, the completed call has zero surface delta: its prompt is already included in provider usage. Later surface changes remain signed deltas against that anchor.
+
 ### Session projections
 
 When the composition provides `ctx.sessionProjections`, token-meter registers three projection units. `tokenUsage` carries the complete durable log's `uncachedInputTokens`, `outputTokens`, `cacheReadTokens`, and `cacheWriteTokens`. A final assistant-message sample replaces streaming usage from the same attempt; `llm/retry-started` ends that replacement scope, so a retry in the same step contributes another billed attempt. `contextPressure` carries optional `pressureTokens` (the newest provider-reported prompt size), optional `projectedTokens` (what the next request's prompt would cost), and optional `contextWindow` from the newest `request/context` record. `contextBreakdown` carries heuristic `systemTokens`, `toolsTokens`, and `messageTokens` — the context's composition, not its provider-billed size. Unloading the plugin removes all three keys.
@@ -131,7 +133,7 @@ These limits define where the measurement stops and future work begins. They are
 
 - **The fixed heuristic is approximate** — text without reusable provider usage is priced by character count plus structural overhead, not an exact provider tokenizer or request serializer; only image occurrences on routes with declared pricing carry provider-exact visual tokens.
 - **Every measurement clones the current surface** — coherent immutable snapshots make reads O(surface), including below-threshold pressure checks.
-- **Provider usage is only reusable for an identical canonical envelope** — prompt, prefix, tools, provider, model, or call-config changes deliberately fall back to full heuristic estimation.
+- **Provider usage is only reusable for an identical canonical envelope** — tools, provider, model, or call-config changes deliberately fall back to full heuristic estimation; system-prompt changes are signed surface deltas until the next successful call.
 - **Missing legacy source seqs are handled conservatively** — assistant messages without `sourceEventSeqs` cannot distinguish provider output from listener rewrites, so the fold avoids claiming a known empty or exact chunk stream.
 - **A system-prompt rewrite carries no shadow price** — the loop replaces a system node without an adjacent metering event, so `contextPressure.projectedTokens` folds that replacement at zero delta until the next usage sample; `contextBreakdown.systemTokens` and `measure()` reprice the new prompt immediately.
 

+ 3 - 1
packages/llm/token-meter/README.zh.md

@@ -42,6 +42,8 @@ const price = ctx.tokenMeter.estimateMessage(message)
 
 每次测量都会通过可选的 `llm` 服务解析生效 envelope 的提供方/模型。适配器声明图片定价时,图片出现处使用路由请求的视觉 token 价格加模型可见文本;其他路由保持固定启发式规则。文件出现处使用同一个 `llm` 服务为适配器分发解析的确切、与路由无关的 handle 文本,其中包含当前执行世界路径或明确的无路径说明。每个节点还携带与路由无关的 `heuristicTokens`,供替换影子价使用。只有当最新成功调用的规范请求 envelope 与已测量 envelope 匹配、且其总量不低于该调用完整路由定价锚点时,才复用提供方用量;否则会对完整当前 envelope 与表面做估算。表面变更保持相对于按同一路由重新定价的匹配锚点的带符号值,包括缩减替换后的负 delta。
 
+测量锚点包含成功的 `assistant/message` 之前的已计价表面,包括 `step/start` 之后接纳的系统与用户消息,以及重试之前执行的替换。持久输出未变时,完成调用的表面增量为零:其提示词已包含在提供方用量中。后续表面变更仍是相对于该锚点的带符号增量。
+
 ### 会话投影
 
 当组合提供 `ctx.sessionProjections` 时,token-meter 注册三个投影单元。`tokenUsage` 携带完整持久日志中的 `uncachedInputTokens`、`outputTokens`、`cacheReadTokens` 与 `cacheWriteTokens`。最终 assistant 消息样本会替换同一次尝试的流式用量;`llm/retry-started` 会结束该替换范围,因此同一步骤中的重试会贡献另一次计费用量。`contextPressure` 携带可选 `pressureTokens`(提供方报告的最新提示词规模)、可选 `projectedTokens`(下一个请求的提示词将花费多少)与来自最新一条 `request/context` 记录的可选 `contextWindow`。`contextBreakdown` 携带启发式 `systemTokens`、`toolsTokens` 与 `messageTokens`——上下文的构成,而非提供方计费规模。卸载插件会移除全部三个键。
@@ -133,7 +135,7 @@ const price = ctx.tokenMeter.estimateMessage(message)
 
 - **固定启发式规则是近似值**——没有可复用提供方用量的文本按字符数加结构开销计价,而非精确提供方分词器或请求序列化器;只有声明了定价的路由上的图片出现处携带提供方精确的视觉 token。
 - **每次测量都克隆当前表面**——连贯不可变快照让读取为 O(surface),包括低于阈值的压力检查。
-- **提供方用量只在规范 envelope 完全相同时可复用**——提示词、前缀、工具、提供方、模型或调用配置变化会刻意回退到完整启发式估算。
+- **提供方用量只在规范 envelope 完全相同时可复用**——工具、提供方、模型或调用配置变化会刻意回退到完整启发式估算;系统提示词变更在下一次成功调用之前按带符号的表面增量计量。
 - **缺失遗留源 seq 时保守处理**——没有 `sourceEventSeqs` 的 assistant 消息无法区分提供方输出与监听器改写,因此 fold 不会声称已知空或精确分片流。
 - **system 提示词改写不带影子价**——循环替换 system 节点时没有紧邻的计量事件,因此 `contextPressure.projectedTokens` 以零增量折叠该替换,直到下一个用量样本;`contextBreakdown.systemTokens` 与 `measure()` 会立即按新提示词重新计价。
 

+ 10 - 6
packages/llm/token-meter/src/index.ts

@@ -50,7 +50,7 @@ export type * from './breakdown-projection.ts'
  */
 interface MeasurementAnchor {
   readonly header: EpochHeader | undefined
-  /** Surface snapshot the anchored request was derived from. */
+  /** Priced surface immediately before the anchored assistant message commits. */
   readonly nodes: readonly MeterSurfaceNode[]
   /** Fixed-heuristic price of the call's provider output. */
   readonly assistantTokens: number
@@ -62,7 +62,7 @@ interface ReplayState {
   consumedEvents: SessionLogOffsetType
   header: EpochHeader | undefined
   surface: MeterSurfaceNode[]
-  stepStart: { turn: number; step: number; nodes: readonly MeterSurfaceNode[] } | undefined
+  stepStart: { turn: number; step: number } | undefined
   anchor: MeasurementAnchor | undefined
 }
 
@@ -131,7 +131,8 @@ export class TokenMeter extends Service {
    * usage is reused only when the latest successful call's canonical request
    * envelope matches `requestHeader` and its total is no lower than that
    * call's full route-priced anchor; otherwise the complete envelope and
-   * surface are repriced.
+   * surface are repriced. The anchor includes all surface nodes immediately
+   * before the assistant message, including inputs admitted after step/start.
    *
    * `requestHeader` replaces the latest logged envelope for pressure and node
    * pricing; the node set always describes the current session surface. Every
@@ -256,7 +257,7 @@ export class TokenMeter extends Service {
             `token meter: step/start at seq ${event.seq} arrived before turn ${state.stepStart.turn}/step ${state.stepStart.step} ended`,
           )
         }
-        nextStepStart = { ...event.data, nodes: [...state.surface] }
+        nextStepStart = { ...event.data }
         break
       case 'step/end':
         if (state.stepStart === undefined
@@ -285,17 +286,20 @@ export class TokenMeter extends Service {
       // assistant/message is surface-mandatory at every append/seed boundary.
       // oxlint-disable-next-line typescript/no-non-null-assertion
       const eventTokens = plan!.tokens
+      // The loop admits prompts and user messages after step/start; retries may
+      // replace them before succeeding. Only the pre-assistant surface is priced
+      // by this call. Provider output stays separate from durable output rewrites.
       if (event.data.usage !== undefined && nextHeader !== undefined) {
         nextAnchor = {
           header: nextHeader,
-          nodes: stepStart.nodes,
+          nodes: [...state.surface],
           assistantTokens: this._estimateProviderAssistant(event),
           usage: event.data.usage,
         }
       } else {
         nextAnchor = {
           header: nextHeader,
-          nodes: stepStart.nodes,
+          nodes: [...state.surface],
           assistantTokens: eventTokens,
           usage: undefined,
         }