perf(token-meter): keep only system prompts in the context breakdown state
contextBreakdown kept one entry per retained surface node, so its checkpoint grew with the message count (7.13 MB across 836 local Sessions, up to 103 KB for one) and every cached Session listing validated it. The state now holds the surviving system prompts, one surface total, and at most one shadow-price claim. Appends add their price; a replacement subtracts the adjacent compaction/summary or compaction/prune price and drops the system prompts its shadowedSeqs name; a system-prompt rewrite subtracts the retained prompt price. Other unmetered replacements fold at zero delta, as in contextPressure. State version 6 discards positional rows. Folding all 836 local logs with both versions gives identical figures after every event; retained state drops to 110 KB.
This commit is contained in:
1 parent
768473d755
commit
33c37200f8
13 files changed
+316
-76
No files matched your search
+22
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-10-13-context-breakdown-system-prompt-state.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.md
|
||||
/agent-note-context-breakdown-retains-only-system-prompts:
|
||||
en: 9b155f87202e037e
|
||||
zh: 8d4190c00412b892
|
||||
/agent-note-context-breakdown-retains-only-system-prompts/problem:
|
||||
en: 7cbbdfbe7da9ae7a
|
||||
zh: 50368b272d7b5ebc
|
||||
/agent-note-context-breakdown-retains-only-system-prompts/decision:
|
||||
en: 52d5c42112c5c6dc
|
||||
zh: 744dbbcc52193300
|
||||
/agent-note-context-breakdown-retains-only-system-prompts/verification:
|
||||
en: 68a7375ca7fe99e8
|
||||
zh: e3c7d108740e8952
|
||||
/agent-note-context-breakdown-retains-only-system-prompts/alternatives-considered:
|
||||
en: 2bd0e83c5298da2b
|
||||
zh: 336259b6985eb7b5
|
||||
/agent-note-context-breakdown-retains-only-system-prompts/consequences:
|
||||
en: 839a919ff16bc53c
|
||||
zh: bf0d4f3a783ead1d
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Agent Note: Context breakdown retains only system prompts
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-13-context-breakdown-system-prompt-state.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`contextBreakdown` kept one `{ seq, heuristicTokens, system }` entry per retained surface node so that any replacement could subtract the exact price of its range and re-select the last nonempty system prompt. Its checkpoint therefore grew with the number of messages in the model context. In a local corpus of 836 Sessions, 151,122 entries made the unit's rows 7.13 MB of the projection cache and up to 103 KB for one Session, and every cached Session listing validated all of them. Long tool-heavy runs below the compaction threshold kept about 2,000 entries each.
|
||||
|
||||
## Decision
|
||||
|
||||
[`breakdown-projection.ts`](../../../../packages/llm/token-meter/src/breakdown-projection.ts) retains the surviving `system/message` nodes in surface order with their heuristic prices, one running surface total, and at most one armed shadow-price claim. State version 6 discards version-5 rows; other projection keys keep their rows.
|
||||
|
||||
- An append adds its price; a `system/message` append also joins the end of the system list.
|
||||
- `compaction/summary` and `compaction/prune` arm a claim from `shadowedRange`, `shadowedTokenCount`, and the retained system prompts named by `shadowedSeqs`. The next event consumes or expires it. A replacement over the claimed range subtracts the claim price and drops those prompts; a replacement naming another range, or putting a `system/message` in place of a metered range, throws.
|
||||
- An unmetered replacement of one retained system prompt subtracts that prompt's own price. A `system/message` replacement keeps its position; any other replacement removes it.
|
||||
- Any other unmetered replacement folds at zero delta, the rule `contextPressure` already applies.
|
||||
|
||||
`systemTokens` is the last retained prompt with a positive price, and `messageTokens` is the surface total minus it; both are floored at zero. `shadowedSeqs` is the authoritative set of shadowed nodes, so membership decides which prompts leave without locating non-system endpoints in surface order.
|
||||
|
||||
## Verification
|
||||
|
||||
Folding all 836 stored logs with the version-5 and version-6 definitions produced identical breakdowns after every event. The logs held 2,206 metered replacements, 106 system-prompt rewrites, and no other replacement. Retained state fell from 7.13 MB to 110 KB, with at most five surviving system prompts in one Session.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep per-node entries with a faster validator.** A hand-written node check cut listing validation from 75 ms to 4.5 ms but kept 7.13 MB of rows, an O(surface) array copy per surface event, and checkpoint writes proportional to the message count.
|
||||
|
||||
**Columnar per-node entries.** Parallel arrays shrink rows about 5.7 times but keep O(surface) state for information the shadow-price events already carry.
|
||||
|
||||
**Exact pricing of unmetered non-system replacements.** That requires per-node prices. Current producers meter every non-system replacement, and released formats from v0 carry the shadow-price fields, so the remaining case is a log that breaks producer adjacency; it drifts like `contextPressure`.
|
||||
|
||||
## Consequences
|
||||
|
||||
Checkpoint size and per-event cost follow the number of surviving system prompts rather than messages, and cached listings no longer validate per-message entries. The figures stay exact for metered replacements and system-prompt rewrites, and their sum equals the `measure()` heuristic total under those producers. An unmetered non-system replacement leaves the figures off by that replacement's price difference. A future range replacement that inserts a system prompt is rejected until the fold can order it among the retained prompts. This supersedes the positional-entry choice for this unit in the [in-history system-prompt replacement](../feature/2026-09-02-in-history-system-prompt-replacement.md) note; `measure()` keeps positional nodes for route pricing.
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Agent Note: 上下文构成只保留系统提示词
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-13-context-breakdown-system-prompt-state.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`contextBreakdown` 曾为每个保留的 surface 节点存一条 `{ seq, heuristicTokens, system }`,以便任意替换都能减去其范围的精确价格,并重新选出最后一个非空系统提示词。因此它的检查点随模型上下文中的消息数增长。在一个包含 836 个 Session 的本地语料中,151,122 条条目让该单元的行占用 projection cache 的 7.13 MB,单个 Session 最多 103 KB,而每次缓存会话列表都要校验全部条目。低于压缩阈值、工具调用密集的长时间运行各自保留约 2,000 条条目。
|
||||
|
||||
## Decision
|
||||
|
||||
[`breakdown-projection.ts`](../../../../packages/llm/token-meter/src/breakdown-projection.ts) 按 surface 顺序保留存活的 `system/message` 节点及其启发式价格、一个 surface 运行总量,以及至多一个已武装的影子价 claim。状态版本 6 丢弃版本 5 的行;其他 projection key 保留各自的行。
|
||||
|
||||
- 追加加上其价格;`system/message` 追加还会加入系统列表末尾。
|
||||
- `compaction/summary` 和 `compaction/prune` 依据 `shadowedRange`、`shadowedTokenCount` 以及 `shadowedSeqs` 中列出的已保留系统提示词武装 claim。下一个事件消费或使其失效。覆盖所 claim 范围的替换减去 claim 价格并移除这些提示词;指向其他范围的替换,或以 `system/message` 取代已计量范围的替换,会抛出错误。
|
||||
- 对单个已保留系统提示词的未计量替换减去该提示词自身的价格。`system/message` 替换保持其位置;其他替换将其移除。
|
||||
- 其他未计量替换以零增量折叠,这与 `contextPressure` 已采用的规则相同。
|
||||
|
||||
`systemTokens` 是最后一个价格为正的保留提示词,`messageTokens` 是 surface 总量减去它;两者都不低于零。`shadowedSeqs` 是被遮蔽节点的权威集合,因此由成员关系决定哪些提示词离开,无需在 surface 顺序中定位非系统端点。
|
||||
|
||||
## Verification
|
||||
|
||||
用版本 5 和版本 6 的定义折叠全部 836 份已存日志,每个事件之后的构成数字都完全相同。这些日志包含 2,206 次已计量替换、106 次系统提示词改写,没有其他替换。保留状态从 7.13 MB 降到 110 KB,单个 Session 最多有五个存活的系统提示词。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留逐节点条目,换用更快的校验器。** 手写节点检查把列表校验从 75 ms 降到 4.5 ms,但仍保留 7.13 MB 的行、每个 surface 事件一次 O(surface) 数组复制,以及与消息数成正比的检查点写入。
|
||||
|
||||
**列式逐节点条目。** 并行数组让行缩小约 5.7 倍,但仍为影子价事件已经携带的信息保留 O(surface) 状态。
|
||||
|
||||
**精确定价未计量的非系统替换。** 这需要逐节点价格。当前生产方会计量每一次非系统替换,自 v0 起的已发布格式都携带影子价字段,因此剩余情况只是破坏生产方相邻性的日志;它像 `contextPressure` 一样产生偏差。
|
||||
|
||||
## Consequences
|
||||
|
||||
检查点大小和每个事件的成本取决于存活系统提示词的数量而不是消息数,缓存列表也不再校验逐消息条目。对已计量替换和系统提示词改写,数字保持精确,其和在这些生产方下等于 `measure()` 的启发式总量。未计量的非系统替换会让数字偏离该替换的价格差。未来插入系统提示词的范围替换会被拒绝,直到折叠能确定它在已保留提示词中的顺序。这取代了 [历史内系统提示词替换](../feature/2026-09-02-in-history-system-prompt-replacement.zh.md) 说明中针对该单元的位置条目选择;`measure()` 仍为路由定价保留位置节点。
|
||||
+4
-4
@@ -18,14 +18,14 @@
|
||||
en: 554d01427ebebee1
|
||||
zh: 664c9bf0a9337a04
|
||||
/agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/decision/presentation-and-accounting:
|
||||
en: 00a91b4fb592e1e4
|
||||
zh: dac2e4f0a3ee9ecf
|
||||
en: 800d579e2fcf2cce
|
||||
zh: 1e92354d9161c50a
|
||||
/agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/decision/compaction:
|
||||
en: c21a3e7a773860e5
|
||||
zh: 566669e245ccdbaf
|
||||
/agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/alternatives-considered:
|
||||
en: f3dec242b2550529
|
||||
zh: 0397fa07cc1806bb
|
||||
en: 02b49911bc4601d0
|
||||
zh: bfa19fd7c4337812
|
||||
/agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/consequences:
|
||||
en: beda064bcd194a2a
|
||||
zh: 8a3bb81d5edcd60a
|
||||
|
||||
+2
-2
@@ -43,7 +43,7 @@ An empty head with no active later system node represents no prompt. Dormant emp
|
||||
|
||||
Web presents an appended in-history node at its own position. `SystemPromptNode` carries `{ seq, time, turn, step, text, update }`, `update` being true for an appended `system/message` that follows an earlier system node in the loaded window. Chat renders a non-empty update as a collapsed `system-prompt` card titled by the locale key `message.systemPromptUpdate`, and a `request/header` in the same turn and step does not repeat the prompt card; `inspectRequestPrompt` reports no system change for a header that follows an update. Trajectory folds an update following a loaded request header into a synthetic request-header fact with `promptChange.kind = 'system'`, so later requests show the effective prompt without a real header change. When the loaded window lacks the earlier system node, the update is presented as an initial prompt. Transcript projections skip it like every `system/message`.
|
||||
|
||||
`dsh-token-meter` prices the last nonempty surviving system node in surface order as `contextBreakdown.systemTokens`; every other visible node, including superseded prompts, contributes to `messageTokens`. Empty dormant nodes are ignored. The sum equals the fixed-heuristic surface total after every replacement, whether or not a shadow-price claim exists. Compact retained entries reuse the measurement surface planner: state and transitions cost O(current retained surface), not O(1) or O(total historical log). Replaced entries and message bodies are discarded, and state version 4 rejects scalar checkpoints. `cacheReadTokens` on subsequent assistant usage remains the observable provider-cache effect.
|
||||
`dsh-token-meter` prices the last nonempty surviving system node in surface order as `contextBreakdown.systemTokens`; every other visible node, including superseded prompts, contributes to `messageTokens`. Empty dormant nodes are ignored. The unit retains only the surviving system prompts and a running surface total, and prices replacements from the adjacent shadow-price event ([system-prompt state](../architecture/2026-10-13-context-breakdown-system-prompt-state.md)). `cacheReadTokens` on subsequent assistant usage remains the observable provider-cache effect.
|
||||
|
||||
Trajectory chooses the newer of the preceding real header and the preceding synthetic system header as the comparison state. A real header owns configuration and tools; an appended prompt can advance that state without another real header. Comparing only real headers would report A rather than B as the previous prompt for an A → B → C sequence.
|
||||
|
||||
@@ -73,7 +73,7 @@ Chat and Trajectory interpret the effective prompt through the pure `uiConversat
|
||||
|
||||
**Clear only the latest system node.** Empty nodes project to no message, so an older prompt would become effective again. Clearing all active versions preserves the meaning of an empty rendering without deleting conversation history. Rejected.
|
||||
|
||||
**Keep only scalar totals or prompt ancestry.** A scalar shadow price cannot identify which bucket lost the newest prompt or restore its predecessor. Prompt-only entries cannot locate arbitrary nonprompt replacement endpoints; `sourceEventSeqs` may also cite surviving prompts, and event sequence order differs from surface order after rewrites. Retaining compact current surface entries reuses the existing planner without full-log access, a second validator, or consumer-specific durable events. Summing all surviving prompts as system tokens would change the intended effective-prompt meaning rather than fix classification.
|
||||
**Keep only scalar totals.** A scalar shadow price cannot identify which bucket lost the newest prompt or restore its predecessor. Summing all surviving prompts as system tokens would change the intended effective-prompt meaning rather than fix classification. Retained system prompts with the shadow-price event's `shadowedSeqs` answer both questions; [the system-prompt state note](../architecture/2026-10-13-context-breakdown-system-prompt-state.md) records that choice over positional entries.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
+2
-2
@@ -43,7 +43,7 @@ Status: implemented
|
||||
|
||||
Web 在追加的历史内节点自己的位置呈现它。`SystemPromptNode` 携带 `{ seq, time, turn, step, text, update }`,其中 `update` 对已加载窗口内跟在更早系统节点之后的追加 `system/message` 为真。Chat 把非空的更新渲染为一张折叠的 `system-prompt` 卡片,标题取自 locale 键 `message.systemPromptUpdate`,同一 turn 与 step 内的 `request/header` 不会重复提示词卡片;`inspectRequestPrompt` 对跟在更新之后的 header 不报告系统变更。Trajectory 把跟在已加载请求 header 之后的更新折叠为一条合成的请求 header 事实,`promptChange.kind = 'system'`,因此之后的请求无需真实的 header 变更就能显示有效提示词。已加载窗口缺少更早的系统节点时,更新按初始提示词呈现。转录投影像对待所有 `system/message` 一样跳过它。
|
||||
|
||||
`dsh-token-meter` 把 surface 顺序中最后一个非空且存活的系统节点计入 `contextBreakdown.systemTokens`;其余可见节点(包括被取代的提示词)计入 `messageTokens`。休眠空节点被忽略。每次替换后,两者之和都等于固定启发式 surface 总量,无论是否存在影子价 claim。紧凑的保留条目复用测量服务的 surface 规划器:状态和转换成本为 O(当前保留 surface),不是 O(1) 或 O(完整历史日志)。被替换条目和消息正文被丢弃,状态版本 4 拒绝标量检查点。后续 assistant 用量中的 `cacheReadTokens` 仍是可观察的提供方缓存效果。
|
||||
`dsh-token-meter` 把 surface 顺序中最后一个非空且存活的系统节点计入 `contextBreakdown.systemTokens`;其余可见节点(包括被取代的提示词)计入 `messageTokens`。休眠空节点被忽略。该单元只保留存活的系统提示词和一个 surface 运行总量,并依据紧邻的影子价事件为替换定价([系统提示词状态](../architecture/2026-10-13-context-breakdown-system-prompt-state.zh.md))。后续 assistant 用量中的 `cacheReadTokens` 仍是可观察的提供方缓存效果。
|
||||
|
||||
Trajectory 选择前一条真实 header 与前一条合成系统 header 中较新的一个作为比较状态。真实 header 拥有配置与工具;追加的提示词可以在没有另一条真实 header 时推进该状态。只比较真实 header 会在 A → B → C 序列中把 A 而不是 B 报告为先前提示词。
|
||||
|
||||
@@ -73,7 +73,7 @@ Chat 与 Trajectory 通过纯操作 `uiConversation.inspectSystemPrompt` 解释
|
||||
|
||||
**仅清除最新系统节点。** 空节点不投影为消息,因此更早的提示词会重新生效。清除所有生效版本才能保留空渲染文本的含义,同时不删除对话历史。被否决。
|
||||
|
||||
**只保留标量总量或提示词祖先链。** 标量影子价无法判断最新提示词从哪个分类消失,也无法恢复其前一个版本。仅有提示词条目无法定位任意非提示词替换端点;`sourceEventSeqs` 还可能引用存活提示词,改写后的事件序号顺序也不同于 surface 顺序。保留紧凑的当前 surface 条目可以复用现有规划器,无需完整日志访问、第二套验证器或消费方专用持久事件。把所有存活提示词之和归入系统数字会改变有效提示词的含义,而不是修复分类。
|
||||
**只保留标量总量。** 标量影子价无法判断最新提示词从哪个分类消失,也无法恢复其前一个版本。把所有存活提示词之和归入系统数字会改变有效提示词的含义,而不是修复分类。保留系统提示词并结合影子价事件的 `shadowedSeqs` 可以回答这两个问题;[系统提示词状态说明](../architecture/2026-10-13-context-breakdown-system-prompt-state.zh.md)记录了为何以这种方式取代位置条目。
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: 58de3b364ab5af6b
|
||||
zh: c5440af43d321e56
|
||||
/deepseek-ai-dsh-token-meter/use-this-package/session-projections:
|
||||
en: 0a4c3ae2763be227
|
||||
zh: 0e2c5a77f9b4b94f
|
||||
en: 6720ae9c7a0602b2
|
||||
zh: 699a0b9927300aa3
|
||||
/deepseek-ai-dsh-token-meter/use-this-package/composition:
|
||||
en: 1a0b7f49e01cba08
|
||||
zh: 666213a1024767c5
|
||||
@@ -45,8 +45,8 @@
|
||||
en: c97959d84cf8a1a4
|
||||
zh: f17570caa42a1a8e
|
||||
/deepseek-ai-dsh-token-meter/understand-the-implementation/projection-semantics:
|
||||
en: 91930cc59e849205
|
||||
zh: a530fcc3d57133bc
|
||||
en: d862c68a79e9eba1
|
||||
zh: 3a592a7a885cb6b2
|
||||
/deepseek-ai-dsh-token-meter/further-exploration:
|
||||
en: 32cf70f8c66ca097
|
||||
zh: a7daf5e0732f31c3
|
||||
|
||||
@@ -50,7 +50,7 @@ When the composition provides `ctx.sessionProjections`, token-meter registers th
|
||||
|
||||
Image offload reprices existing node identities while preserving prior usage anchors. The fixed reference heuristic excludes `offloaded` metadata, so an offload decision does not change `contextBreakdown` or the scalar heuristic total; route-aware measurement replaces the selected visual prices with placeholder text.
|
||||
|
||||
`contextBreakdown` classifies the last nonempty surviving `system/message` in surface order as `systemTokens`; empty dormant nodes contribute nothing, and no nonempty system means zero. `messageTokens` includes every other visible node, including superseded prompts. Their sum always equals `measure().nodes[].heuristicTokens`, including after unmetered replacements, compaction, and per-node prompt clearing. `toolsTokens` follows the latest `request/header`. All three use the fixed heuristic, not route image pricing or file-handle projection; they are approximate composition, not billing or `projectedTokens`.
|
||||
`contextBreakdown` classifies the last nonempty surviving `system/message` in surface order as `systemTokens`; empty dormant nodes contribute nothing, and no nonempty system means zero. `messageTokens` includes every other visible node, including superseded prompts. Their sum equals `measure().nodes[].heuristicTokens` after appends, metered compaction and pruning, and system-prompt rewrites or clearing; an unmetered replacement of a non-system range folds at zero delta, like `contextPressure`. `toolsTokens` follows the latest `request/header`. All three use the fixed heuristic, not route image pricing or file-handle projection; they are approximate composition, not billing or `projectedTokens`.
|
||||
|
||||
`deriveTurnTokenUsage(events)` folds one complete turn into exact per-attempt and whole-turn usage for browser consumers. It returns no result when lifecycle evidence is missing, counts are unsafe, or exact totals conflict; each corresponding aggregate appears only when every participating attempt reports its optional cache, reasoning, or route value.
|
||||
|
||||
@@ -100,7 +100,7 @@ Each `measure()` call synchronizes the fold to the current durable tail, then re
|
||||
|
||||
### Projection semantics
|
||||
|
||||
`contextBreakdown` retains plain-JSON `{ seq, heuristicTokens, system }` entries in surface order and reuses the measurement plan/commit fold. Its state and surface transitions are O(current retained surface), not O(1) and not O(total historical log); replaced entries and message bodies are not retained. State version 5 invalidates checkpoints priced with offload metadata. `contextPressure` remains the scalar shadow-price consumer: replacements without adjacent claims contribute zero delta. The usage fold retains one last-sample slot because legal logs never report usage for an earlier step after a later step reports usage.
|
||||
`contextBreakdown` retains only the surviving `system/message` nodes with their heuristic prices, one running surface total, and at most one armed shadow-price claim; a replacement subtracts the adjacent claim, whose `shadowedSeqs` also name the system prompts it removes, or the retained price of the one system prompt it rewrites ([decision](../../../.agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.md)). Its state follows the number of surviving system prompts, not the message count; state version 6 invalidates positional checkpoints. `contextPressure` is the other scalar shadow-price consumer: replacements without adjacent claims contribute zero delta. The usage fold retains one last-sample slot because legal logs never report usage for an earlier step after a later step reports usage.
|
||||
|
||||
`./estimate` exports the stateless text/content estimators used by tool-result retention. Model adapters supply request-image costs through `imageRequestPricing`.
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ const price = ctx.tokenMeter.estimateMessage(message)
|
||||
|
||||
图片省略重新计算现有节点的价格,同时保留此前的用量锚点。固定引用启发式规则不计入 `offloaded` 元数据,因此一次省略决定不改变 `contextBreakdown` 或标量启发式总量,按路由的测量则把所选图片的视觉价格换成占位文本价格。
|
||||
|
||||
`contextBreakdown` 把 surface 顺序中最后一个非空且存活的 `system/message` 归入 `systemTokens`;休眠的空节点不贡献 token,没有非空系统消息时为零。`messageTokens` 包含其余所有可见节点,包括被取代的提示词。两者之和始终等于 `measure().nodes[].heuristicTokens`,未计量替换、压缩和逐节点清空提示词之后也成立。`toolsTokens` 跟随最新 `request/header`。三个数字都使用固定启发式规则,而非路由图片定价或文件句柄投影;它们是近似构成,不是计费数据或 `projectedTokens`。
|
||||
`contextBreakdown` 把 surface 顺序中最后一个非空且存活的 `system/message` 归入 `systemTokens`;休眠的空节点不贡献 token,没有非空系统消息时为零。`messageTokens` 包含其余所有可见节点,包括被取代的提示词。在追加、已计量的压缩与裁剪、系统提示词改写或清空之后,两者之和等于 `measure().nodes[].heuristicTokens`;对非系统范围的未计量替换以零增量折叠,与 `contextPressure` 相同。`toolsTokens` 跟随最新 `request/header`。三个数字都使用固定启发式规则,而非路由图片定价或文件句柄投影;它们是近似构成,不是计费数据或 `projectedTokens`。
|
||||
|
||||
`deriveTurnTokenUsage(events)` 为浏览器消费方把一个完整轮次折叠为精确的逐次尝试与整轮用量。生命周期证据缺失、计数不安全或精确总量矛盾时不返回结果;只有每次参与的尝试都报告可选缓存、推理或路由值时,相应汇总才会出现。
|
||||
|
||||
@@ -100,7 +100,7 @@ const price = ctx.tokenMeter.estimateMessage(message)
|
||||
|
||||
### 投影语义
|
||||
|
||||
`contextBreakdown` 按 surface 顺序保留纯 JSON 的 `{ seq, heuristicTokens, system }` 条目,并复用测量服务的 plan/commit fold。其状态与 surface 转换成本为 O(当前保留 surface),不是 O(1),也不是 O(完整历史日志);被替换条目和消息正文不保留。状态版本 5 使计入省略元数据的检查点失效。`contextPressure` 仍是标量影子价消费方:没有相邻 claim 的替换贡献零增量。用量 fold 保留一个最后样本槽,因为合法日志不会在更晚步骤报告用量后再次报告更早步骤的用量。
|
||||
`contextBreakdown` 只保留存活的 `system/message` 节点及其启发式价格、一个 surface 运行总量,以及至多一个已武装的影子价 claim;替换减去紧邻的 claim(其 `shadowedSeqs` 同时列出被移除的系统提示词),或减去被改写的那个系统提示词的保留价格([决策](../../../.agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.zh.md))。其状态取决于存活系统提示词的数量,而非消息数;状态版本 6 使位置检查点失效。`contextPressure` 是另一个标量影子价消费方:没有相邻 claim 的替换贡献零增量。用量 fold 保留一个最后样本槽,因为合法日志不会在更晚步骤报告用量后再次报告更早步骤的用量。
|
||||
|
||||
`./estimate` 导出无服务状态的文字与内容估算函数,供工具结果保留复用。图片的实际请求成本由模型适配器的 `imageRequestPricing` 提供。
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
/**
|
||||
* Heuristic composition of the current retained surface, independent of route
|
||||
* image pricing and provider usage. Positional entries preserve system-prompt
|
||||
* classification across replacements without retaining historical messages.
|
||||
* image pricing and provider usage. The state keeps the surviving system
|
||||
* prompts in surface order and one running surface total; every other node is
|
||||
* priced only as a delta, so state size follows the system prompts, not the
|
||||
* message count.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { canonicalHeader, isSurfaceEvent, SessionSeq } from '@deepseek-ai/dsh-session'
|
||||
import { canonicalHeader, deriveEventMessage, isSurfaceEvent, SessionSeq } from '@deepseek-ai/dsh-session'
|
||||
import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
import { estimateToolsTokens } from './estimate.ts'
|
||||
import { commitSurfaceTokens, planSurfaceTokens } from './surface-fold.ts'
|
||||
// Type-only: the `compaction/*` SessionEventMap merges (shadow-price events).
|
||||
import type {} from '@deepseek-ai/dsh-compaction'
|
||||
import { estimateMessage, estimateToolsTokens } from './estimate.ts'
|
||||
// Import for the `contextBreakdown` SessionProjectionStateMap key merge.
|
||||
import type {} from './projection.ts'
|
||||
|
||||
@@ -27,52 +31,156 @@ const breakdownSchema = z.object({
|
||||
messageTokens: tokenCount,
|
||||
}).strict()
|
||||
|
||||
/** Plain-JSON checkpoint: one compact entry per retained surface position. */
|
||||
/** One surviving `system/message` node and its heuristic price. */
|
||||
const systemNodeSchema = z.object({
|
||||
seq: sessionSeq,
|
||||
tokens: tokenCount,
|
||||
}).strict()
|
||||
|
||||
/**
|
||||
* Shadow price armed by the `compaction/summary` or `compaction/prune` event
|
||||
* immediately before a replacement: the replaced range, its heuristic price,
|
||||
* and the surviving system prompts inside it.
|
||||
*/
|
||||
const claimSchema = z.object({
|
||||
start: sessionSeq,
|
||||
end: sessionSeq,
|
||||
tokens: tokenCount,
|
||||
systemSeqs: z.array(sessionSeq),
|
||||
}).strict()
|
||||
|
||||
/** Plain-JSON checkpoint: surviving system prompts in surface order, the surface total, and an armed claim. */
|
||||
const contextBreakdownStateSchema = z.object({
|
||||
nodes: z.array(z.object({
|
||||
seq: sessionSeq,
|
||||
heuristicTokens: tokenCount,
|
||||
system: z.boolean(),
|
||||
}).strict()),
|
||||
systems: z.array(systemNodeSchema),
|
||||
surfaceTokens: tokenCount,
|
||||
claim: claimSchema.optional(),
|
||||
breakdown: breakdownSchema,
|
||||
}).strict()
|
||||
type ContextBreakdownState = z.infer<typeof contextBreakdownStateSchema>
|
||||
type SystemNode = ContextBreakdownState['systems'][number]
|
||||
type BreakdownClaim = z.infer<typeof claimSchema>
|
||||
|
||||
/** The system prompts and surface total after one surface event. */
|
||||
interface SurfaceTotals {
|
||||
readonly systems: SystemNode[]
|
||||
readonly surfaceTokens: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Place one surface event's priced node.
|
||||
* @param state - state before the event, without its armed claim.
|
||||
* @param claim - shadow price armed by the immediately preceding event, if any.
|
||||
* @param event - the surface event.
|
||||
* @param tokens - the event message's heuristic price.
|
||||
* @returns the system prompts and surface total after the event.
|
||||
* @throws when a metered replacement names another range than its claim, or
|
||||
* puts a system message in place of a metered range.
|
||||
*/
|
||||
function placeSurfaceEvent(
|
||||
state: ContextBreakdownState,
|
||||
claim: BreakdownClaim | undefined,
|
||||
event: SurfaceEvent,
|
||||
tokens: number,
|
||||
): SurfaceTotals {
|
||||
const op = event.surfaceOp
|
||||
const node: SystemNode = { seq: event.seq, tokens }
|
||||
const isSystem = event.type === 'system/message'
|
||||
if (op === 'append') {
|
||||
return { systems: isSystem ? [...state.systems, node] : state.systems, surfaceTokens: state.surfaceTokens + tokens }
|
||||
}
|
||||
if (claim !== undefined) {
|
||||
if (claim.start !== op.startSeq || claim.end !== op.endSeq) {
|
||||
throw new Error(
|
||||
`context breakdown: replace at seq ${event.seq} over range ${op.startSeq}-${op.endSeq} has no adjacent shadow price`
|
||||
+ ` (armed claim covers ${claim.start}-${claim.end})`,
|
||||
)
|
||||
}
|
||||
if (isSystem) {
|
||||
throw new Error(
|
||||
`context breakdown: replace at seq ${event.seq} puts a system message in place of a metered range;`
|
||||
+ ' its order among the surviving system prompts is unknown',
|
||||
)
|
||||
}
|
||||
const shadowed = new Set<number>(claim.systemSeqs)
|
||||
return {
|
||||
systems: state.systems.filter(system => !shadowed.has(system.seq)),
|
||||
surfaceTokens: state.surfaceTokens + tokens - claim.tokens,
|
||||
}
|
||||
}
|
||||
const index = op.startSeq === op.endSeq ? state.systems.findIndex(system => system.seq === op.startSeq) : -1
|
||||
const replaced = state.systems[index]
|
||||
if (replaced === undefined) {
|
||||
// Bounded state cannot price an unmetered non-system range, so it folds at
|
||||
// zero delta; current producers meter every such replacement.
|
||||
return { systems: state.systems, surfaceTokens: state.surfaceTokens }
|
||||
}
|
||||
return {
|
||||
systems: isSystem ? state.systems.with(index, node) : state.systems.toSpliced(index, 1),
|
||||
surfaceTokens: state.surfaceTokens + tokens - replaced.tokens,
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish totals, keeping the previous breakdown object when its figures are unchanged. */
|
||||
function withTotals(state: ContextBreakdownState, totals: SurfaceTotals): ContextBreakdownState {
|
||||
const surfaceTokens = Math.max(0, totals.surfaceTokens)
|
||||
const systemTokens = totals.systems.findLast(system => system.tokens > 0)?.tokens ?? 0
|
||||
const messageTokens = Math.max(0, surfaceTokens - systemTokens)
|
||||
const breakdown = systemTokens === state.breakdown.systemTokens && messageTokens === state.breakdown.messageTokens
|
||||
? state.breakdown
|
||||
: { systemTokens, toolsTokens: state.breakdown.toolsTokens, messageTokens }
|
||||
return { ...state, systems: totals.systems, surfaceTokens, breakdown }
|
||||
}
|
||||
|
||||
/** Drop an armed claim; the state is returned unchanged when none is armed. */
|
||||
function withoutClaim(state: ContextBreakdownState): ContextBreakdownState {
|
||||
if (state.claim === undefined) return state
|
||||
const { claim: _expired, ...unclaimed } = state
|
||||
return unclaimed
|
||||
}
|
||||
|
||||
/**
|
||||
* Context composition with the last nonempty surviving system in surface
|
||||
* order classified as system tokens; all other visible prices are messages.
|
||||
* Replacements use the measurement planner, not shadow-price claims. State
|
||||
* and surface transitions cost O(current retained surface), not O(log length).
|
||||
* Tools are priced from the latest request header. No route pricing applies.
|
||||
* Appends add their price; a replacement subtracts the shadow price of the
|
||||
* adjacent `compaction/summary` or `compaction/prune` event, whose
|
||||
* `shadowedSeqs` also name the system prompts it removes, and a system-prompt
|
||||
* rewrite subtracts the retained price of the system node it replaces. Tools
|
||||
* are priced from the latest request header. No route pricing applies.
|
||||
*/
|
||||
export const contextBreakdownProjectionDefinition = {
|
||||
key: 'contextBreakdown',
|
||||
stateVersion: 5,
|
||||
stateVersion: 6,
|
||||
stateSchema: contextBreakdownStateSchema,
|
||||
init: (): ContextBreakdownState => ({
|
||||
nodes: [],
|
||||
systems: [],
|
||||
surfaceTokens: 0,
|
||||
breakdown: { systemTokens: 0, toolsTokens: 0, messageTokens: 0 },
|
||||
}),
|
||||
apply: (state, event) => {
|
||||
if (event.type === 'compaction/summary' || event.type === 'compaction/prune') {
|
||||
const shadowed = new Set<number>(event.data.shadowedSeqs)
|
||||
return {
|
||||
...state,
|
||||
claim: {
|
||||
start: SessionSeq(event.data.shadowedRange.start),
|
||||
end: SessionSeq(event.data.shadowedRange.end),
|
||||
tokens: event.data.shadowedTokenCount,
|
||||
systemSeqs: state.systems.filter(system => shadowed.has(system.seq)).map(system => system.seq),
|
||||
},
|
||||
}
|
||||
}
|
||||
// A claim prices only the event immediately after its metering event.
|
||||
const unclaimed = withoutClaim(state)
|
||||
if (event.type === 'request/header') {
|
||||
const toolsTokens = estimateToolsTokens(canonicalHeader(event.data.header))
|
||||
return toolsTokens === state.breakdown.toolsTokens
|
||||
? state
|
||||
: { ...state, breakdown: { ...state.breakdown, toolsTokens } }
|
||||
return toolsTokens === unclaimed.breakdown.toolsTokens
|
||||
? unclaimed
|
||||
: { ...unclaimed, breakdown: { ...unclaimed.breakdown, toolsTokens } }
|
||||
}
|
||||
if (!isSurfaceEvent(event)) return state
|
||||
const plan = planSurfaceTokens(state.nodes, event)
|
||||
const nodes = [...state.nodes]
|
||||
commitSurfaceTokens(nodes, {
|
||||
...plan,
|
||||
node: { seq: event.seq, heuristicTokens: plan.tokens, system: event.type === 'system/message' },
|
||||
})
|
||||
const systemTokens = nodes.findLast(node => node.system && node.heuristicTokens > 0)?.heuristicTokens ?? 0
|
||||
const messageTokens = state.breakdown.systemTokens + state.breakdown.messageTokens + plan.deltaTokens - systemTokens
|
||||
const breakdown = systemTokens === state.breakdown.systemTokens && messageTokens === state.breakdown.messageTokens
|
||||
? state.breakdown
|
||||
: { systemTokens, toolsTokens: state.breakdown.toolsTokens, messageTokens }
|
||||
return { nodes, breakdown }
|
||||
if (!isSurfaceEvent(event)) return unclaimed
|
||||
const message = deriveEventMessage(event)
|
||||
const tokens = message === null ? 0 : estimateMessage(message)
|
||||
return withTotals(unclaimed, placeSurfaceEvent(unclaimed, state.claim, event, tokens))
|
||||
},
|
||||
wire: {
|
||||
viewSchema: breakdownSchema,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Positional pricing shared by measurement and the context-breakdown fold:
|
||||
* measurement retains attachment details for route pricing; breakdown keeps
|
||||
* only retained node identities, heuristic prices, and system classification.
|
||||
* The occupancy projection uses the scalar shadow-price protocol instead.
|
||||
* Positional pricing for measurement: each retained node keeps its identity,
|
||||
* heuristic price, and the attachment details route pricing replaces. The
|
||||
* occupancy and context-breakdown projections use the scalar shadow-price
|
||||
* protocol instead.
|
||||
*
|
||||
* The fold is a plan/commit pair: {@link planSurfaceTokens} runs every
|
||||
* fallible step read-only and {@link commitSurfaceTokens} mutates in place,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* The context-pressure projection's O(1) surface-token fold.
|
||||
*
|
||||
* Its checkpoint keeps scalar totals rather than the retained surface used
|
||||
* by context breakdown. Replacements ride the compact
|
||||
* by measurement. Replacements ride the compact
|
||||
* seam's shadow-price protocol: the metering event immediately before a
|
||||
* surface `replace` (`compaction/summary` or `compaction/prune`) states the
|
||||
* heuristic price of the exact replaced range, so the fold keeps a running
|
||||
|
||||
@@ -294,21 +294,35 @@ describe('contextBreakdown session projection', () => {
|
||||
expect(agree()).toBeLessThan(grown)
|
||||
})
|
||||
|
||||
it('prices unmetered replacements and rejects absent ranges without mutating prior state', async () => {
|
||||
const { session } = await harness()
|
||||
it('folds an unmetered non-system replacement at zero delta and rejects contradicting claims without mutating prior state', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
const first = appendUser(session, 'first message')
|
||||
const last = appendUser(session, 'last message')
|
||||
const definition = contextBreakdownProjectionDefinition
|
||||
const state = session.snapshotEvents().reduce(definition.apply, definition.init())
|
||||
const before = JSON.stringify(state)
|
||||
const replacement = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'summary' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: { op: 'replace', startSeq: first, endSeq: last }, sourceEventSeqs: [first, last] })
|
||||
expect(definition.wire.view(definition.apply(state, replacement)).messageTokens).toBe(10)
|
||||
expect(JSON.stringify(state)).toBe(before)
|
||||
const invalid = { ...replacement, surfaceOp: { op: 'replace', startSeq: SessionSeq(999), endSeq: last } } as SessionEvent
|
||||
expect(() => definition.apply(state, invalid)).toThrow('invalid current range')
|
||||
const summary = createUserMessage({ content: [{ type: 'text', text: 'summary' }], source: { kind: 'user' } })
|
||||
const replacement = session.append('user/message', summary, {
|
||||
surfaceOp: { op: 'replace', startSeq: first, endSeq: last }, sourceEventSeqs: [first, last],
|
||||
})
|
||||
// Bounded state cannot price a range without an adjacent shadow price.
|
||||
expect(definition.wire.view(definition.apply(state, replacement))).toEqual(definition.wire.view(state))
|
||||
expect(JSON.stringify(state)).toBe(before)
|
||||
|
||||
appendSummaryMeter(ctx, session, replacement.seq, replacement.seq)
|
||||
const claimed = session.snapshotEvents().reduce(definition.apply, definition.init())
|
||||
const armed = JSON.stringify(claimed)
|
||||
const elsewhere = { ...replacement, seq: SessionSeq(session.seq), surfaceOp: { op: 'replace', startSeq: first, endSeq: first } } as SessionEvent
|
||||
expect(() => definition.apply(claimed, elsewhere)).toThrow('has no adjacent shadow price')
|
||||
const asSystem = {
|
||||
type: 'system/message',
|
||||
seq: SessionSeq(session.seq),
|
||||
time: 0,
|
||||
data: { turn: 1, step: 1, message: createSystemMessage('prompt') },
|
||||
surfaceOp: { op: 'replace', startSeq: replacement.seq, endSeq: replacement.seq },
|
||||
} as SessionEvent
|
||||
expect(() => definition.apply(claimed, asSystem)).toThrow('puts a system message in place of a metered range')
|
||||
expect(JSON.stringify(claimed)).toBe(armed)
|
||||
})
|
||||
|
||||
it('classifies the last nonempty system by position despite rewrites and extra source citations', async () => {
|
||||
@@ -326,15 +340,19 @@ describe('contextBreakdown session projection', () => {
|
||||
.toBe(ctx.tokenMeter.measure(session).nodes.reduce((total, node) => total + node.heuristicTokens, 0))
|
||||
}
|
||||
agree('last prompt in surface order')
|
||||
appendSummaryMeter(ctx, session, question, question)
|
||||
question = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'rewritten question' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: { op: 'replace', startSeq: question, endSeq: question }, sourceEventSeqs: [question] }).seq
|
||||
expect(question).toBeGreaterThan(middle)
|
||||
// Source-event references can cite a surviving prompt outside the replaced span.
|
||||
// Source-event references can cite a surviving prompt outside the replaced
|
||||
// span; the shadow price's own node list decides which prompts leave.
|
||||
appendSummaryMeter(ctx, session, question, middle)
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'middle summary' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: { op: 'replace', startSeq: question, endSeq: middle }, sourceEventSeqs: [question, middle, tail] })
|
||||
agree('last prompt in surface order')
|
||||
// An unmetered replacement of a retained system prompt is priced from that prompt's own entry.
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'tail summary' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: { op: 'replace', startSeq: tail, endSeq: tail }, sourceEventSeqs: [tail, head] })
|
||||
@@ -365,8 +383,9 @@ describe('contextBreakdown session projection', () => {
|
||||
expect(projected(ctx, session)).toMatchObject({ systemTokens: 0, messageTokens: 10 })
|
||||
})
|
||||
|
||||
it('retains only compact current surface entries as history grows and compacts', async () => {
|
||||
it('retains only system prompts and one surface total as history grows and compacts', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
appendSystem(session, 'head')
|
||||
const first = appendUser(session, 'the first of many messages')
|
||||
for (let index = 0; index < 24; index += 1) appendUser(session, `message number ${index} with some text`)
|
||||
const last = appendUser(session, 'the last message before compaction')
|
||||
@@ -375,14 +394,17 @@ describe('contextBreakdown session projection', () => {
|
||||
if (current === undefined) throw new Error('registered context breakdown has no state')
|
||||
return current
|
||||
}
|
||||
expect(state().nodes).toHaveLength(26)
|
||||
expect(Object.keys(state().nodes[0]!).sort()).toEqual(['heuristicTokens', 'seq', 'system'])
|
||||
const shadowed = [...session.surface.nodes]
|
||||
expect(Object.keys(state()).sort()).toEqual(['breakdown', 'surfaceTokens', 'systems'])
|
||||
expect(state().systems).toHaveLength(1)
|
||||
appendSummaryMeter(ctx, session, first, last)
|
||||
expect(state().claim).toMatchObject({ start: first, end: last, systemSeqs: [] })
|
||||
const shadowed = session.surface.nodes.filter(seq => seq >= first)
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'summary' }], source: { kind: 'test' },
|
||||
}), { surfaceOp: { op: 'replace', startSeq: first, endSeq: last }, sourceEventSeqs: shadowed })
|
||||
expect(state().nodes).toHaveLength(1)
|
||||
expect(projected(ctx, session).messageTokens).toBe(10)
|
||||
expect(state()).not.toHaveProperty('claim')
|
||||
expect(state().systems).toHaveLength(1)
|
||||
expect(projected(ctx, session)).toMatchObject({ systemTokens: 5, messageTokens: 10 })
|
||||
})
|
||||
|
||||
it('retains wire identity when a same-price rewrite changes only checkpoint positions', async () => {
|
||||
@@ -394,7 +416,7 @@ describe('contextBreakdown session projection', () => {
|
||||
const next = definition.apply(state, session.snapshotEvents().at(-1)!)
|
||||
expect(next).not.toBe(state)
|
||||
expect(definition.wire.view(next)).toBe(definition.wire.view(state))
|
||||
expect(state.nodes[0]?.seq).toBe(head)
|
||||
expect(state.systems[0]?.seq).toBe(head)
|
||||
})
|
||||
|
||||
it('replays late registration, resumes a compact checkpoint, and discards scalar version 2', async () => {
|
||||
@@ -412,31 +434,33 @@ describe('contextBreakdown session projection', () => {
|
||||
ctx.sessionProjections.checkpoint(session),
|
||||
)) as ReturnType<typeof ctx.sessionProjections.checkpoint>
|
||||
const row = checkpoint['contextBreakdown']!
|
||||
expect(row.ver).toBe(5)
|
||||
expect(row.ver).toBe(6)
|
||||
expect(ctx.sessionProjections.viewCheckpoint(checkpoint).contextBreakdown).toEqual(projected(ctx, session))
|
||||
appendSummaryMeter(ctx, session, first, last)
|
||||
const meter = session.snapshotEvents().at(-1)!
|
||||
const replacement = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'summary' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: { op: 'replace', startSeq: first, endSeq: last }, sourceEventSeqs: [first, last] })
|
||||
const restored = ctx.sessionProjections.restore(
|
||||
checkpoint, [replacement], SessionLogOffset(replacement.seq), session.header, session.inheritedEventCount,
|
||||
checkpoint, [meter, replacement], SessionLogOffset(meter.seq), session.header, session.inheritedEventCount,
|
||||
)
|
||||
expect(restored.snapshot.values.contextBreakdown).toEqual({ systemTokens: 5, toolsTokens: 0, messageTokens: 10 })
|
||||
const stale = { ...checkpoint, contextBreakdown: { ...row, ver: 2, val: { systemTokens: 1004, toolsTokens: 0, messageTokens: -989 } } }
|
||||
expect(ctx.sessionProjections.viewCheckpoint(stale).contextBreakdown).toBeUndefined()
|
||||
expect(ctx.sessionProjections.restoreFloor(stale)).toBe(0)
|
||||
expect(() => ctx.sessionProjections.restore(
|
||||
stale, [replacement], SessionLogOffset(replacement.seq), session.header, session.inheritedEventCount,
|
||||
stale, [meter, replacement], SessionLogOffset(meter.seq), session.header, session.inheritedEventCount,
|
||||
)).toThrow('re-read from seq 0')
|
||||
const replayed = ctx.sessionProjections.restore(
|
||||
stale, session.snapshotEvents(), SessionLogOffset(0), session.header, session.inheritedEventCount,
|
||||
)
|
||||
expect(replayed.snapshot.values.contextBreakdown).toEqual(projected(ctx, session))
|
||||
expect(replayed.checkpoint['contextBreakdown']?.ver).toBe(5)
|
||||
expect(replayed.checkpoint['contextBreakdown']?.ver).toBe(6)
|
||||
const invalid = {
|
||||
...checkpoint,
|
||||
contextBreakdown: {
|
||||
...row,
|
||||
val: { nodes: [{ seq: 0, heuristicTokens: -1, system: true }], breakdown: { systemTokens: 0, toolsTokens: 0, messageTokens: 0 } },
|
||||
val: { systems: [{ seq: 0, tokens: -1 }], surfaceTokens: 0, breakdown: { systemTokens: 0, toolsTokens: 0, messageTokens: 0 } },
|
||||
},
|
||||
}
|
||||
expect(() => ctx.sessionProjections.restore(
|
||||
@@ -465,7 +489,7 @@ describe('contextBreakdown session projection', () => {
|
||||
systemTokens: 8, toolsTokens: staleValue.toolsTokens, messageTokens: 9,
|
||||
})
|
||||
expect(restored.checkpoint).toEqual(current)
|
||||
expect(restored.checkpoint['contextBreakdown']?.ver).toBe(5)
|
||||
expect(restored.checkpoint['contextBreakdown']?.ver).toBe(6)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
@@ -494,6 +518,20 @@ describe('contextBreakdown session projection', () => {
|
||||
messageTokens: 9,
|
||||
})
|
||||
})
|
||||
|
||||
it('expires an armed shadow price at the next event so a later replacement folds at zero delta', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
const first = appendUser(session, 'first message')
|
||||
const last = appendUser(session, 'last message')
|
||||
appendSummaryMeter(ctx, session, first, last)
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
expect(ctx.sessionProjections.stateOf(session, 'contextBreakdown')).not.toHaveProperty('claim')
|
||||
const before = projected(ctx, session)
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'summary' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: { op: 'replace', startSeq: first, endSeq: last }, sourceEventSeqs: [first, last] })
|
||||
expect(projected(ctx, session)).toEqual(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shared estimator', () => {
|
||||
|
||||
Reference in new issue
Block a user