From cfa180c127715d52d5deb53b56c14725dd7dd73d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 20 Jul 2026 15:34:00 +0800 Subject: [PATCH] Resolve compaction policy per routed model --- ...n-pressure-and-overflow-recovery.i18n.yaml | 4 +- ...mpaction-pressure-and-overflow-recovery.md | 4 +- ...ction-pressure-and-overflow-recovery.zh.md | 4 +- ...07-15-replay-token-meter-service.i18n.yaml | 4 +- .../2026-07-15-replay-token-meter-service.md | 12 +- ...026-07-15-replay-token-meter-service.zh.md | 12 +- ...el-context-and-compaction-policy.i18n.yaml | 6 + ...ted-model-context-and-compaction-policy.md | 57 ++++ ...-model-context-and-compaction-policy.zh.md | 57 ++++ .../2026-06-18-compaction-capability-seam.md | 4 +- docs/config-catalog.md | 43 ++- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 16 +- docs/core-data-structures/core.md | 10 + docs/core-data-structures/llm-streaming.md | 13 +- docs/event-producer-consumer.md | 2 +- examples/acp-agent/cordis.yml | 14 +- examples/headless-agent/composition.md | 3 + examples/headless-agent/cordis.yml | 9 +- examples/jsonrpc-agent/cordis.yml | 7 +- examples/repl-agent/cordis.yml | 4 +- examples/repl-agent/tests/compaction.e2e.ts | 4 +- examples/repl-agent/tests/harness.ts | 14 +- packages/compact/compact-basic/README.md | 29 +- packages/compact/compact-basic/src/config.ts | 309 ++++++++++++++---- packages/compact/compact-basic/src/index.ts | 129 ++++++-- .../compact/compact-basic/src/summarizer.ts | 9 +- packages/compact/compact-basic/src/types.ts | 57 +++- .../compact-basic/tests/compact-basic.spec.ts | 274 ++++++++++++++-- .../tests/compact-loop-repro.spec.ts | 12 +- .../tests/loader-composition.spec.ts | 25 +- .../cordis/tool-cordis/src/api-catalog.ts | 8 + packages/llm/README.md | 2 +- packages/llm/llm-deepseek/README.md | 6 +- packages/llm/llm-deepseek/src/adapter.ts | 18 +- packages/llm/llm-deepseek/src/index.ts | 12 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 30 +- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/src/adapter.ts | 18 +- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 7 + packages/llm/llm/README.md | 5 +- packages/llm/llm/src/index.ts | 46 ++- packages/llm/llm/src/types.ts | 6 + packages/llm/llm/tests/service.spec.ts | 44 ++- packages/llm/token-meter/README.md | 14 +- packages/llm/token-meter/src/index.ts | 35 +- packages/llm/token-meter/src/types.ts | 7 +- .../llm/token-meter/tests/token-meter.spec.ts | 34 +- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 1 + website/zh-CN/api/harness/events.md | 2 +- website/zh-CN/api/harness/llm.md | 33 +- website/zh-CN/api/harness/token-meter.md | 17 +- website/zh-CN/guide/config.md | 32 +- 54 files changed, 1210 insertions(+), 319 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md create mode 100644 .agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml index a6344d276f..ee9ecbf681 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.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 -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: f1a1868cd00007fb24efb21779dcc94c098b54e2 -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: a4993de2830301610bb2a9b0d28e8bbdf0ed9c46 +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: e9e1215734aaf4765ff572476132d7715e3a55ec +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 5c5082345d0fabf7854d670de0fc28dfb3de5e7b diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md index f1a1868cd0..e9e1215734 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md @@ -32,9 +32,9 @@ If cancellation lands after assistant tool calls are durable but before all call `CompactService.compactIfNeeded(agent, trigger, signal)` accepts `trigger: 'pressure' | 'context-overflow'`. The interface gains no estimation methods or token types; `ctx.tokenMeter` remains the reusable accounting owner. -For `pressure`, compact-basic applies the service-wide threshold and retained-tail policy to one unified `ctx.tokenMeter.measure()` result. The same singleton meter owns range pricing, provenance, shadowed token counts, and non-shrinking-summary rejection. The common defaults remain threshold ratio `0.8`, retained history `floor(contextWindow × 0.16)`, summarization provider/model `''`, `maxTokens: 8192`, `compactionRetries: 1`, and `auto: true`. +For `pressure`, compact-basic resolves the durable provider/model target's adapter-owned capacity and exact-target policy, then applies the resulting threshold and retained-tail budgets to one unified `ctx.tokenMeter.measure()` result. The same singleton meter owns range pricing, provenance, shadowed token counts, and non-shrinking-summary rejection. Common defaults remain threshold ratio `0.8`, retained-history ratio `0.16`, summarization provider/model `''`, `maxTokens: 8192`, `compactionRetries: 1`, and `auto: true`; optional `modelPolicies` entries override them for an exact provider/model pair. -For canonical overflow, compact-basic bypasses scalar pressure and the normal retained-token budget. It chooses the maximal tool-balanced head range while leaving the newest indivisible unit, then attempts exactly one shrinking compaction under the same signal. The automatic listener snapshots `session.surface.replaceGeneration` and returns `{ action: 'retry' }` only when compaction succeeds and the generation increases. A backend returning a result without replacement cannot authorize retry. +For canonical overflow, compact-basic requires no capacity metadata and bypasses scalar pressure and the normal retained-token budget. It chooses the maximal tool-balanced head range while leaving the newest indivisible unit, then attempts exactly one shrinking compaction under the same signal. The automatic listener snapshots `session.surface.replaceGeneration` and returns `{ action: 'retry' }` only when compaction succeeds and the generation increases. A backend returning a result without replacement cannot authorize retry. `maxOverflowRetries` is optional and defaults to `1`; `0` disables overflow recovery without disabling pressure. `auto: false` registers neither automatic listener. Noncanonical errors, exhausted attempts, an already-aborted signal, a missing routed model, no safe range, no generation change, and recovery throws all delegate to the next listener. With no later recovery, the loop reports the original provider error object and code. Cancellation or disposal remains authoritative even if recovery work completes concurrently. diff --git a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md index a4993de283..5c5082345d 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md @@ -32,9 +32,9 @@ Status: implemented `CompactService.compactIfNeeded(agent, trigger, signal)` 接收 `trigger: 'pressure' | 'context-overflow'`。接口不增加估算方法或 token 类型;`ctx.tokenMeter` 继续作为可复用的核算所有者。 -对于 `pressure`,compact-basic 把服务级阈值与保留尾部策略应用到一次统一的 `ctx.tokenMeter.measure()` 结果。范围定价、来源、被遮蔽 token 数与非缩小摘要拒绝也由同一个单例 meter 完成。通用默认值保持为阈值比例 `0.8`、保留历史 `floor(contextWindow × 0.16)`、摘要提供方/模型 `''`、`maxTokens: 8192`、`compactionRetries: 1` 与 `auto: true`。 +对于 `pressure`,compact-basic 先解析持久提供方/模型目标的适配器所属容量与精确目标策略,再把得到的阈值与保留尾部预算应用到一次统一的 `ctx.tokenMeter.measure()` 结果。范围定价、来源、被遮蔽 token 数与非缩小摘要拒绝也由同一个单例 meter 完成。通用默认值保持为阈值比例 `0.8`、保留历史比例 `0.16`、摘要提供方/模型 `''`、`maxTokens: 8192`、`compactionRetries: 1` 与 `auto: true`;可选 `modelPolicies` 项可以按精确提供方/模型组合覆盖这些值。 -对于规范化溢出,compact-basic 绕过标量压力与普通保留 token 预算。它在保留最新不可分割单元的同时,选择最大的工具配对平衡头部范围,并在同一 signal 下只尝试一次缩小压缩。自动监听器先记录 `session.surface.replaceGeneration`,只有压缩成功且 generation 增加时才返回 `{ action: 'retry' }`。后端若只返回结果但没有替换表层,不能授权重试。 +对于规范化溢出,compact-basic 不要求容量元数据,并绕过标量压力与普通保留 token 预算。它在保留最新不可分割单元的同时,选择最大的工具配对平衡头部范围,并在同一 signal 下只尝试一次缩小压缩。自动监听器先记录 `session.surface.replaceGeneration`,只有压缩成功且 generation 增加时才返回 `{ action: 'retry' }`。后端若只返回结果但没有替换表层,不能授权重试。 `maxOverflowRetries` 可选且默认为 `1`;`0` 只禁用溢出恢复,不会禁用压力检查。`auto: false` 不注册任何自动监听器。非规范化错误、尝试耗尽、已经中止的 signal、缺失路由模型、没有安全范围、generation 未变化,以及恢复抛错都会委托给下一个监听器。若没有后续恢复,循环报告原始提供方错误对象与代码。即使恢复工作并发完成,取消或销毁仍具有最终优先级。 diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml index d49aedb545..c560903ca7 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.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 -2026-07-15-replay-token-meter-service.md: 9bbc177f456e006179c466f8c245e4599db3dd5a -2026-07-15-replay-token-meter-service.zh.md: 4437626c8651a80537d45197a93733271a592173 +2026-07-15-replay-token-meter-service.md: 4bedbb0cb9fa383108a688dbbb32546c7f39bd20 +2026-07-15-replay-token-meter-service.zh.md: ccf1b014cd86d16ef498b4209818e78be562e66f diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md index 9bbc177f45..4bedbb0cb9 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md +++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md @@ -6,7 +6,7 @@ English | [中文](2026-07-15-replay-token-meter-service.zh.md) ## Problem -Context pressure is useful outside compaction. A compaction backend, an overflow guard, or a future request-policy plugin can all need the same answer: how much of the configured context window does the durable request consume? Keeping that fold inside `dsh-compact-basic` duplicates replay logic, makes measurement unavailable without compaction, and encourages callers to reuse stale accounting. +Context pressure is useful outside compaction. A compaction backend, an overflow guard, or a future request-policy plugin can all need the same answer: how many tokens does the durable request consume? Keeping that fold inside `dsh-compact-basic` duplicates replay logic, makes measurement unavailable without compaction, and encourages callers to reuse stale accounting. Provider usage is not a complete answer. It describes one successful call under one exact request envelope, while the current surface can grow, shrink, or be replaced afterward. Sessions also switch providers and models, old logs can lack chunk provenance, and usage fields separate input, cache-read, cache-write, output, and reasoning counts. A useful service therefore combines the latest exact anchor with conservative heuristic repricing and exposes the log revision consumed by each result. @@ -14,9 +14,9 @@ Provider usage is not a complete answer. It describes one successful call under ### One concrete LLM-family service -`@deepseek-ai/dsh-token-meter` is one concrete package under `packages/llm/` and registers `ctx.tokenMeter`. It is not split into an interface and backend before a second implementation exists. `TokenMeterService` itself exposes `contextWindow`, `measure(session, requestHeader?)`, and `estimateMessage(message)`; consumers call the singleton service directly. +`@deepseek-ai/dsh-token-meter` is one concrete package under `packages/llm/` and registers `ctx.tokenMeter`. It is not split into an interface and backend before a second implementation exists. `TokenMeterService` itself exposes `measure(session, requestHeader?)` and `estimateMessage(message)`; consumers call the singleton service directly. -The service has one `contextWindow`, defaulting to 128,000 tokens and configurable as a positive integer. Estimation uses a fixed four-characters-per-token heuristic plus structural overhead. There are no model profiles, density settings, tokenizer backends, or language-specific strategies. +The service has no configuration. Estimation uses a fixed four-characters-per-token heuristic plus structural overhead. There are no model profiles, capacity settings, density settings, tokenizer backends, or language-specific strategies. Exact provider/model capacity is a separate adapter-owned query, as specified by the [routed model context and compaction policy Agent Note](2026-07-20-routed-model-context-and-compaction-policy.md). ### Per-session replay folds @@ -34,7 +34,7 @@ Usage sums the disjoint input, cache-read, cache-write, and output buckets. Reas Automatic compaction uses one unified measurement for each threshold-and-retention decision. The region transaction measures after appending its durable `compact/start` lock and again after asynchronous summarization; any intervening durable append changes `logRevision` and prevents replacement. -Compact policy has service-wide defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, `summarizationProvider: ''`, `summarizationModel: ''`, `maxTokens: 8192`, `compactionRetries: 1`, `maxOverflowRetries: 1`, and `auto: true`. Top-level `thresholdRatio` and `retainTokens` override the pressure policy; retention must remain below the resulting threshold. The summarization provider and model must both be set or both be empty; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. +Compact policy has service-wide defaults: threshold ratio `0.8`, retained-tail ratio `0.16`, `summarizationProvider: ''`, `summarizationModel: ''`, `maxTokens: 8192`, `compactionRetries: 1`, `maxOverflowRetries: 1`, and `auto: true`. Top-level fields apply to every routed target; exact provider/model entries in `modelPolicies` partially override them. Pressure scales ratios against capacity resolved from the owning adapter, and `retainTokens` may replace `retainRatio`; retention must remain below the resulting threshold. The summarization provider and model must both be set or both be empty; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. Automatic pressure runs at `agent/post-step` and measures the canonical durable envelope produced under the provider/model actually selected by `agent/request`. A headerless session has no completed routed request to assess and produces no work; any routed target can use the singleton estimator. Canonical overflow recovery uses the same measurement for forced range selection and retries only after a proven surface replacement. @@ -46,14 +46,14 @@ Unit tests cover fixed estimation, envelope invalidation and anchor replacement, - **Keep estimation inside `CompactService`** — rejected because measurement has consumers and replay semantics independent of compaction; it would also force every compactor to expose the same unrelated API. - **Split a token-meter interface from a heuristic backend immediately** — rejected because only one implementation exists. One concrete service preserves the future seam without speculative packages or configuration. -- **Keep model-keyed windows and density profiles** — rejected because the deployment currently has one context policy and one estimator. Model registries, unknown-model failures, and configurable density add branches without a second behavior to select. +- **Put model-keyed windows and density profiles in the meter** — rejected because replay estimation does not own model routing or capacity facts. The route-owning adapter exposes capacity, while compact-basic owns the consumer-specific threshold and retention policy. - **Keep separate scalar and surface measurements** — rejected because callers would need two reads and revision matching for one decision. A scalar-only read could avoid cloning nodes below threshold, but the split API introduces a caller-side race window; the unified snapshot accepts O(surface) cloning in exchange for coherence. - **Treat provider usage as portable between envelopes** — rejected because model, tools, prefixes, and call config are request facts. Mismatch reprices the whole current request. ## Consequences - Token pressure has one replay-aware owner that compaction and future plugins can share. -- The default makes the bundled composition usable with two zero-config plugin entries; deployments override one context capacity when needed. +- The default makes the meter a zero-config composition entry; deployments configure capacity on each route-owning adapter and optional policy overrides on compact-basic. - Fixed heuristic pricing remains an estimate of provider behavior and is not an exact tokenizer or request serializer. - Every measurement clones the current positional surface and therefore costs O(surface), including pressure checks that finish below threshold. - Measurements fail loudly on malformed durable boundaries. This turns corrupted replay into a named integration failure instead of silently drifting pressure. diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md index 4437626c86..ccf1b014cd 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -上下文压力并不只对压缩有用。压缩后端、溢出保护或未来的请求策略插件都可能需要回答同一个问题:持久请求占用了已配置上下文窗口的多少容量?如果把该折叠逻辑留在 `dsh-compact-basic` 内部,就会重复实现回放逻辑,使未加载压缩的调用方无法使用计量,并诱使调用方复用陈旧的核算结果。 +上下文压力并不只对压缩有用。压缩后端、溢出保护或未来的请求策略插件都可能需要回答同一个问题:持久请求消耗了多少 token?如果把该折叠逻辑留在 `dsh-compact-basic` 内部,就会重复实现回放逻辑,使未加载压缩的调用方无法使用计量,并诱使调用方复用陈旧的核算结果。 提供方 usage 也不是完整答案。它只描述某个精确请求信封下的一次成功调用,而当前表层之后还可能增长、缩小或被替换。会话也可能切换提供方与模型,旧日志可能缺少分片来源,usage 字段还会分别报告输入、缓存读取、缓存写入、输出与推理计数。因此,可用的服务必须把最新精确锚点与保守的启发式重新定价结合起来,并公开每个结果已经消费的日志修订号。 @@ -14,9 +14,9 @@ Status: implemented ### 一个具体的 LLM 家族服务 -`@deepseek-ai/dsh-token-meter` 是 `packages/llm/` 下的单个具体包,并注册 `ctx.tokenMeter`。在第二种实现出现之前,它不会被拆成接口与后端。`TokenMeterService` 本身公开 `contextWindow`、`measure(session, requestHeader?)` 与 `estimateMessage(message)`;消费方直接调用这个单例服务。 +`@deepseek-ai/dsh-token-meter` 是 `packages/llm/` 下的单个具体包,并注册 `ctx.tokenMeter`。在第二种实现出现之前,它不会被拆成接口与后端。`TokenMeterService` 本身公开 `measure(session, requestHeader?)` 与 `estimateMessage(message)`;消费方直接调用这个单例服务。 -服务只有一个 `contextWindow`,默认值为 128,000 token,并允许配置为正整数。估算采用固定的每 token 四个字符启发式规则,并加上结构开销。服务不提供模型 profile、密度设置、分词器后端或语言专用策略。 +服务没有配置。估算采用固定的每 token 四个字符启发式规则,并加上结构开销。服务不提供模型 profile、容量设置、密度设置、分词器后端或语言专用策略。精确提供方/模型容量由独立的适配器查询拥有,具体见[路由模型上下文与压缩策略 Agent Note](2026-07-20-routed-model-context-and-compaction-policy.md)。 ### 逐会话回放折叠 @@ -34,7 +34,7 @@ Usage 会对互不重叠的输入、缓存读取、缓存写入与输出 bucket 自动压缩的每次阈值与保留联合决策只使用一次统一计量。区域事务先追加持久 `compact/start` 锁,再执行一次计量,并在异步摘要完成后再次计量;期间任何持久追加都会改变 `logRevision`,从而阻止替换。 -压缩策略采用服务级默认值:阈值比例 `0.8`、保留尾部 `floor(contextWindow × 0.16)`、`summarizationProvider: ''`、`summarizationModel: ''`、`maxTokens: 8192`、`compactionRetries: 1`、`maxOverflowRetries: 1` 与 `auto: true`。顶层 `thresholdRatio` 与 `retainTokens` 覆盖压力策略;保留值必须小于最终阈值。摘要提供方与模型必须同时设置或同时为空;空组合先解析最近记录的请求目标,再使用 `AgentOptions` 中的组合。 +压缩策略采用服务级默认值:阈值比例 `0.8`、保留尾部比例 `0.16`、`summarizationProvider: ''`、`summarizationModel: ''`、`maxTokens: 8192`、`compactionRetries: 1`、`maxOverflowRetries: 1` 与 `auto: true`。顶层字段适用于每个路由目标;`modelPolicies` 中的精确提供方/模型项可以部分覆盖这些字段。压力检查根据所属适配器解析的容量缩放比例,`retainTokens` 可以替代 `retainRatio`;保留值必须小于最终阈值。摘要提供方与模型必须同时设置或同时为空;空组合先解析最近记录的请求目标,再使用 `AgentOptions` 中的组合。 自动压力检查运行在 `agent/post-step`,并计量 `agent/request` 实际所选提供方/模型产生的规范持久信封。没有请求头的会话尚无已完成的路由请求可供判断,因此不执行工作;任意路由目标都可使用这个单例估算器。规范化溢出恢复使用同一计量结果强制选择范围,并且只有在表层替换得到证明后才重试。 @@ -46,14 +46,14 @@ Usage 会对互不重叠的输入、缓存读取、缓存写入与输出 bucket - **把估算保留在 `CompactService` 内**——不予采纳,因为计量拥有独立于压缩的消费方与回放语义;它还会强迫每个压缩器暴露同一套无关 API。 - **立即把 token meter 拆成接口与启发式后端**——不予采纳,因为目前只有一种实现。单个具体服务保留未来接缝,同时避免推测性的包与配置。 -- **保留模型键控的窗口与密度 profile**——不予采纳,因为当前部署只有一种上下文策略与一个估算器。模型注册表、未知模型错误和可配置密度只增加分支,却没有第二种行为可供选择。 +- **把模型键控窗口与密度 profile 放进 meter**——不予采纳,因为回放估算不拥有模型路由或容量事实。路由所属适配器公开容量,compact-basic 则拥有消费方专用的阈值与保留策略。 - **保留独立的标量与表层计量**——不予采纳,因为消费方必须为一次决策执行两次读取并匹配修订号。仅读取标量可以避免在低于阈值时复制节点,但拆分 API 会在消费方引入竞态窗口;统一快照接受 O(surface) 复制成本,以换取结果一致性。 - **在不同信封之间移用提供方 usage**——不予采纳,因为模型、工具、前缀与调用配置都是请求事实。不匹配时会重新定价完整当前请求。 ## 后果 - Token 压力拥有一个可供压缩与未来插件共享的回放感知所有者。 -- 默认值让内置组合只需两个零配置插件条目即可使用;部署需要时只覆盖一个上下文容量。 +- 默认值让 meter 成为零配置组合项;部署在各个路由所属适配器上配置容量,并在 compact-basic 上配置可选策略覆盖。 - 固定启发式定价仍然只是提供方行为的估计,并不是精确分词器或请求序列化器。 - 每次计量都会复制当前的位置表层,因此成本为 O(surface),低于阈值即可结束的压力检查也不例外。 - 遇到畸形持久边界时,计量会明确失败。这会把损坏的回放转化为具名集成错误,而不是让压力静默漂移。 diff --git a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml new file mode 100644 index 0000000000..c9290db5ea --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 +2026-07-20-routed-model-context-and-compaction-policy.md: f0b9288d3d864bfcc2964862b1ff294406daa345 +2026-07-20-routed-model-context-and-compaction-policy.zh.md: cda740a5671a3ef8a5bb415e5cc45ca8397c1c59 diff --git a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md new file mode 100644 index 0000000000..f0b9288d3d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md @@ -0,0 +1,57 @@ +# Agent Note: Routed model context and compaction policy + +Status: implemented + +English | [中文](2026-07-20-routed-model-context-and-compaction-policy.zh.md) + +## Problem + +Compaction cannot safely apply one global context window when a process routes requests to models with different capacities. The same model id can also exist under multiple providers, and an adapter may accept dynamic ids absent from its advisory catalog. A wrong capacity either compacts too late and triggers avoidable overflow or compacts too early and discards useful context. + +Neither obvious configuration owner is sufficient. Compact-basic is optional and does not know which models an adapter accepts. LLM adapters own model routing but must not depend on an optional compaction plugin or absorb consumer-specific threshold, retention, summarizer, and retry policy. The design needs an authoritative capacity fact and optional per-target compaction policy without creating a second model registry. + +## Decision + +### Adapters own exact-route capacity + +`LlmAdapter.resolveModelContext(provider, model)` optionally returns `LlmModelContext` for one exact route. `LlmService.resolveModelContext()` selects the registered route owner, validates a positive integer `contextWindow`, and returns a detached value. The query is independent of `listModels()`: an unlisted dynamic model may have capacity metadata, and `undefined` means only that the adapter cannot describe capacity. + +The hand-rolled DeepSeek adapter accepts optional `contextWindow` on each configured model. Its two default model entries publish 128,000 tokens; an explicit entry without capacity and an unlisted pass-through id return `undefined`. The pi-ai adapter resolves capacity from the same catalog descriptor that authoritatively resolves the request model. + +### Token measurement remains model-agnostic + +`dsh-token-meter` has no configuration and no model profiles. It owns one fixed replay fold and returns absolute estimated token pressure plus positional surface prices. Removing global capacity keeps measurement reusable when compact-basic is absent and prevents replay accounting from becoming another model registry. + +### Compact-basic resolves a target spec + +Compact-basic owns consumer policy. Top-level fields define defaults; `modelPolicies` contains partial overrides keyed by the exact `{ provider, model }` pair. Duplicate targets and unknown or invalid fields fail plugin load. `thresholdRatio` defaults to `0.8`, and retention defaults to `retainRatio: 0.16`; callers may use an absolute `retainTokens` instead, but the two retention forms are mutually exclusive. After inheritance, a ratio retention that is not below its threshold ratio also fails plugin load because no model capacity can make that policy valid. + +For proactive pressure, compact-basic reads the latest durable request route, resolves its adapter capacity and exact-target policy, and scales ratios into a `ResolvedCompactSpec`. It performs this resolution on every check, so a provider or model switch in one session changes capacity and policy immediately. An absolute retained budget that is not below the scaled threshold fails when the target capacity first makes that comparison possible. + +The same exact-target override can select summarization provider/model, summarization output cap, convergence retries, and overflow retry cap. These are compaction concerns and never enter the adapter seam. + +### Target-specific pressure failures preserve optional composition + +An adapter that lacks capacity metadata remains a valid LLM route. Manual proactive pressure fails with a target-specific configuration error; the automatic listener warns once per exact route and continues with full history. The same per-route suppression applies when resolved capacity exposes an invalid absolute retention budget, while unrelated operational failures remain independently visible. Canonical provider-confirmed overflow does not need capacity metadata: it bypasses the proactive threshold and normal retention budget, attempts one maximal balanced reduction, and preserves the original provider error unless replacement proves progress. + +## Testing + +Service tests cover detached context metadata, invalid adapter output, catalog independence, and default absence. Adapter tests cover DeepSeek configured/default/unlisted behavior and pi-ai exact descriptor resolution. Compact tests cover ratio scaling, exact provider/model overrides, load-time rejection of invalid merged ratios, runtime absolute-budget validation, same-model-id provider switches, target-specific warning suppression, and capacity-independent overflow recovery. Loader fixtures reject the removed token-meter capacity setting, and examples configure capacity on adapters. + +## Alternatives considered + +- **Put capacity and all policies in compact-basic** — rejected because compact-basic would duplicate adapter model knowledge, dynamic unlisted models would require parallel registration, and capacity would disappear when compaction is not installed. +- **Put compaction policy in each LLM adapter** — rejected because adapters must remain independent of optional consumers, while summarization and retry policy are not provider facts. +- **Make `listModels()` authoritative** — rejected because discovery is advisory and some adapters intentionally accept dynamic ids. Correctness metadata must not turn selector membership into a routing whitelist. +- **Add per-model folds to token-meter** — rejected because the replay algorithm is shared; only the capacity and consumer policy change. Multiple folds would duplicate state without improving estimation. +- **Create a standalone model-context registry** — rejected because the adapter already owns authoritative route resolution. A second registry would introduce lifecycle ordering, duplicate-key, and drift problems without an independent backend. + +## Consequences + +- Capacity has one authoritative owner at the provider seam, while compaction policy stays in the optional consuming plugin. +- The same compact-basic instance safely handles different windows, provider switches, and identical model ids under different providers without consulting discovery metadata. +- LLM-only and meter-only compositions remain valid; loading compact-basic adds no reverse dependency from adapters. +- Deployments using explicit DeepSeek model lists must provide `contextWindow` for proactive pressure on those entries. Missing metadata is visible instead of silently applying a wrong global fallback. +- Ratio defaults scale naturally across models, while exact-target absolute retention remains available for deployment-specific behavior. + +This note supersedes the global-capacity and no-model-policy parts of the [replay token meter service Agent Note](2026-07-15-replay-token-meter-service.md). Its single-fold measurement decision remains unchanged. diff --git a/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md new file mode 100644 index 0000000000..cda740a567 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.zh.md @@ -0,0 +1,57 @@ +# Agent Note: 路由模型上下文与压缩策略 + +Status: implemented + +[English](2026-07-20-routed-model-context-and-compaction-policy.md) | 中文 + +## 问题 + +当一个进程把请求路由到不同容量的模型时,压缩不能安全地应用同一个全局上下文窗口。相同模型 id 也可能存在于多个提供方下,适配器还可能接受不在建议目录中的动态 id。错误容量要么让压缩触发过晚并造成原本可避免的溢出,要么让压缩触发过早并丢弃有用上下文。 + +两个直观的配置归属方都无法独立解决问题。Compact-basic 是可选插件,不知道适配器接受哪些模型。LLM 适配器拥有模型路由,但不能依赖可选压缩插件,也不应吸收消费方专用的阈值、保留、摘要器与重试策略。该设计既需要权威容量事实和可选的逐目标压缩策略,又不能建立第二套模型注册表。 + +## 决策 + +### 适配器拥有精确路由容量 + +`LlmAdapter.resolveModelContext(provider, model)` 可以为一条精确路由返回 `LlmModelContext`。`LlmService.resolveModelContext()` 选择已注册的路由所属方,验证 `contextWindow` 为正整数,并返回分离值。该查询独立于 `listModels()`:不在目录中的动态模型也可以拥有容量元数据,而 `undefined` 只表示适配器无法描述容量。 + +手写 DeepSeek 适配器允许每个已配置模型提供可选 `contextWindow`。两个默认模型项都公开 128,000 token;未提供容量的显式模型项与未列出的透传 id 返回 `undefined`。pi-ai 适配器从同一个目录描述符解析容量,该描述符也用于权威解析请求模型。 + +### Token 计量保持模型无关 + +`dsh-token-meter` 没有配置,也没有模型 profile。它拥有一个固定回放折叠,并返回绝对估算 token 压力与逐位置表层价格。移除全局容量后,未加载 compact-basic 时仍可复用计量,同时避免让回放核算变成另一套模型注册表。 + +### Compact-basic 解析目标规格 + +Compact-basic 拥有消费方策略。顶层字段定义默认值;`modelPolicies` 包含以精确 `{ provider, model }` 组合为键的部分覆盖。重复目标、未知字段或无效字段都会让插件加载失败。`thresholdRatio` 默认为 `0.8`,保留策略默认为 `retainRatio: 0.16`;调用方也可以改用绝对 `retainTokens`,但两种保留形式互斥。完成继承后,如果保留比例不小于阈值比例,插件也会加载失败,因为任何模型容量都无法让该策略有效。 + +对于主动压力检查,compact-basic 读取最新持久请求路由,解析其适配器容量与精确目标策略,再把比例缩放为 `ResolvedCompactSpec`。每次检查都会重新解析,因此同一会话切换提供方或模型后,容量与策略会立即变化。若绝对保留预算不小于缩放后的阈值,系统会在目标容量首次允许比较两者时失败。 + +同一精确目标覆盖还可以选择摘要提供方/模型、摘要输出上限、收敛重试次数与溢出重试上限。这些都属于压缩问题,不会进入适配器 seam。 + +### 目标专用压力错误仍保留可选组合 + +缺少容量元数据的适配器仍是有效 LLM 路由。手动主动压力检查会返回目标专用配置错误;自动监听器按精确路由只警告一次,并继续保留完整历史。当已解析容量暴露出无效的绝对保留预算时,系统也按路由抑制重复警告;其他运行故障仍会各自对外可见。提供方已经确认的规范化溢出不需要容量元数据:它绕过主动阈值与普通保留预算,尝试一次最大的平衡缩减,并在替换无法证明进展时保留原始提供方错误。 + +## 测试 + +服务测试覆盖分离上下文元数据、无效适配器输出、目录独立性与默认缺失行为。适配器测试覆盖 DeepSeek 的配置值、默认值与未列出行为,以及 pi-ai 的精确描述符解析。压缩测试覆盖比例缩放、精确提供方/模型覆盖、加载期拒绝无效合并比例、运行时校验绝对预算、相同模型 id 的提供方切换、目标专用警告抑制与不依赖容量的溢出恢复。Loader fixture 会拒绝已经移除的 token-meter 容量设置,示例则在适配器上配置容量。 + +## 考虑过的替代方案 + +- **把容量与所有策略都放进 compact-basic**——不予采纳,因为 compact-basic 会复制适配器的模型知识,未列出的动态模型需要并行注册,而且未安装压缩时容量也会消失。 +- **把压缩策略放进各个 LLM 适配器**——不予采纳,因为适配器必须独立于可选消费方,而摘要与重试策略也不是提供方事实。 +- **让 `listModels()` 成为权威来源**——不予采纳,因为发现能力只是建议信息,一些适配器有意接受动态 id。正确性元数据不能把选择器成员关系变成路由白名单。 +- **给 token-meter 增加逐模型折叠**——不予采纳,因为回放算法可以共享,变化的只有容量与消费方策略。多个折叠会重复状态,却不会改善估算。 +- **建立独立模型上下文注册表**——不予采纳,因为适配器已经拥有权威路由解析。第二套注册表会引入生命周期顺序、重复键与漂移问题,却没有独立后端。 + +## 后果 + +- 容量在提供方 seam 上拥有唯一权威归属方,而压缩策略留在可选消费插件中。 +- 同一个 compact-basic 实例无需查询发现元数据,就能安全处理不同窗口、提供方切换,以及不同提供方下的相同模型 id。 +- 仅 LLM 与仅 meter 的组合仍然有效;加载 compact-basic 不会让适配器产生反向依赖。 +- 使用显式 DeepSeek 模型列表的部署必须为需要主动压力检查的条目提供 `contextWindow`。系统会暴露缺失元数据,而不是静默应用错误的全局回退值。 +- 比例默认值会随模型自然缩放,同时仍可按精确目标使用绝对保留值,以满足部署专用行为。 + +本记录取代[回放式 token 计量服务 Agent Note](2026-07-15-replay-token-meter-service.md) 中的全局容量与无模型策略部分,单折叠计量决策保持不变。 diff --git a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md index a7b06a8379..f90541f29b 100644 --- a/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -52,7 +52,7 @@ retry → next numbered step/start ⟵ derives from the replacement surface Auto-compaction checks after **every successful** step, not once per turn. This is load-bearing for runaway-turn survival: a tool-heavy ReAct turn appends an `assistant/message` + a `tool/result` per step, so the surface grows within a turn. The post-step check can compact early closed tool pairs before continuation opens the next step, and provider-confirmed overflow remains the backstop when a request crosses the limit first. -`compactIfNeeded` retains the smallest tail of whole surface units whose estimated size reaches `retainTokens` and compacts older nodes. A unit is a complete closed step or one no-step message. If the token cutoff lands inside a step, retention expands until the cut is tool-pairing balanced. Balance is checked on surface order, not log sequence, because replacement summaries have new sequence numbers at old surface positions. `dsh-compact` exports the before/after edge helpers; their per-session cache folds only appended surface-tail nodes while `replaceGeneration` is unchanged, does no event reads for log-only growth, and rebuilds current membership and balances after replacement. `compactRegion` rejects boundaries that split a tool call from its result. The in-flight turn receives no special retention. +`compactIfNeeded` retains the smallest tail of whole surface units whose estimated size reaches the resolved retained-token budget and compacts older nodes. A unit is a complete closed step or one no-step message. If the token cutoff lands inside a step, retention expands until the cut is tool-pairing balanced. Balance is checked on surface order, not log sequence, because replacement summaries have new sequence numbers at old surface positions. `dsh-compact` exports the before/after edge helpers; their per-session cache folds only appended surface-tail nodes while `replaceGeneration` is unchanged, does no event reads for log-only growth, and rebuilds current membership and balances after replacement. `compactRegion` rejects boundaries that split a tool call from its result. The in-flight turn receives no special retention. A runaway turn thus compacts exactly like any other history: its early *closed* steps get summarized while its recent steps stay verbatim. When the only compactable content left is an un-splittable open tail step (its tool-calls have no results yet), compaction declines (`null`) and retries once that step closes. @@ -64,7 +64,7 @@ Auto-compaction always starts at the surface head, merging the prior checkpoint ### Approximate convergence invariant -`resolveConfig` supplies usable defaults: threshold ratio `0.8`, retained tail `floor(contextWindow × 0.16)`, empty summarization provider/model overrides, `maxTokens: 8192`, `compactionRetries: 1`, `maxOverflowRetries: 1`, and `auto: true`. Optional top-level `thresholdRatio` and `retainTokens` override the policy for the token meter's single context window; retention must remain below the resulting threshold. Convergence remains dynamic because provider output caps can be spent on hidden or surfaced reasoning tokens and summary size is unpredictable. If pressure remains over threshold, `compactIfNeeded()` re-compacts the head checkpoint up to the configured retry count, but each committed summary must be smaller than what it shadows. Overflow bypasses threshold and retained-tail policy for one maximal balanced head reduction, leaving the newest indivisible unit. +`resolveConfig` supplies usable defaults: threshold ratio `0.8`, retained-tail ratio `0.16`, empty summarization provider/model overrides, `maxTokens: 8192`, `compactionRetries: 1`, `maxOverflowRetries: 1`, and `auto: true`. Optional exact provider/model policies partially override the top-level defaults; pressure scales ratios against capacity from the route-owning LLM adapter, while `retainTokens` can replace ratio retention. Retention must remain below the resulting threshold. Convergence remains dynamic because provider output caps can be spent on hidden or surfaced reasoning tokens and summary size is unpredictable. If pressure remains over threshold, `compactIfNeeded()` re-compacts the head checkpoint up to the configured retry count, but each committed summary must be smaller than what it shadows. Overflow needs no capacity metadata and bypasses threshold and retained-tail policy for one maximal balanced head reduction, leaving the newest indivisible unit. The ownership split is specified by the [routed model context and compaction policy Agent Note](../architecture/2026-07-20-routed-model-context-and-compaction-policy.md). ### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 11550beefb..bf55077c64 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -287,15 +287,25 @@ Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:21`](../package Requires: `llm` · `tokenMeter` ```ts config-catalog -/** Basic compaction configuration; every common field has a deployment default. */ -export interface BasicCompactConfig { - /** Compact at this fraction of the token meter's context window. Defaults to `0.8`. */ +/** Basic compaction configuration with an optional exact-target policy table. */ +export interface BasicCompactConfig extends CompactPolicyConfig { + /** Exact provider/model overrides; duplicate targets fail plugin load. */ + modelPolicies?: ModelCompactPolicyConfig[] + /** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */ + auto?: boolean +} + +/** Policy fields shared by the default policy and exact model overrides. */ +export interface CompactPolicyConfig { + /** Compact at this fraction of the model's context window. Defaults to `0.8`. */ thresholdRatio?: number - /** Recent surface tokens retained verbatim. Defaults to `floor(contextWindow * 0.16)`. */ + /** Recent context retained as a fraction of the model's window. Defaults to `0.16`. */ + retainRatio?: number + /** Absolute recent-context budget; mutually exclusive with `retainRatio`. */ retainTokens?: number - /** Summary provider; `''` resolves the latest routed pair, then the agent pair. Defaults to `''`. */ + /** Summary provider; set together with `summarizationModel`, or inherit the conversation target. */ summarizationProvider?: string - /** Summary model; `''` resolves the latest routed pair, then the agent pair. Defaults to `''`. */ + /** Summary model; set together with `summarizationProvider`, or inherit the conversation target. */ summarizationModel?: string /** Provider generation cap for summarization. Defaults to `8192`. */ maxTokens?: number @@ -303,12 +313,18 @@ export interface BasicCompactConfig { compactionRetries?: number /** Maximum retries after canonical context overflow; `0` disables recovery. Defaults to `1`. */ maxOverflowRetries?: number - /** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */ - auto?: boolean +} + +/** Exact provider/model override merged over the default compaction policy. */ +export interface ModelCompactPolicyConfig extends CompactPolicyConfig { + /** Registered provider route to match. */ + provider: string + /** Exact routed model id to match within `provider`. */ + model: string } ``` -Source: [`packages/compact/compact-basic/src/types.ts:8`](../packages/compact/compact-basic/src/types.ts) +Source: [`packages/compact/compact-basic/src/types.ts:38`](../packages/compact/compact-basic/src/types.ts) ## `@deepseek-ai/dsh-fs-local` @@ -437,6 +453,8 @@ export interface DeepSeekCatalogModel { name?: string /** Optional selector detail for deployments with similar model variants. */ description?: string + /** Known combined request/response context capacity; omitted when deployment metadata is unavailable. */ + contextWindow?: number } ``` @@ -1005,11 +1023,8 @@ Source: [`packages/context/time-context/src/index.ts:19`](../packages/context/ti ## `@deepseek-ai/dsh-token-meter` ```ts config-catalog -/** Token-meter plugin configuration. */ -export interface TokenMeterConfig { - /** Service-wide context-window capacity in tokens. Defaults to `128000`. */ - contextWindow?: number -} +/** Token-meter plugin configuration; the fixed estimator has no settings. */ +export type TokenMeterConfig = object ``` Source: [`packages/llm/token-meter/src/types.ts:10`](../packages/llm/token-meter/src/types.ts) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 5d6c627751..61e137ccb2 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -473,7 +473,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:43`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:50`](../../packages/llm/llm/src/index.ts) ## `session/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 10cd0bbf05..8ff9c8db0b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -508,6 +508,16 @@ listProviders(): LlmProviderInfo[] */ async listModels(provider: string): Promise +/** + * Resolve context capacity from the adapter that owns one exact route. + * This query is independent of the advisory model catalog: an unlisted model + * may return metadata, while `undefined` never rejects later routing. + * @param provider - registered provider route to inspect. + * @param model - exact model id passed to the adapter. + * @returns detached context metadata, or `undefined` when the adapter has none. + */ +async resolveModelContext( provider: string, model: string, ): Promise + /** * Stream one model call as raw chunks (token-level deltas). Throws * `LlmError` with code `NO_ADAPTER` if no adapter is registered for @@ -523,9 +533,9 @@ async listModels(provider: string): Promise stream(options: GenerateOptions): AsyncIterable ``` -Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) +Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmModelContext](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:97`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:118`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` @@ -1106,7 +1116,7 @@ estimateMessage(message: Message): number Types: [EpochHeader](../core-data-structures/session.md) · [Message](../core-data-structures/core.md) · [Session](../core-data-structures/session.md) · [TokenMeasurement](../core-data-structures/token-meter.md) -Source: [`packages/llm/token-meter/src/index.ts:106`](../../packages/llm/token-meter/src/index.ts) +Source: [`packages/llm/token-meter/src/index.ts:82`](../../packages/llm/token-meter/src/index.ts) ## `ctx.tools` — `ToolRegistry` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 1b87513e11..67be566f48 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -190,6 +190,16 @@ interface LlmModelInfo { } ``` +Correctness-sensitive model capacity is queried separately from the advisory catalog and is owned by the adapter serving the exact route. + +```ts type-equiv +/** Provider-owned context capacity for one exact provider/model route. */ +interface LlmModelContext { + /** Maximum combined request and response context in tokens. */ + contextWindow: number +} +``` + ```ts type-equiv /** A single model request, fully assembled. */ interface GenerateOptions { diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 41ce6427e6..f370edb84a 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -132,7 +132,7 @@ declare class BlockAssembler { ## The seam -`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerInfo()` and asynchronous `listModels()` methods feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). +`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerInfo()` and asynchronous `listModels()` methods feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. The separate `resolveModelContext()` query exposes correctness-sensitive capacity for an exact route without making catalog membership authoritative; absence means unknown metadata, not invalid routing. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). ```ts public-api /** @@ -156,6 +156,17 @@ declare abstract class LlmAdapter { * @returns discoverable models in adapter-preferred order. */ listModels(_provider: string): Promise; + /** + * Resolve context capacity for one model accepted by this adapter. Absence + * means the adapter does not know the capacity, not that routing is invalid. + * @param _provider - one provider route owned by this adapter. + * @param _model - exact model id passed to {@link GenerateOptions.model}. + * @returns provider-owned context metadata, or `undefined` when unavailable. + */ + resolveModelContext( + _provider: string, + _model: string, + ): Promise; /** * Stream one model call as raw chunks. The only required method. * @param options - the fully-assembled request; implementations must honor `options.signal`. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4f6e6daff0..5e56af710d 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:61`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:43`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:50`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`workspace-context`](../packages/context/workspace-context) | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 510fac46b6..b54e47dac9 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -9,6 +9,11 @@ config: apiKey: !!js process.env.DEEPSEEK_API_KEY baseURL: !!js process.env.DEEPSEEK_BASE_URL + models: + - id: deepseek-v4-flash + contextWindow: 256000 + - id: deepseek-v4-pro + contextWindow: 256000 # The default composition confines bash to the workspace and asks before a # wider retry. Snapshots use danger-full-access; DSH_PERMISSION_MODE overrides @@ -48,20 +53,17 @@ Verify your work by running the code or tests. Keep answers brief and factual. -# Replay-aware request pressure with one service-wide context window. +# Replay-aware request pressure; the routed adapter supplies model capacity. - id: token-meter name: '@deepseek-ai/dsh-token-meter' - config: - # FIXME: Resolve compaction config per model; this capacity assumes a 256k context window. - contextWindow: 256000 # Summarize an older range after measured pressure or a canonical provider overflow. -# Service-wide policy provides pressure, retention, and one overflow-retry default. +# Ratios scale against the routed model's context window. - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' config: thresholdRatio: 0.8 - retainTokens: 20480 + retainRatio: 0.08 maxTokens: 8192 compactionRetries: 1 diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index 9533af28d3..49fcac96aa 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -21,6 +21,8 @@ flowchart LR bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] + plugin_headless_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] + cfg --> plugin_headless_token_meter plugin_headless_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] cfg --> plugin_headless_compact_basic plugin_headless_subagent["subagent
@deepseek-ai/dsh-subagent"] @@ -52,6 +54,7 @@ flowchart LR | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `cli-agent` | `@deepseek-ai/dsh-cli-demo` | +| `token-meter` | `@deepseek-ai/dsh-token-meter` | | `compact-basic` | `@deepseek-ai/dsh-compact-basic` | | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml index 7f48c529c4..a4e02d2de0 100644 --- a/examples/headless-agent/cordis.yml +++ b/examples/headless-agent/cordis.yml @@ -11,7 +11,9 @@ baseURL: !!js process.env.DEEPSEEK_BASE_URL models: - id: deepseek-v4-pro + contextWindow: 128000 - id: deepseek-v4-flash + contextWindow: 128000 - id: bash name: '@deepseek-ai/dsh-bash-local' @@ -34,13 +36,14 @@ factual. # Summarize an older range when derived history approaches the context window. +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' config: - contextWindow: 128000 thresholdRatio: 0.8 - retainTokens: 20480 - summarizationModel: '' + retainRatio: 0.16 maxTokens: 8192 compactionRetries: 1 diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml index 00ef48f4ed..5c8029db6b 100644 --- a/examples/jsonrpc-agent/cordis.yml +++ b/examples/jsonrpc-agent/cordis.yml @@ -63,12 +63,13 @@ - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' config: - contextWindow: 128000 thresholdRatio: 0.8 - retainTokens: 20480 - summarizationModel: '' + retainRatio: 0.16 maxTokens: 8192 compactionRetries: 1 diff --git a/examples/repl-agent/cordis.yml b/examples/repl-agent/cordis.yml index a8a211a795..a782fb9903 100644 --- a/examples/repl-agent/cordis.yml +++ b/examples/repl-agent/cordis.yml @@ -47,12 +47,12 @@ Verify your work by running the code or tests. Keep answers brief and factual. -# Replay-aware request pressure with one service-wide context window. +# Replay-aware request pressure; the routed adapter supplies model capacity. - id: token-meter name: '@deepseek-ai/dsh-token-meter' # Summarize an older range after measured pressure or a canonical provider overflow. -# Service-wide policy provides pressure, retention, and one overflow-retry default. +# Ratios scale against the routed model's context window. - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' diff --git a/examples/repl-agent/tests/compaction.e2e.ts b/examples/repl-agent/tests/compaction.e2e.ts index d992fc9efa..fcebddb863 100644 --- a/examples/repl-agent/tests/compaction.e2e.ts +++ b/examples/repl-agent/tests/compaction.e2e.ts @@ -33,9 +33,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa // Reasoning tokens require a larger generation cap than the retained checkpoint. ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT, - tokenMeter: { - contextWindow: 2000, - }, + modelContextWindow: 2000, compact: { thresholdRatio: 0.5, retainTokens: 400, diff --git a/examples/repl-agent/tests/harness.ts b/examples/repl-agent/tests/harness.ts index eeba57fc61..d7777acbb6 100644 --- a/examples/repl-agent/tests/harness.ts +++ b/examples/repl-agent/tests/harness.ts @@ -8,7 +8,6 @@ import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import TokenMeterService from '@deepseek-ai/dsh-token-meter' -import type { TokenMeterConfig } from '@deepseek-ai/dsh-token-meter' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' @@ -45,8 +44,8 @@ export interface CodingHarnessOptions { * compaction plugin (the default suites run without it). */ compact?: BasicCompactConfig - /** Optional token-meter capacity loaded before compact-basic. */ - tokenMeter?: TokenMeterConfig + /** Test-only context capacity advertised for `deepseek-v4-flash`. */ + modelContextWindow?: number } export async function codingHarness(workdir: string, options: CodingHarnessOptions = {}): Promise { @@ -55,14 +54,15 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio systemPrompt: { persona: options.persona ?? '' }, }) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LlmDeepSeek) + await ctx.plugin(LlmDeepSeek, options.modelContextWindow === undefined ? {} : { + models: [{ id: 'deepseek-v4-flash', contextWindow: options.modelContextWindow }], + }) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) await ctx.plugin(ToolBash) await ctx.plugin(ToolTodo) - // Compaction is opt-in: only the compaction e2e loads the reusable meter and - // backend, with a lower context window so a short real session crosses the threshold. + // Compaction is opt-in: only the compaction e2e loads the reusable meter and backend. if (options.compact !== undefined) { - await ctx.plugin(TokenMeterService, options.tokenMeter) + await ctx.plugin(TokenMeterService) await ctx.plugin(BasicCompactService, options.compact) } // Durable JSONL persistence is opt-in: only the resume e2e needs it, and the diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 6a67d2dfd4..01729bfd36 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -9,31 +9,38 @@ This is the implementation tier of the compaction capability — see the [interf This backend owns the compaction policy: - **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Post-step pressure therefore includes the actual system prompt, tools, prefix, routing, assistant completion, tool results, buffered context, and steering. +- **Routed policy** — proactive pressure resolves capacity from the adapter that owns the latest durable provider/model route, then scales the default policy plus an optional exact-target override into concrete token budgets. Model discovery remains advisory and is not consulted. - **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope. - **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold. - **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. - **Framing** — the replacement user message marks established checkpoint context with `` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint. - **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes. -- **Overflow recovery** — below-threshold overflow bypasses normal retention and attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized only when `surface.replaceGeneration` advances; no range, no replacement, recovery failure, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure. +- **Overflow recovery** — provider-confirmed overflow does not require capacity metadata: it bypasses normal pressure and retention and attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized only when `surface.replaceGeneration` advances; no range, no replacement, recovery failure, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure. - **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. A region failure records an error end and leaves the surface unchanged. Operational post-step failures warn and continue; overflow-recovery failure preserves the original provider error. The protected `summarize()` method is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, provider, model, maxTokens? }`), which is logged on `compact/summary`. ## Config (`BasicCompactConfig`) -Every setting is optional. The pressure and retention policy applies to the token meter's single context window. Unrecognized top-level keys are rejected. +Every setting is optional. Top-level policy fields are defaults for every routed model; `modelPolicies` applies partial overrides to exact provider/model pairs. At pressure time, compact-basic asks the owning LLM adapter for that route's context capacity and resolves absolute budgets. Unrecognized keys, duplicate targets, mutually exclusive retention forms, and a merged `retainRatio` that is not below `thresholdRatio` fail plugin load. An absolute `retainTokens` budget that is not below its scaled threshold fails on the first resolvable target because that comparison requires model capacity. | Key | Required | Meaning | |---|---|---| -| `thresholdRatio` | no (default `0.8`) | Compact at `floor(contextWindow × ratio)`. | -| `retainTokens` | no (default `floor(contextWindow × 0.16)`) | Recent surface budget kept verbatim; must be below the threshold. | +| `thresholdRatio` | no (default `0.8`) | Compact at `floor(routedContextWindow × ratio)`. | +| `retainRatio` | no (default `0.16`) | Recent surface budget kept verbatim as a fraction of the routed context window; mutually exclusive with `retainTokens`. | +| `retainTokens` | no | Absolute recent surface budget kept verbatim; mutually exclusive with `retainRatio` and must be below the resolved threshold. | | `summarizationProvider` | no (default `''`) | Set together with `summarizationModel`; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. | | `summarizationModel` | no (default `''`) | Set together with `summarizationProvider`; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. | | `maxTokens` | no (default `8192`) | Provider generation cap for the summarization call; may include reasoning tokens. | | `compactionRetries` | no (default `1`) | Extra attempts after the first when pressure remains above threshold. | | `maxOverflowRetries` | no (default `1`) | Maximum retries after canonical context-window overflow; `0` disables recovery only. | +| `modelPolicies` | no (default `[]`) | Exact `{ provider, model, ...partialPolicy }` overrides; matching uses both fields and does not depend on `listModels()`. | | `auto` | no (default `true`) | Register post-step pressure and overflow-recovery listeners. Set `false` for manual-only. | +Every `modelPolicies` entry accepts the policy fields above except `auto` and `modelPolicies` itself. If an entry supplies either retention field, it replaces the default policy's retention choice; otherwise retention is inherited. Summarization provider/model remain a pair inside each entry. + +An adapter may return no capacity for a valid dynamic route, and resolved capacity may expose an invalid absolute retention budget. Manual pressure checks then throw a target-specific configuration error; the automatic listener warns once for that exact target and continues with full history. Unrelated operational failures remain independently visible. Canonical provider overflow still attempts recovery because the provider has already established that compaction is necessary. + ## Usage ```ts @@ -52,6 +59,20 @@ export function apply(ctx: Context): void { Loading the plugin registers `ctx.compact`. With `auto: true` (the default) it compacts automatically under token pressure; a consumer (a future `/compact` tool) can also call `ctx.compact.compactIfNeeded(...)` or `ctx.compact.compactRegion(...)` directly. +For example, the same compact plugin can safely serve models with different capacities and one target-specific policy: + +```yaml +- name: '@deepseek-ai/dsh-compact-basic' + config: + thresholdRatio: 0.8 + retainRatio: 0.16 + modelPolicies: + - provider: local + model: small-context + thresholdRatio: 0.7 + retainTokens: 2048 +``` + ## Model Experience ### Conversation history diff --git a/packages/compact/compact-basic/src/config.ts b/packages/compact/compact-basic/src/config.ts index 1587fac272..90adb5b5f7 100644 --- a/packages/compact/compact-basic/src/config.ts +++ b/packages/compact/compact-basic/src/config.ts @@ -1,111 +1,298 @@ /** - * Runtime defaulting and policy validation for compact-basic. + * Load-time validation and routed-model policy resolution for compact-basic. * * @module @deepseek-ai/dsh-compact-basic/config */ import { deepFreeze } from '@deepseek-ai/dsh-llm' -import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter' -import type { BasicCompactConfig, ResolvedConfig } from './types.ts' +import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' +import type { + BasicCompactConfig, + CompactPolicyConfig, + ModelCompactPolicyConfig, + ResolvedCompactSpec, + ResolvedConfig, + ResolvedRetention, + ResolvedTargetPolicy, +} from './types.ts' -/** Default request-pressure fraction of the token meter's context window. */ +/** Default request-pressure fraction for every routed model. */ const DEFAULT_THRESHOLD_RATIO = 0.8 -/** Default verbatim-tail fraction of the token meter's context window. */ +/** Default verbatim-tail fraction for every routed model. */ const DEFAULT_RETAIN_RATIO = 0.16 -/** Complete public configuration key set. */ -const BASIC_COMPACT_CONFIG_KEYS: ReadonlySet = new Set([ +/** Fields shared by top-level defaults and exact-target overrides. */ +const POLICY_CONFIG_KEYS = [ 'thresholdRatio', + 'retainRatio', 'retainTokens', 'summarizationProvider', 'summarizationModel', 'maxTokens', 'compactionRetries', 'maxOverflowRetries', +] as const + +/** Complete public top-level configuration key set. */ +const BASIC_COMPACT_CONFIG_KEYS: ReadonlySet = new Set([ + ...POLICY_CONFIG_KEYS, + 'modelPolicies', 'auto', ]) -/** Reject stale or misspelled keys before defaults can hide them. */ -function validateConfigKeys(config: BasicCompactConfig): void { - for (const key of Object.keys(config)) { - if (!BASIC_COMPACT_CONFIG_KEYS.has(key)) { - throw new Error( - `BasicCompactConfig: unknown key "${key}" ` - + '(allowed: thresholdRatio, retainTokens, summarizationProvider, summarizationModel, ' - + 'maxTokens, compactionRetries, maxOverflowRetries, auto)', - ) - } +/** Complete exact-target override key set. */ +const MODEL_POLICY_KEYS: ReadonlySet = new Set([ + 'provider', + 'model', + ...POLICY_CONFIG_KEYS, +]) + +/** Target-specific pressure configuration failure eligible for warning suppression. */ +export class TargetPressureConfigError extends Error { + /** + * @param targetKey - exact provider/model route used as the warning key. + * @param message - actionable configuration failure detail. + */ + constructor(readonly targetKey: string, message: string) { + super(message) } } /** - * Resolve defaults and validate the service-wide compaction policy. - * @param config - raw compact-basic configuration. - * @param tokenMeter - token meter supplying the context capacity. - * @returns a detached deeply immutable configuration. + * Resolve and validate service defaults plus exact-target partial overrides. + * @param config - untrusted plugin configuration after Loader normalization. + * @returns detached immutable defaults and validated exact-target overrides. */ -export function resolveConfig( - config: BasicCompactConfig = {}, - tokenMeter: TokenMeterService, -): ResolvedConfig { - validateConfigKeys(config) +export function resolveConfig(config: BasicCompactConfig = {}): ResolvedConfig { + validateKeys(config, BASIC_COMPACT_CONFIG_KEYS, 'BasicCompactConfig') + validatePolicy(config, 'BasicCompactConfig') + if (config.auto !== undefined && typeof config.auto !== 'boolean') { + throw new Error('BasicCompactConfig: auto must be a boolean') + } + const thresholdRatio = config.thresholdRatio ?? DEFAULT_THRESHOLD_RATIO - const retainTokens = config.retainTokens - ?? Math.floor(tokenMeter.contextWindow * DEFAULT_RETAIN_RATIO) - const resolved: ResolvedConfig = { + const retention = resolveRetention(config, { retainRatio: DEFAULT_RETAIN_RATIO }) + validateRatioRetention(thresholdRatio, retention, 'BasicCompactConfig') + const modelPolicies = resolveModelPolicies(config.modelPolicies) + for (const [index, policy] of modelPolicies.entries()) { + validateRatioRetention( + policy.thresholdRatio ?? thresholdRatio, + resolveRetention(policy, retention), + `BasicCompactConfig: modelPolicies[${index}]`, + ) + } + + return deepFreeze({ thresholdRatio, - retainTokens, + ...retention, summarizationProvider: config.summarizationProvider ?? '', summarizationModel: config.summarizationModel ?? '', maxTokens: config.maxTokens ?? 8192, compactionRetries: config.compactionRetries ?? 1, maxOverflowRetries: config.maxOverflowRetries ?? 1, + modelPolicies, auto: config.auto ?? true, - } + }) +} - assertRatio('thresholdRatio', resolved.thresholdRatio) - assertNonNegativeInteger('retainTokens', resolved.retainTokens) - const thresholdTokens = Math.floor(tokenMeter.contextWindow * resolved.thresholdRatio) - if (resolved.retainTokens >= thresholdTokens) { - throw new Error( - `BasicCompactConfig: retainTokens (${resolved.retainTokens}) must be less than threshold tokens ${thresholdTokens}`, +/** + * Merge the exact provider/model override over the validated default policy. + * @param config - validated service defaults and override table. + * @param target - exact durable provider/model route to match. + * @returns detached immutable policy before model-capacity scaling. + */ +export function resolveTargetPolicy( + config: ResolvedConfig, + target: Pick, +): ResolvedTargetPolicy { + const override = config.modelPolicies.find(policy => ( + policy.provider === target.provider && policy.model === target.model + )) + const inheritedRetention: ResolvedRetention = config.retainTokens === undefined + ? { retainRatio: config.retainRatio } + : { retainTokens: config.retainTokens } + return deepFreeze({ + target: { provider: target.provider, model: target.model }, + thresholdRatio: override?.thresholdRatio ?? config.thresholdRatio, + ...resolveRetention(override ?? {}, inheritedRetention), + summarizationProvider: override?.summarizationProvider ?? config.summarizationProvider, + summarizationModel: override?.summarizationModel ?? config.summarizationModel, + maxTokens: override?.maxTokens ?? config.maxTokens, + compactionRetries: override?.compactionRetries ?? config.compactionRetries, + maxOverflowRetries: override?.maxOverflowRetries ?? config.maxOverflowRetries, + }) +} + +/** + * Scale one routed policy into concrete token budgets for its model capacity. + * @param policy - merged policy for the exact routed target. + * @param contextWindow - positive adapter-owned capacity for that target. + * @returns detached immutable pressure and retention budgets. + */ +export function resolveCompactSpec( + policy: ResolvedTargetPolicy, + contextWindow: number, +): ResolvedCompactSpec { + const targetKey = `${policy.target.provider}/${policy.target.model}` + if (!Number.isInteger(contextWindow) || contextWindow <= 0) { + throw new TargetPressureConfigError( + targetKey, + `BasicCompactConfig: contextWindow (${contextWindow}) must be a positive integer`, ) } - assertPositiveInteger('maxTokens', resolved.maxTokens) - assertNonNegativeInteger('compactionRetries', resolved.compactionRetries) - assertNonNegativeInteger('maxOverflowRetries', resolved.maxOverflowRetries) - if (typeof resolved.summarizationProvider !== 'string') { - throw new Error('BasicCompactConfig: summarizationProvider must be a string') - } - if (typeof resolved.summarizationModel !== 'string') { - throw new Error('BasicCompactConfig: summarizationModel must be a string') - } - if ((resolved.summarizationProvider.length === 0) !== (resolved.summarizationModel.length === 0)) { - throw new Error( - 'BasicCompactConfig: summarizationProvider and summarizationModel must both be set or both be empty', + const thresholdTokens = Math.floor(contextWindow * policy.thresholdRatio) + const retainTokens = policy.retainTokens === undefined + ? Math.floor(contextWindow * policy.retainRatio) + : policy.retainTokens + if (retainTokens >= thresholdTokens) { + throw new TargetPressureConfigError( + targetKey, + `BasicCompactConfig: ${policy.target.provider}/${policy.target.model} retainTokens ` + + `(${retainTokens}) must be less than threshold tokens ${thresholdTokens}`, ) } - if (typeof resolved.auto !== 'boolean') { - throw new Error('BasicCompactConfig: auto must be a boolean') - } - return deepFreeze(resolved) + return deepFreeze({ + target: { ...policy.target }, + contextWindow, + thresholdRatio: policy.thresholdRatio, + thresholdTokens, + retainTokens, + summarizationProvider: policy.summarizationProvider, + summarizationModel: policy.summarizationModel, + maxTokens: policy.maxTokens, + compactionRetries: policy.compactionRetries, + maxOverflowRetries: policy.maxOverflowRetries, + }) } -function assertPositiveInteger(name: string, value: number): void { - if (!Number.isInteger(value) || value <= 0) { - throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive integer`) +/** Choose an explicit retention form or inherit the already-resolved fallback. */ +function resolveRetention( + config: CompactPolicyConfig, + fallback: ResolvedRetention, +): ResolvedRetention { + if (config.retainTokens !== undefined) return { retainTokens: config.retainTokens } + if (config.retainRatio !== undefined) return { retainRatio: config.retainRatio } + return fallback +} + +/** Reject a capacity-independent retention conflict at plugin load. */ +function validateRatioRetention( + thresholdRatio: number, + retention: ResolvedRetention, + name: string, +): void { + if (retention.retainRatio !== undefined && retention.retainRatio >= thresholdRatio) { + throw new Error( + `${name}: retainRatio (${retention.retainRatio}) must be less than ` + + `the resolved thresholdRatio (${thresholdRatio})`, + ) } } -function assertNonNegativeInteger(name: string, value: number): void { - if (!Number.isInteger(value) || value < 0) { - throw new Error(`BasicCompactConfig: ${name} (${value}) must be a non-negative integer`) +/** Validate, detach, and reject duplicate exact-target policies. */ +function resolveModelPolicies(configured: unknown): ModelCompactPolicyConfig[] { + if (configured === undefined) return [] + if (!Array.isArray(configured)) { + throw new Error('BasicCompactConfig: modelPolicies must be an array') + } + const seen = new Set() + return configured.map((source: unknown, index) => { + const name = `BasicCompactConfig: modelPolicies[${index}]` + assertModelPolicy(source, name) + const key = `${source.provider}\u0000${source.model}` + if (seen.has(key)) { + throw new Error( + `BasicCompactConfig: duplicate model policy for ${source.provider}/${source.model}`, + ) + } + seen.add(key) + return { ...source } + }) +} + +/** Validate one untrusted exact-target override and narrow its public type. */ +function assertModelPolicy( + source: unknown, + name: string, +): asserts source is ModelCompactPolicyConfig { + if (!isUnknownRecord(source)) throw new Error(`${name} must be an object`) + validateKeys(source, MODEL_POLICY_KEYS, name) + assertNonEmptyString(`${name}.provider`, source.provider) + assertNonEmptyString(`${name}.model`, source.model) + validatePolicy(source, name) +} + +/** Validate the fields common to defaults and exact-target partial overrides. */ +function validatePolicy( + config: CompactPolicyConfig | Record, + name: string, +): void { + const thresholdRatio = config.thresholdRatio + const retainRatio = config.retainRatio + const retainTokens = config.retainTokens + const maxTokens = config.maxTokens + const compactionRetries = config.compactionRetries + const maxOverflowRetries = config.maxOverflowRetries + if (thresholdRatio !== undefined) assertRatio(`${name}.thresholdRatio`, thresholdRatio) + if (retainRatio !== undefined) assertRatio(`${name}.retainRatio`, retainRatio) + if (retainTokens !== undefined) assertNonNegativeInteger(`${name}.retainTokens`, retainTokens) + if (retainRatio !== undefined && retainTokens !== undefined) { + throw new Error(`${name}: retainRatio and retainTokens are mutually exclusive`) + } + if (maxTokens !== undefined) assertPositiveInteger(`${name}.maxTokens`, maxTokens) + if (compactionRetries !== undefined) { + assertNonNegativeInteger(`${name}.compactionRetries`, compactionRetries) + } + if (maxOverflowRetries !== undefined) { + assertNonNegativeInteger(`${name}.maxOverflowRetries`, maxOverflowRetries) + } + + const summarizationProvider = config.summarizationProvider + const summarizationModel = config.summarizationModel + if (summarizationProvider !== undefined && typeof summarizationProvider !== 'string') { + throw new Error(`${name}.summarizationProvider must be a string`) + } + if (summarizationModel !== undefined && typeof summarizationModel !== 'string') { + throw new Error(`${name}.summarizationModel must be a string`) + } + if (((summarizationProvider ?? '').length === 0) + !== ((summarizationModel ?? '').length === 0)) { + throw new Error(`${name}: summarizationProvider and summarizationModel must both be empty or both be non-empty`) } } -function assertRatio(name: string, value: number): void { +/** Reject stale or misspelled keys before defaults can hide them. */ +function validateKeys(config: object, keys: ReadonlySet, name: string): void { + for (const key of Object.keys(config)) { + if (!keys.has(key)) throw new Error(`${name}: unknown key "${key}"`) + } +} + +function isUnknownRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function assertNonEmptyString(name: string, value: unknown): asserts value is string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${name} must be a non-empty string`) + } +} + +function assertPositiveInteger(name: string, value: unknown): asserts value is number { + if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) { + throw new Error(`${name} (${String(value)}) must be a positive integer`) + } +} + +function assertNonNegativeInteger(name: string, value: unknown): asserts value is number { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + throw new Error(`${name} (${String(value)}) must be a non-negative integer`) + } +} + +function assertRatio(name: string, value: unknown): asserts value is number { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) { - throw new Error(`BasicCompactConfig: ${name} (${value}) must be a number in (0, 1]`) + throw new Error(`${name} (${String(value)}) must be a number in (0, 1]`) } } diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 5d325d57ba..1e9af13888 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -10,27 +10,76 @@ import { CompactService } from '@deepseek-ai/dsh-compact' import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact' import type { Session } from '@deepseek-ai/dsh-session' import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, LlmCallConfig } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import { resolveConfig } from './config.ts' +import { + resolveCompactSpec, + resolveConfig, + resolveTargetPolicy, + TargetPressureConfigError, +} from './config.ts' import { compactSurfaceRegion, selectCompactableRange } from './region.ts' import { summarizeWithLlm } from './summarizer.ts' import type { BasicCompactConfig, + ModelCompactPolicyConfig, ResolvedConfig, } from './types.ts' export type { BasicCompactConfig, + CompactPolicyConfig, + ModelCompactPolicyConfig, + ResolvedCompactSpec, ResolvedConfig, + ResolvedRetention, + ResolvedTargetPolicy, } from './types.ts' -/** Resolve the exact model durably routed for the latest provider request. */ -function routedModel(session: Session): string | undefined { - const model = session.requestHeader()?.config.model - return model === undefined || model.length === 0 ? undefined : model +/** Resolve the exact provider/model durably routed for the latest request. */ +function routedTarget( + session: Session, +): Pick | undefined { + const config = session.requestHeader()?.config + if (config === undefined || config.provider.length === 0 || config.model.length === 0) { + return undefined + } + return { provider: config.provider, model: config.model } } +/** Resolve the conversation target used to select an optional policy override. */ +function conversationTarget( + agent: Agent, +): Pick | undefined { + const routed = routedTarget(agent.session) + if (routed !== undefined) return routed + if (agent.options.provider === undefined || agent.options.provider.length === 0 + || agent.options.model === undefined || agent.options.model.length === 0) return undefined + return { provider: agent.options.provider, model: agent.options.model } +} + +const thresholdRatioSchema = z.number() +const retainRatioSchema = z.number() +const retainTokensSchema = z.number().step(1).min(0) +const summarizationProviderSchema = z.string() +const summarizationModelSchema = z.string() +const maxTokensSchema = z.number().step(1).min(1) +const compactionRetriesSchema = z.number().step(1).min(0) +const maxOverflowRetriesSchema = z.number().step(1).min(0) + +const modelPolicy: z = z.object({ + provider: z.string().required(), + model: z.string().required(), + thresholdRatio: thresholdRatioSchema, + retainRatio: retainRatioSchema, + retainTokens: retainTokensSchema, + summarizationProvider: summarizationProviderSchema, + summarizationModel: summarizationModelSchema, + maxTokens: maxTokensSchema, + compactionRetries: compactionRetriesSchema, + maxOverflowRetries: maxOverflowRetriesSchema, +}) + /** * Dependency-light compaction backend using `ctx.tokenMeter` for pressure, * retention, provenance, and summary-convergence pricing. @@ -43,22 +92,26 @@ export class BasicCompactService extends CompactService { static inject = ['llm', 'tokenMeter'] static Config: z = z.object({ - thresholdRatio: z.number().default(0.8), - retainTokens: z.number().step(1), - summarizationProvider: z.string().default(''), - summarizationModel: z.string().default(''), - maxTokens: z.number().step(1).min(1).default(8192), - compactionRetries: z.number().step(1).min(0).default(1), - maxOverflowRetries: z.number().step(1).min(0).default(1), - auto: z.boolean().default(true), + thresholdRatio: thresholdRatioSchema, + retainRatio: retainRatioSchema, + retainTokens: retainTokensSchema, + summarizationProvider: summarizationProviderSchema, + summarizationModel: summarizationModelSchema, + maxTokens: maxTokensSchema, + compactionRetries: compactionRetriesSchema, + maxOverflowRetries: maxOverflowRetriesSchema, + modelPolicies: z.array(modelPolicy), + auto: z.boolean(), }) /** Resolved and validated compaction configuration. */ readonly config: ResolvedConfig + private readonly warnedPressureConfigTargets = new Set() + constructor(ctx: Context, config: BasicCompactConfig = {}) { super(ctx) - this.config = resolveConfig(config, ctx.tokenMeter) + this.config = resolveConfig(config) if (this.config.auto) this._registerAutomaticCompaction() } @@ -88,15 +141,21 @@ export class BasicCompactService extends CompactService { const result = await this.compactIfNeeded(agent, 'pressure', signal) if (result !== null) logResult(result, 'post-step pressure') } catch (error: unknown) { + if (error instanceof TargetPressureConfigError) { + if (this.warnedPressureConfigTargets.has(error.targetKey)) return + this.warnedPressureConfigTargets.add(error.targetKey) + } const message = error instanceof Error ? error.message : String(error) ctx.logger.warn(`post-step compaction failed: ${message}; continuing the turn`) } }) ctx.on('agent/request-error', async (agent, _turn, _step, error, retryAttempt, signal, next) => { - if (error.code !== CONTEXT_WINDOW_EXCEEDED_CODE - || retryAttempt >= this.config.maxOverflowRetries - || signal.aborted) return next() + if (error.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next() + const target = routedTarget(agent.session) + if (target === undefined) return next() + const policy = resolveTargetPolicy(this.config, target) + if (retryAttempt >= policy.maxOverflowRetries) return next() let generation: number let result: CompactionResult | null @@ -131,7 +190,11 @@ export class BasicCompactService extends CompactService { agent: Agent, signal?: AbortSignal, ): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> { - return summarizeWithLlm(this.ctx, this.config, text, agent, signal) + const target = conversationTarget(agent) + const config = target === undefined + ? this.config + : resolveTargetPolicy(this.config, target) + return summarizeWithLlm(this.ctx, config, text, agent, signal) } /** @@ -149,8 +212,9 @@ export class BasicCompactService extends CompactService { trigger: CompactionTrigger, signal: AbortSignal, ): Promise { - const model = routedModel(agent.session) - if (model === undefined) return null + const target = routedTarget(agent.session) + if (target === undefined) return null + const policy = resolveTargetPolicy(this.config, target) const meter = this.ctx.tokenMeter switch (trigger) { case 'context-overflow': { @@ -166,13 +230,22 @@ export class BasicCompactService extends CompactService { assertNever(trigger, 'compaction trigger') } - const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio) + const context = await this.ctx.llm.resolveModelContext(target.provider, target.model) + const targetKey = `${target.provider}/${target.model}` + if (context === undefined) { + throw new TargetPressureConfigError( + targetKey, + `compact-basic: no context capacity for ${targetKey}; ` + + 'configure contextWindow on that adapter model', + ) + } + const spec = resolveCompactSpec(policy, context.contextWindow) let measurement = meter.measure(agent.session) - if (measurement.totalTokens < threshold) return null + if (measurement.totalTokens < spec.thresholdTokens) return null let result: CompactionResult | null = null - for (let attempt = 0; attempt <= this.config.compactionRetries; attempt += 1) { - const range = selectCompactableRange(agent.session, measurement, this.config.retainTokens) + for (let attempt = 0; attempt <= spec.compactionRetries; attempt += 1) { + const range = selectCompactableRange(agent.session, measurement, spec.retainTokens) if (range === null) { /* v8 ignore else -- concrete replacement preserves a compactable checkpoint; subclass hooks cannot mutate it. */ if (result === null) return null @@ -181,12 +254,12 @@ export class BasicCompactService extends CompactService { } result = await this.compactRegion(range.start, range.end, agent, signal) measurement = meter.measure(agent.session) - if (measurement.totalTokens < threshold) return result + if (measurement.totalTokens < spec.thresholdTokens) return result } throw new Error( - `compaction still above threshold after ${this.config.compactionRetries + 1} compaction attempts ` - + `(${measurement.totalTokens} estimated tokens >= threshold ${threshold})`, + `compaction still above threshold after ${spec.compactionRetries + 1} compaction attempts ` + + `(${measurement.totalTokens} estimated tokens >= threshold ${spec.thresholdTokens})`, ) } diff --git a/packages/compact/compact-basic/src/summarizer.ts b/packages/compact/compact-basic/src/summarizer.ts index 62b5f5f5e2..7a4de4223d 100644 --- a/packages/compact/compact-basic/src/summarizer.ts +++ b/packages/compact/compact-basic/src/summarizer.ts @@ -8,7 +8,12 @@ import type { Context } from 'cordis' import { BlockAssembler } from '@deepseek-ai/dsh-llm' import type { ContentBlock, FinishReason, GenerateOptions } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { ResolvedConfig } from './types.ts' + +interface SummaryConfig { + readonly summarizationProvider: string + readonly summarizationModel: string + readonly maxTokens: number +} /** Tags wrapping the structured summary inside the landed checkpoint node. */ const SUMMARY_OPEN_TAG = '' @@ -74,7 +79,7 @@ export interface SummaryResult { */ export async function summarizeWithLlm( ctx: Context, - config: ResolvedConfig, + config: SummaryConfig, text: string, agent: Agent, signal?: AbortSignal, diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts index 6ed0165226..c322f508ac 100644 --- a/packages/compact/compact-basic/src/types.ts +++ b/packages/compact/compact-basic/src/types.ts @@ -4,15 +4,19 @@ * @module @deepseek-ai/dsh-compact-basic/types */ -/** Basic compaction configuration; every common field has a deployment default. */ -export interface BasicCompactConfig { - /** Compact at this fraction of the token meter's context window. Defaults to `0.8`. */ +import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' + +/** Policy fields shared by the default policy and exact model overrides. */ +export interface CompactPolicyConfig { + /** Compact at this fraction of the model's context window. Defaults to `0.8`. */ thresholdRatio?: number - /** Recent surface tokens retained verbatim. Defaults to `floor(contextWindow * 0.16)`. */ + /** Recent context retained as a fraction of the model's window. Defaults to `0.16`. */ + retainRatio?: number + /** Absolute recent-context budget; mutually exclusive with `retainRatio`. */ retainTokens?: number - /** Summary provider; `''` resolves the latest routed pair, then the agent pair. Defaults to `''`. */ + /** Summary provider; set together with `summarizationModel`, or inherit the conversation target. */ summarizationProvider?: string - /** Summary model; `''` resolves the latest routed pair, then the agent pair. Defaults to `''`. */ + /** Summary model; set together with `summarizationProvider`, or inherit the conversation target. */ summarizationModel?: string /** Provider generation cap for summarization. Defaults to `8192`. */ maxTokens?: number @@ -20,18 +24,53 @@ export interface BasicCompactConfig { compactionRetries?: number /** Maximum retries after canonical context overflow; `0` disables recovery. Defaults to `1`. */ maxOverflowRetries?: number +} + +/** Exact provider/model override merged over the default compaction policy. */ +export interface ModelCompactPolicyConfig extends CompactPolicyConfig { + /** Registered provider route to match. */ + provider: string + /** Exact routed model id to match within `provider`. */ + model: string +} + +/** Basic compaction configuration with an optional exact-target policy table. */ +export interface BasicCompactConfig extends CompactPolicyConfig { + /** Exact provider/model overrides; duplicate targets fail plugin load. */ + modelPolicies?: ModelCompactPolicyConfig[] /** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */ auto?: boolean } -/** Validated and detached compaction configuration. */ -export interface ResolvedConfig { +/** Exactly one validated retention form. */ +export type ResolvedRetention = + | { readonly retainRatio: number; readonly retainTokens?: never } + | { readonly retainRatio?: never; readonly retainTokens: number } + +/** Validated policy fields shared before and after exact-target matching. */ +interface ResolvedPolicyFields { readonly thresholdRatio: number - readonly retainTokens: number readonly summarizationProvider: string readonly summarizationModel: string readonly maxTokens: number readonly compactionRetries: number readonly maxOverflowRetries: number +} + +/** Validated immutable config whose target-specific defaults remain unresolved. */ +export type ResolvedConfig = ResolvedPolicyFields & ResolvedRetention & { + readonly modelPolicies: readonly Readonly[] readonly auto: boolean } + +/** Fully merged policy for one routed conversation target, before capacity scaling. */ +export type ResolvedTargetPolicy = ResolvedPolicyFields & ResolvedRetention & { + readonly target: Pick +} + +/** One routed model's concrete pressure and retention budget. */ +export type ResolvedCompactSpec = Omit & { + readonly contextWindow: number + readonly thresholdTokens: number + readonly retainTokens: number +} diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 0a411440b3..39d807e9c1 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -4,10 +4,14 @@ import BasicCompactService from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts' import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' -import { resolveConfig } from '@deepseek-ai/dsh-compact-basic/src/config.ts' +import { + resolveCompactSpec, + resolveConfig, + resolveTargetPolicy, +} from '@deepseek-ai/dsh-compact-basic/src/config.ts' import type { CompactionResult } from '@deepseek-ai/dsh-compact' import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@deepseek-ai/dsh-llm' -import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, LlmModelContext, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -15,9 +19,40 @@ import type { Agent } from '@deepseek-ai/dsh-agent' const SIGNAL = new AbortController().signal const MODEL = 'test-model' +class ContextAdapter extends LlmAdapter { + constructor(private readonly contextWindow: number) { + super() + } + + override resolveModelContext(): Promise { + return Promise.resolve({ contextWindow: this.contextWindow }) + } + + override async * stream(): AsyncIterable { + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +class RoutedContextAdapter extends LlmAdapter { + constructor(private readonly windows: Readonly>) { + super() + } + + override resolveModelContext(provider: string): Promise { + const contextWindow = this.windows[provider] + return Promise.resolve(contextWindow === undefined ? undefined : { contextWindow }) + } + + override async * stream(): AsyncIterable { + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + function createContext(contextWindow = 1_000): Context { const ctx = new Context() - void new TokenMeterService(ctx, { contextWindow }) + void new LlmService(ctx) + void new TokenMeterService(ctx) + ctx.llm.registerAdapter([MODEL, 'actual', 'unlisted-provider'], new ContextAdapter(contextWindow)) return ctx } @@ -140,43 +175,96 @@ async function compactIfNeeded( describe('compact configuration and defaults', () => { it('uses low-friction service-wide defaults', () => { - const ctx = createContext() - const resolved = resolveConfig({}, ctx.tokenMeter) + const resolved = resolveConfig({}) expect(resolved).toEqual({ thresholdRatio: 0.8, - retainTokens: 160, + retainRatio: 0.16, summarizationProvider: '', summarizationModel: '', maxTokens: 8192, compactionRetries: 1, maxOverflowRetries: 1, + modelPolicies: [], auto: true, }) expect(Object.isFrozen(resolved)).toBe(true) }) it('resolves threshold and retention overrides independently', () => { - const ctx = createContext() const thresholdOnly = resolveConfig({ thresholdRatio: 0.5, - }, ctx.tokenMeter) + }) expect(thresholdOnly).toMatchObject({ thresholdRatio: 0.5, - retainTokens: 160, + retainRatio: 0.16, }) const retentionOnly = resolveConfig({ retainTokens: 70, - }, ctx.tokenMeter) + }) expect(retentionOnly).toMatchObject({ thresholdRatio: 0.8, retainTokens: 70, }) + expect(retentionOnly).not.toHaveProperty('retainRatio') + }) + + it('merges exact provider/model policy overrides and scales ratios per model', () => { + const config = resolveConfig({ + thresholdRatio: 0.8, + retainRatio: 0.1, + modelPolicies: [{ + provider: 'small-provider', + model: 'shared-id', + thresholdRatio: 0.5, + retainTokens: 120, + }], + }) + const small = resolveTargetPolicy(config, { + provider: 'small-provider', + model: 'shared-id', + }) + const otherProvider = resolveTargetPolicy(config, { + provider: 'large-provider', + model: 'shared-id', + }) + + expect(resolveCompactSpec(small, 1_000)).toMatchObject({ + thresholdTokens: 500, + retainTokens: 120, + }) + expect(resolveCompactSpec(otherProvider, 2_000)).toMatchObject({ + thresholdTokens: 1_600, + retainTokens: 200, + }) + + const ratioOverride = resolveTargetPolicy(resolveConfig({ + retainTokens: 200, + modelPolicies: [{ + provider: 'ratio-provider', + model: 'ratio-model', + thresholdRatio: 0.6, + retainRatio: 0.2, + summarizationProvider: 'summary-provider', + summarizationModel: 'summary-model', + maxTokens: 512, + compactionRetries: 2, + maxOverflowRetries: 3, + }], + }), { provider: 'ratio-provider', model: 'ratio-model' }) + expect(resolveCompactSpec(ratioOverride, 2_000)).toMatchObject({ + thresholdTokens: 1_200, + retainTokens: 400, + summarizationProvider: 'summary-provider', + summarizationModel: 'summary-model', + maxTokens: 512, + compactionRetries: 2, + maxOverflowRetries: 3, + }) }) it('validates common values and pressure-policy invariants', () => { - const ctx = createContext() const bad = [ [{ maxTokens: 0 }, /maxTokens/], [{ compactionRetries: -1 }, /compactionRetries/], @@ -184,19 +272,48 @@ describe('compact configuration and defaults', () => { [{ auto: 'yes' }, /auto must be a boolean/], [{ summarizationProvider: 1 }, /summarizationProvider must be a string/], [{ summarizationModel: 1 }, /summarizationModel must be a string/], - [{ summarizationProvider: MODEL }, /must both be set or both be empty/], - [{ summarizationModel: MODEL }, /must both be set or both be empty/], + [{ summarizationProvider: MODEL }, /must both be empty or both be non-empty/], + [{ summarizationModel: MODEL }, /must both be empty or both be non-empty/], [{ thresholdRatio: 0 }, /number in \(0, 1\]/], [{ thresholdRatio: 1.1 }, /number in \(0, 1\]/], + [{ retainRatio: 0.9 }, /retainRatio \(0.9\) must be less than the resolved thresholdRatio \(0.8\)/], + [{ thresholdRatio: 0.1 }, /retainRatio \(0.16\) must be less than the resolved thresholdRatio \(0.1\)/], [{ retainTokens: -1 }, /non-negative integer/], - [{ thresholdRatio: 0.5, retainTokens: 500 }, /less than threshold/], + [{ retainRatio: 0.2, retainTokens: 100 }, /mutually exclusive/], + [{ modelPolicies: {} }, /modelPolicies must be an array/], + [{ modelPolicies: [1] }, /modelPolicies\[0\] must be an object/], + [{ modelPolicies: [null] }, /modelPolicies\[0\] must be an object/], + [{ modelPolicies: [[]] }, /modelPolicies\[0\] must be an object/], + [{ modelPolicies: [{ provider: 1, model: MODEL }] }, /provider must be a non-empty string/], + [{ modelPolicies: [{ provider: '', model: MODEL }] }, /provider must be a non-empty string/], + [{ modelPolicies: [{ provider: MODEL, model: 1 }] }, /model must be a non-empty string/], + [{ modelPolicies: [{ provider: MODEL, model: '' }] }, /model must be a non-empty string/], + [{ modelPolicies: [{ provider: MODEL, model: MODEL, summarizationProvider: 1 }] }, /summarizationProvider must be a string/], + [{ modelPolicies: [{ provider: MODEL, model: MODEL, retainRatio: 0.2, retainTokens: 100 }] }, /mutually exclusive/], + [ + { modelPolicies: [{ provider: MODEL, model: MODEL, thresholdRatio: 0.1 }] }, + /modelPolicies\[0\]: retainRatio \(0.16\).*thresholdRatio \(0.1\)/, + ], + [ + { modelPolicies: [{ provider: MODEL, model: MODEL, retainRatio: 0.9 }] }, + /modelPolicies\[0\]: retainRatio \(0.9\).*thresholdRatio \(0.8\)/, + ], + [{ modelPolicies: [{ provider: MODEL, model: MODEL }, { provider: MODEL, model: MODEL }] }, /duplicate model policy/], [{ models: { [MODEL]: { retainTokens: 10 } } }, /BasicCompactConfig: unknown key "models"/], [{ thresholdRato: 0.5 }, /BasicCompactConfig: unknown key "thresholdRato"/], ] as Array<[unknown, RegExp]> for (const [config, pattern] of bad) { - expect(() => resolveConfig(config as BasicCompactConfig, ctx.tokenMeter)).toThrow(pattern) + expect(() => resolveConfig(config as BasicCompactConfig)).toThrow(pattern) } + + const invalidPressure = resolveTargetPolicy(resolveConfig({ + thresholdRatio: 0.5, + retainTokens: 500, + }), { provider: MODEL, model: MODEL }) + expect(() => resolveCompactSpec(invalidPressure, 1_000)).toThrow(/less than threshold/) + expect(() => resolveCompactSpec(invalidPressure, 1.5)).toThrow(/positive integer/) + expect(() => resolveCompactSpec(invalidPressure, 0)).toThrow(/positive integer/) }) }) @@ -216,7 +333,7 @@ describe('pressure measurement and retention', () => { expect(compact.calls).toHaveLength(0) }) - it('meters any routed model without profile resolution', async () => { + it('meters an unlisted model when its provider adapter supplies context metadata', async () => { const compact = service(compactConfig) const session = conversation() session.append('request/header', { @@ -227,6 +344,52 @@ describe('pressure measurement and retention', () => { .resolves.not.toBeNull() }) + it('re-resolves capacity after a same-model-id provider switch in one session', async () => { + const ctx = new Context() + void new LlmService(ctx) + void new TokenMeterService(ctx) + ctx.llm.registerAdapter(['large', 'small'], new RoutedContextAdapter({ + large: 10_000, + small: 1_000, + })) + const compact = service({ + auto: false, + thresholdRatio: 0.5, + retainRatio: 0.1, + }, ctx) + const session = conversation(4) + session.append('request/header', { + header: { config: { provider: 'large', model: 'shared-id' } }, + reason: 'resume', + }) + await expect(compactIfNeeded(compact, session)).resolves.toBeNull() + + session.append('request/header', { + header: { config: { provider: 'small', model: 'shared-id' } }, + reason: 'change', + }) + await expect(compactIfNeeded(compact, session)).resolves.not.toBeNull() + }) + + it('requires capacity only for proactive pressure, not provider-confirmed overflow', async () => { + const ctx = new Context() + void new LlmService(ctx) + void new TokenMeterService(ctx) + ctx.llm.registerAdapter(['unknown-context'], new ContextAdapter(1_000)) + vi.spyOn(ctx.llm, 'resolveModelContext').mockResolvedValue(undefined) + const compact = service(compactConfig, ctx) + const session = conversation(4) + session.append('request/header', { + header: { config: { provider: 'unknown-context', model: 'model' } }, + reason: 'resume', + }) + + await expect(compactIfNeeded(compact, session, 'pressure')) + .rejects.toThrow(/no context capacity for unknown-context\/model/) + await expect(compactIfNeeded(compact, session, 'context-overflow')) + .resolves.not.toBeNull() + }) + it('declines forced overflow when the whole surface is one indivisible tool pair', async () => { const compact = service(compactConfig) const session = new Session(SessionId('single-tool-pair')) @@ -678,7 +841,7 @@ async function summarizerHarness( ): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: ExposedCompactService }> { const ctx = new Context() await ctx.plugin(LlmService) - void new TokenMeterService(ctx, { contextWindow: 1_000 }) + void new TokenMeterService(ctx) const adapter = new ScriptedAdapter(blocks, finish) ctx.llm.registerAdapter([model], adapter) const compact = new ExposedCompactService(ctx, config) @@ -761,6 +924,31 @@ describe('default one-shot summarizer', () => { .rejects.toThrow(/no provider\/model available for summarization/) }) + it('uses a complete AgentOptions target when no durable route exists', async () => { + const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }]) + const session = new Session(SessionId('headerless-summary')) + + await expect(compact.runSummarize('history', agent(session, MODEL))).resolves.toMatchObject({ + provider: MODEL, + model: MODEL, + }) + expect(adapter.lastOptions).toMatchObject({ provider: MODEL, model: MODEL }) + }) + + it.each([ + { provider: '', model: MODEL }, + { provider: MODEL }, + { provider: MODEL, model: '' }, + ])('rejects incomplete AgentOptions target %#', async (options) => { + const { compact } = await summarizerHarness([{ type: 'text', text: 'unused' }]) + const owner = { + session: new Session(SessionId(`incomplete-${String(options.model)}`)), + options, + } as Agent + await expect(compact.runSummarize('history', owner)) + .rejects.toThrow(/no provider\/model available for summarization/) + }) + it.each([ [{ kind: 'error', message: 'provider failed', code: 'PROVIDER' }, 'PROVIDER', /provider failed/], [{ kind: 'error', message: 'opaque' }, undefined, /opaque/], @@ -857,6 +1045,43 @@ describe('automatic listener and loader composition', () => { expect(session.events.some(event => event.type === 'compact/summary')).toBe(false) }) + it('warns once per routed target when proactive pressure has no context metadata', async () => { + const ctx = createContext() + const warnings: string[] = [] + ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn + vi.spyOn(ctx.llm, 'resolveModelContext').mockResolvedValue(undefined) + void new TestCompactService(ctx, { + thresholdRatio: 0.5, + retainTokens: 180, + }) + const session = conversation(4) + + await postStep(ctx, agent(session, MODEL)) + await postStep(ctx, agent(session, MODEL)) + + expect(warnings).toEqual([ + expect.stringContaining(`no context capacity for ${MODEL}/${MODEL}`), + ]) + }) + + it('warns once per routed target when absolute retention exceeds its resolved threshold', async () => { + const ctx = createContext() + const warnings: string[] = [] + ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn + void new TestCompactService(ctx, { + thresholdRatio: 0.5, + retainTokens: 500, + }) + const session = conversation(4) + + await postStep(ctx, agent(session, MODEL)) + await postStep(ctx, agent(session, MODEL)) + + expect(warnings).toEqual([ + expect.stringContaining('retainTokens (500) must be less than threshold tokens 500'), + ]) + }) + it('force-compacts below normal pressure for canonical overflow and retries only after replacement', async () => { const ctx = createContext(10_000) void new TestCompactService(ctx, { @@ -989,6 +1214,18 @@ describe('automatic listener and loader composition', () => { .toEqual({ action: 'retry' }) }) + it('delegates canonical overflow when no durable routed target exists', async () => { + const ctx = createContext() + void new TestCompactService(ctx) + const session = new Session(SessionId('headerless-overflow')) + session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + + await expect(recover(ctx, agent(session, MODEL), overflow())).resolves.toEqual({ action: 'fail' }) + }) + it('honors retry caps, non-context failures, and cancellation', async () => { const ctx = createContext() const compact = new TestCompactService(ctx, { maxOverflowRetries: 1 }) @@ -1051,7 +1288,6 @@ describe('automatic listener and loader composition', () => { const meterFiber = await ctx.plugin(TokenMeterService) const compactFiber = await ctx.plugin(BasicCompactService, { auto: false }) - expect(ctx.tokenMeter.contextWindow).toBe(128_000) expect(ctx.get('compact')).toBeInstanceOf(BasicCompactService) await compactFiber.dispose() expect(ctx.get('compact')).toBeUndefined() @@ -1062,7 +1298,7 @@ describe('automatic listener and loader composition', () => { it('removes its automatic listener with the plugin fiber', async () => { const ctx = new Context() await ctx.plugin(LlmService) - await ctx.plugin(TokenMeterService, { contextWindow: 1_000 }) + await ctx.plugin(TokenMeterService) const fiber = await ctx.plugin(TestCompactService, { thresholdRatio: 0.5, retainTokens: 180, diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index 1327f073c5..e73fd82687 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -37,6 +37,10 @@ class StepwiseToolAdapter extends LlmAdapter { super() } + override resolveModelContext(): Promise<{ contextWindow: number }> { + return Promise.resolve({ contextWindow: 400 }) + } + async * stream(_options: GenerateOptions): AsyncIterable { const n = this.calls this.calls += 1 @@ -65,6 +69,10 @@ class OverflowRecoveryAdapter extends LlmAdapter { super() } + override resolveModelContext(): Promise<{ contextWindow: number }> { + return Promise.resolve({ contextWindow: 128 }) + } + override async * stream(options: GenerateOptions): AsyncIterable { if (options.system?.includes('You are a compaction engine')) { this.summaryRequests.push(options) @@ -100,7 +108,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr await mountAgentLoopTestDependencies(ctx) await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(TokenMeterService, { contextWindow: 400 }) + await ctx.plugin(TokenMeterService) ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps)) ctx.tools.register(defineTool({ name: 'work', @@ -225,7 +233,7 @@ describe('context-overflow recovery across the real loop and compact-basic', () await mountAgentLoopTestDependencies(ctx) await ctx.plugin(Invariants) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(TokenMeterService, { contextWindow: 128 }) + await ctx.plugin(TokenMeterService) ctx.llm.registerAdapter(['mock'], adapter) ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, provider: 'mock', model: 'mock' })) await ctx.plugin(BasicCompactService, { diff --git a/packages/compact/compact-basic/tests/loader-composition.spec.ts b/packages/compact/compact-basic/tests/loader-composition.spec.ts index b13e8f8b67..be630919bd 100644 --- a/packages/compact/compact-basic/tests/loader-composition.spec.ts +++ b/packages/compact/compact-basic/tests/loader-composition.spec.ts @@ -54,12 +54,10 @@ describe('real Loader composition', () => { const loaded = await loadYaml([ "- name: '@deepseek-ai/dsh-llm'", "- name: '@deepseek-ai/dsh-token-meter'", - ' config:', - ' contextWindow: 4096', "- name: '@deepseek-ai/dsh-compact-basic'", ' config:', ' thresholdRatio: 0.5', - ' retainTokens: 512', + ' retainRatio: 0.125', ' auto: false', ]) @@ -67,11 +65,10 @@ describe('real Loader composition', () => { .filter(entry => entry.fiber === undefined && !entry.disabled) .map(entry => entry.options.name) expect(unloaded).toEqual([]) - expect(loaded.tokenMeter.contextWindow).toBe(4096) expect(loaded.get('compact')).toBeInstanceOf(BasicCompactService) expect((loaded.compact as BasicCompactService).config).toMatchObject({ thresholdRatio: 0.5, - retainTokens: 512, + retainRatio: 0.125, auto: false, }) }) @@ -79,8 +76,8 @@ describe('real Loader composition', () => { it('rejects stale token-meter config after Schemastery normalization', async () => { context = new Context() await expect(context.plugin(TokenMeterService, { - models: { legacy: { contextWindow: 4096 } }, - } as never)).rejects.toThrow(/TokenMeterConfig: unknown key "models"/) + contextWindow: 4096, + })).rejects.toThrow(/TokenMeterConfig: unknown key "contextWindow"/) }) it('rejects stale compact-basic config after Schemastery normalization', async () => { @@ -91,4 +88,18 @@ describe('real Loader composition', () => { models: { legacy: { thresholdRatio: 0.5 } }, } as never)).rejects.toThrow(/BasicCompactConfig: unknown key "models"/) }) + + it('rejects a capacity-independent merged ratio conflict during plugin load', async () => { + context = new Context() + await context.plugin(LlmService) + await context.plugin(TokenMeterService) + await expect(context.plugin(BasicCompactService, { + retainRatio: 0.2, + modelPolicies: [{ + provider: 'test-provider', + model: 'test-model', + thresholdRatio: 0.1, + }], + })).rejects.toThrow(/modelPolicies\[0\]: retainRatio \(0.2\).*thresholdRatio \(0.1\)/) + }) }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index c0c29331ae..3699463d91 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -266,6 +266,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async listModels(provider: string): Promise', jsDoc: '/**\n * Discover models advertised by one registered provider. Catalog membership\n * is advisory and never changes routing or request validation.\n * @param provider - registered provider route to inspect.\n * @returns detached model metadata in adapter-preferred order.\n */', }, + { + signature: 'async resolveModelContext( provider: string, model: string, ): Promise', + jsDoc: '/**\n * Resolve context capacity from the adapter that owns one exact route.\n * This query is independent of the advisory model catalog: an unlisted model\n * may return metadata, while `undefined` never rejects later routing.\n * @param provider - registered provider route to inspect.\n * @param model - exact model id passed to the adapter.\n * @returns detached context metadata, or `undefined` when the adapter has none.\n */', + }, { signature: 'stream(options: GenerateOptions): AsyncIterable', jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Throws\n * `LlmError` with code `NO_ADAPTER` if no adapter is registered for\n * `options.provider`. Replay state is retained only when the same adapter\n * instance owns its historical provider and the target provider. Final\n * adapter selection, dispatch, and iteration failures retain their original\n * Error identity and are tagged in a call-local scope for narrow agent-loop\n * request recovery; middleware and nested-call failures remain untagged for\n * the outer call.\n * @param options - the full request; `options.provider` selects the adapter.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */', @@ -1184,6 +1188,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'LlmCallConfig', declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}', }, + { + name: 'LlmModelContext', + declaration: 'export interface LlmModelContext {\n contextWindow: number;\n}', + }, { name: 'LlmModelInfo', declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n}', diff --git a/packages/llm/README.md b/packages/llm/README.md index ac08ffafc2..2e239b3cb2 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -9,4 +9,4 @@ The LLM seam and its provider adapters. The interface package (`llm`) owns the a | `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) | | `llm-pi-ai/` | Multi-provider adapter via `@earendil-works/pi-ai` | (registers on `ctx.llm`) | -The interface lives at `llm/llm/`; adapters and the reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. A new provider adapter joins here and registers one or more provider routes on `ctx.llm` without touching the interface. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the contract-validation origin of the two shipping implementations and the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership. +The interface lives at `llm/llm/`; adapters and the reusable token meter are flat siblings under the group. Requests route by `provider`, while `model` is passed through to the selected adapter. The same route-owning adapter optionally resolves exact provider/model context capacity; the token meter remains model-agnostic. A new provider adapter joins here and registers one or more provider routes on `ctx.llm` without touching consumers. See [twin LLM adapters](../../.agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md) for the contract-validation origin of the two shipping implementations, the [replay token meter Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md) for measurement ownership, and the [routed model context Agent Note](../../.agents/notes/implemented/architecture/2026-07-20-routed-model-context-and-compaction-policy.md) for capacity and compaction-policy ownership. diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 27bf4b626a..5dfdf954f2 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -19,11 +19,15 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire models: # optional; defaults to V4 Flash and V4 Pro - id: deepseek-v4-flash name: DeepSeek V4 Flash + contextWindow: 128000 - id: private-reasoner description: Company-hosted reasoning model + contextWindow: 64000 ``` -The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`. +The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`, each with a 128,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. + +`contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelContext('deepseek', model)` returns it only for an exact configured id; omission or an unlisted pass-through model returns `undefined` without invalidating routing. Pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`. `reasoningEffort` is **omitted by default** — when unset, the `reasoning_effort` wire field is not sent and the server applies its own default for the model. The only accepted values are `high` and `max` (DeepSeek's official effort levels). It is meaningful only with thinking enabled (the provider default). diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 918c8eee82..da780fff45 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -6,7 +6,13 @@ */ import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { + GenerateOptions, + LlmModelContext, + LlmModelInfo, + LlmProviderInfo, + StreamChunk, +} from '@deepseek-ai/dsh-llm' import { serializeRequest } from './serialize.ts' import type { RequestDefaults } from './serialize.ts' import { parseSse } from './sse.ts' @@ -21,6 +27,8 @@ export interface DeepSeekCatalogModel { name?: string /** Optional selector detail for deployments with similar model variants. */ description?: string + /** Known combined request/response context capacity; omitted when deployment metadata is unavailable. */ + contextWindow?: number } /** Constructor options for {@link DeepSeekAdapter}; the plugin's `apply` resolves them from Config + environment. */ @@ -79,6 +87,14 @@ export class DeepSeekAdapter extends LlmAdapter { }))) } + override resolveModelContext( + _provider: string, + model: string, + ): Promise { + const contextWindow = this.options.models?.find(entry => entry.id === model)?.contextWindow + return Promise.resolve(contextWindow === undefined ? undefined : { contextWindow }) + } + async * stream(options: GenerateOptions): AsyncIterable { const body = serializeRequest(options, this.options.defaults ?? {}) diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index f9f223b6ff..c066ed026a 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -20,8 +20,8 @@ export const name = 'llm-deepseek' export const inject = ['llm'] const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ - { id: 'deepseek-v4-flash' }, - { id: 'deepseek-v4-pro' }, + { id: 'deepseek-v4-flash', contextWindow: 128_000 }, + { id: 'deepseek-v4-pro', contextWindow: 128_000 }, ] /** @@ -47,6 +47,7 @@ const catalogModel: z = z.object({ id: z.string().required(), name: z.string(), description: z.string(), + contextWindow: z.number().step(1).min(1), }) export const Config: z = z.object({ @@ -68,12 +69,19 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee if (model.name !== undefined && model.name.length === 0) { throw new Error(`llm-deepseek: catalog model "${model.id}" has an empty name`) } + if (model.contextWindow !== undefined + && (!Number.isInteger(model.contextWindow) || model.contextWindow <= 0)) { + throw new Error( + `llm-deepseek: catalog model "${model.id}" contextWindow must be a positive integer`, + ) + } if (seen.has(model.id)) throw new Error(`llm-deepseek: duplicate catalog model "${model.id}"`) seen.add(model.id) return { id: model.id, ...model.name === undefined ? {} : { name: model.name }, ...model.description === undefined ? {} : { description: model.description }, + ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }, } }) } diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 954a0ecebd..fb8fbcb872 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -312,6 +312,8 @@ describe('plugin registration and config', () => { { provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' }, { provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' }, ]) + await expect(ctx.llm.resolveModelContext('deepseek', 'deepseek-v4-flash')) + .resolves.toEqual({ contextWindow: 128_000 }) }) it('uses the default model catalog when apply is called directly', async () => { @@ -331,14 +333,23 @@ describe('plugin registration and config', () => { apiKey: 'k', baseURL: 'http://127.0.0.1:1', models: [ - { id: 'private-fast' }, - { id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget' }, + { id: 'private-fast', contextWindow: 32_000 }, + { + id: 'private-reasoner', + name: 'Private Reasoner', + description: 'Higher reasoning budget', + contextWindow: 64_000, + }, ], }) await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ { provider: 'deepseek', id: 'private-fast', name: 'private-fast' }, { provider: 'deepseek', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget' }, ]) + await expect(ctx.llm.resolveModelContext('deepseek', 'private-fast')) + .resolves.toEqual({ contextWindow: 32_000 }) + await expect(ctx.llm.resolveModelContext('deepseek', 'arbitrary-unlisted')) + .resolves.toBeUndefined() }) it('allows an explicit empty model catalog', async () => { @@ -355,6 +366,8 @@ describe('plugin registration and config', () => { it.each([ [[{ id: '' }], /ids must be non-empty/], [[{ id: 'm', name: '' }], /empty name/], + [[{ id: 'm', contextWindow: 0 }], /contextWindow/], + [[{ id: 'm', contextWindow: 1.5 }], /contextWindow/], [[{ id: 'm' }, { id: 'm' }], /duplicate catalog model/], ] as const)('rejects invalid advisory model config', async (models, message) => { const ctx = new Context() @@ -367,6 +380,19 @@ describe('plugin registration and config', () => { expect(ctx.llm.listProviders()).toEqual([]) }) + it('rejects invalid context capacity when apply is called directly', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + expect(() => { + LlmDeepSeek.apply(ctx, { + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + models: [{ id: 'invalid-context', contextWindow: 0 }], + }) + }).toThrow(/contextWindow must be a positive integer/) + expect(ctx.llm.listProviders()).toEqual([]) + }) + it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'env-key') vi.stubEnv('DEEPSEEK_BASE_URL', 'http://127.0.0.1:1') diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 06395d701c..2146190b26 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -28,7 +28,7 @@ Configure credentials and deployment-specific transport settings per provider. O Each provider name must exist in pi-ai's installed catalog and may appear only once in this plugin instance. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`. -The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. +The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelContext(provider, model)` performs the same exact descriptor lookup and returns its context window, keeping capacity metadata on the route-owning adapter rather than a consuming plugin. Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `maxRetries`, and `maxRetryDelayMs`. They map to pi-ai's common stream options. Harness app attribution wins a conflicting configured header name. diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 7f40c67da3..c284ba3720 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -15,7 +15,7 @@ import type { SimpleStreamOptions, } from '@earendil-works/pi-ai' import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmModelContext, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import type { PiAiProviderProfile } from './config.ts' import { toPiContext } from './context.ts' import { toStreamChunks } from './stream.ts' @@ -87,6 +87,22 @@ export class PiAiAdapter extends LlmAdapter { }))) } + override resolveModelContext( + provider: string, + model: string, + ): Promise { + const profile = this.profiles.get(provider) + if (profile === undefined) { + return Promise.reject(new LlmError( + `pi-ai adapter does not own provider "${provider}"`, + 'NO_ADAPTER', + )) + } + return Promise.resolve().then(() => ({ + contextWindow: resolveModel(profile, model).contextWindow, + })) + } + async * stream(options: GenerateOptions): AsyncIterable { if (options.stop !== undefined) { throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION') diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 51f78e7760..dfccc134d2 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -260,6 +260,9 @@ describe('provider profile lifecycle', () => { provider: 'openai', id: 'gpt-4.1', name: 'GPT-4.1', }) expect(models.every(model => model.provider === 'openai')).toBe(true) + const context = await ctx.llm.resolveModelContext('openai', 'gpt-4.1') + expect(context).toBeDefined() + expect(typeof context?.contextWindow).toBe('number') }) it('accepts absent credentials for pi-ai ambient authentication', async () => { @@ -296,6 +299,10 @@ describe('provider profile lifecycle', () => { it('constructs the adapter directly and rejects routes it does not own', async () => { const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai' }] }) await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' }) + await expect(adapter.resolveModelContext('anthropic', 'claude-sonnet-4')) + .rejects.toMatchObject({ code: 'NO_ADAPTER' }) + await expect(adapter.resolveModelContext('openai', 'not-a-catalog-model')) + .rejects.toMatchObject({ code: 'UNKNOWN_MODEL' }) await expect((async () => { for await (const _chunk of adapter.stream({ provider: 'anthropic', model: 'claude-sonnet-4', messages: [] })) { /* drain */ } })()).rejects.toMatchObject({ code: 'NO_ADAPTER' }) diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index f9142dbc52..7d922fbdfe 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -11,12 +11,15 @@ An adapter registry plus a single streaming call surface, interceptable via a wa - `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber. - `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order. - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. +- `ctx.llm.resolveModelContext(provider: string, model: string): Promise` Resolve authoritative context capacity for one exact route from its owning adapter. - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`. `LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification does not replace the adapter's original coded `Error`. Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`. +Context capacity is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelContext()` asks the adapter that owns the exact provider/model route; an adapter can describe an unlisted dynamic model, and `undefined` means only that capacity is unavailable. Invalid returned capacity fails with `INVALID_MODEL_CONTEXT`. + ### Events | Event | Mode | Purpose | @@ -25,7 +28,7 @@ Provider and model metadata is a discovery surface, not a routing whitelist. `re ### Extension points -- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata; their defaults use the route id as its name and advertise no models. +- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata, and `resolveModelContext()` when exact capacity is known; the defaults use the route id as its name, advertise no models, and return no capacity. - Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, retry, logging, rate-limiting, etc. ### Content-block vocabulary (`types.ts`) diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index f276aa9f92..5f53e44710 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -7,7 +7,14 @@ */ import { Context, Service } from 'cordis' -import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, Message, StreamChunk } from './types.ts' +import type { + GenerateOptions, + LlmModelContext, + LlmModelInfo, + LlmProviderInfo, + Message, + StreamChunk, +} from './types.ts' import { deepFreeze } from './call-config.ts' import { HarnessError } from './error.ts' import { bindAdapterFailureScope, markLlmAdapterFailure } from './adapter-failure.ts' @@ -82,6 +89,20 @@ export abstract class LlmAdapter { return Promise.resolve([]) } + /** + * Resolve context capacity for one model accepted by this adapter. Absence + * means the adapter does not know the capacity, not that routing is invalid. + * @param _provider - one provider route owned by this adapter. + * @param _model - exact model id passed to {@link GenerateOptions.model}. + * @returns provider-owned context metadata, or `undefined` when unavailable. + */ + resolveModelContext( + _provider: string, + _model: string, + ): Promise { + return Promise.resolve(undefined) + } + /** * Stream one model call as raw chunks. The only required method. * @param options - the fully-assembled request; implementations must honor `options.signal`. @@ -177,6 +198,29 @@ export class LlmService extends Service { }) } + /** + * Resolve context capacity from the adapter that owns one exact route. + * This query is independent of the advisory model catalog: an unlisted model + * may return metadata, while `undefined` never rejects later routing. + * @param provider - registered provider route to inspect. + * @param model - exact model id passed to the adapter. + * @returns detached context metadata, or `undefined` when the adapter has none. + */ + async resolveModelContext( + provider: string, + model: string, + ): Promise { + const context = await this.registration(provider).adapter.resolveModelContext(provider, model) + if (context === undefined) return undefined + if (!Number.isInteger(context.contextWindow) || context.contextWindow <= 0) { + throw new LlmError( + `adapter returned invalid context metadata for provider "${provider}" model "${model}"`, + 'INVALID_MODEL_CONTEXT', + ) + } + return { contextWindow: context.contextWindow } + } + private registration(provider: string): { adapter: LlmAdapter; provider: LlmProviderInfo } { const registration = this.adapters.get(provider) if (!registration) throw new LlmError(`no adapter registered for provider "${provider}"`, 'NO_ADAPTER') diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index b8054c2046..b95ad95c7f 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -141,6 +141,12 @@ export interface LlmModelInfo { description?: string } +/** Provider-owned context capacity for one exact provider/model route. */ +export interface LlmModelContext { + /** Maximum combined request and response context in tokens. */ + contextWindow: number +} + /** * Raw streaming protocol emitted by adapters. * Block indexes correlate interleaved deltas, and `block-end` carries the diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 6e14d749ba..952d3e981c 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -9,7 +9,7 @@ import LlmService, { LlmError, StreamChunk, } from '@deepseek-ai/dsh-llm' -import type { LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' +import type { LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' class ScriptedAdapter extends LlmAdapter { constructor(private script: StreamChunk[]) { @@ -44,6 +44,7 @@ class CatalogAdapter extends ScriptedAdapter { constructor( private readonly provider: LlmProviderInfo, private readonly models: readonly LlmModelInfo[], + private readonly contexts: Readonly> = {}, ) { super(SCRIPT) } @@ -55,6 +56,13 @@ class CatalogAdapter extends ScriptedAdapter { override listModels(_provider: string): Promise { return Promise.resolve(this.models) } + + override resolveModelContext( + _provider: string, + model: string, + ): Promise { + return Promise.resolve(this.contexts[model]) + } } const SCRIPT: StreamChunk[] = [ @@ -439,8 +447,42 @@ describe('LlmService', () => { expect(ctx.llm.listProviders()).toEqual([{ id: 'plain', name: 'plain' }]) await expect(ctx.llm.listModels('plain')).resolves.toEqual([]) await expect(ctx.llm.listModels('missing')).rejects.toMatchObject({ code: 'NO_ADAPTER' }) + await expect(ctx.llm.resolveModelContext('plain', 'unlisted')).resolves.toBeUndefined() + await expect(ctx.llm.resolveModelContext('missing', 'm')).rejects.toMatchObject({ code: 'NO_ADAPTER' }) }) + it('resolves detached model context independently of advisory catalog membership', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + const source = { contextWindow: 32_000 } + ctx.llm.registerAdapter(['route'], new CatalogAdapter( + { id: 'route', name: 'Route' }, + [], + { unlisted: source }, + )) + + const resolved = await ctx.llm.resolveModelContext('route', 'unlisted') + expect(resolved).toEqual({ contextWindow: 32_000 }) + source.contextWindow = 64_000 + expect(resolved).toEqual({ contextWindow: 32_000 }) + await expect(ctx.llm.resolveModelContext('route', 'other')).resolves.toBeUndefined() + }) + + it.each([0, -1, 1.5, Number.NaN])( + 'rejects invalid adapter model context %s', + async (contextWindow) => { + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['route'], new CatalogAdapter( + { id: 'route', name: 'Route' }, + [], + { model: { contextWindow } }, + )) + await expect(ctx.llm.resolveModelContext('route', 'model')) + .rejects.toMatchObject({ code: 'INVALID_MODEL_CONTEXT' }) + }, + ) + it.each([ [{ id: 1, name: 'Name' }, 'non-string id'], [{ id: 'other', name: 'Name' }, 'mismatched id'], diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md index 20539de6a4..18f828ddd4 100644 --- a/packages/llm/token-meter/README.md +++ b/packages/llm/token-meter/README.md @@ -4,11 +4,7 @@ Replay-aware token measurement through the singleton `ctx.tokenMeter` service. I ## Configuration -| Key | Default | Contract | -|---|---:|---| -| `contextWindow` | `128000` | Positive integer service-wide context capacity. | - -The estimator intentionally uses one fixed heuristic: four characters per token plus structural overhead for roles, blocks, and request-envelope fields. `contextWindow` is the only deployment setting. Direct construction validates it; Loader mounts first apply the package's Schemastery shape validation. Unrecognized top-level keys are rejected. +The estimator has no settings. It intentionally uses one fixed heuristic: four characters per token plus structural overhead for roles, blocks, and request-envelope fields. Any key is rejected, including the obsolete global `contextWindow`; model capacity belongs to the adapter that owns an exact provider/model route and is available through `ctx.llm.resolveModelContext()`. ## Measurement contract @@ -30,13 +26,7 @@ Usage accounting sums disjoint input, cache-read, cache-write, and output bucket - name: '@deepseek-ai/dsh-compact-basic' ``` -Both plugins have usable defaults. A deployment with a different capacity configures the meter once: - -```yaml -- name: '@deepseek-ai/dsh-token-meter' - config: - contextWindow: 32768 -``` +Both plugins have usable defaults. The meter remains independent of model routing and optional compaction. A deployment configures capacity on its LLM adapter and compaction policy on `dsh-compact-basic`. ## Model Experience diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index 0c18dee74b..2fdfb064db 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -19,12 +19,6 @@ import type { export type * from './types.ts' -/** Default service-wide provider context capacity. */ -const DEFAULT_CONTEXT_WINDOW = 128_000 - -/** Complete public configuration key set. */ -const TOKEN_METER_CONFIG_KEYS: ReadonlySet = new Set(['contextWindow']) - /** Fixed text-density estimate used until exact tokenization is needed. */ const CHARS_PER_TOKEN = 4 @@ -74,28 +68,10 @@ function optionalHeaderEquals( /** Reject stale or misspelled keys before defaults can hide them. */ function validateConfigKeys(config: TokenMeterConfig): void { for (const key of Object.keys(config)) { - if (!TOKEN_METER_CONFIG_KEYS.has(key)) { - throw new Error( - `TokenMeterConfig: unknown key "${key}" (allowed: contextWindow)`, - ) - } + throw new Error(`TokenMeterConfig: unknown key "${key}" (no settings are supported)`) } } -/** Resolve and validate the one service-wide context capacity. */ -function resolveContextWindow(config: TokenMeterConfig): number { - validateConfigKeys(config) - const contextWindow = config.contextWindow === undefined - ? DEFAULT_CONTEXT_WINDOW - : config.contextWindow - if (!Number.isInteger(contextWindow) || contextWindow <= 0) { - throw new Error( - `TokenMeterConfig: contextWindow (${contextWindow}) must be a positive integer`, - ) - } - return contextWindow -} - declare module 'cordis' { interface Context { tokenMeter: TokenMeterService @@ -104,18 +80,13 @@ declare module 'cordis' { /** Replay owner for one service-wide estimator and isolated per-session folds. */ export class TokenMeterService extends Service { - static Config: z = z.object({ - contextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW), - }) - - /** Provider context-window capacity used by pressure consumers. */ - readonly contextWindow: number + static Config: z = z.object({}) private readonly states = new WeakMap() constructor(ctx: Context, config: TokenMeterConfig = {}) { super(ctx, 'tokenMeter') - this.contextWindow = resolveContextWindow(config) + validateConfigKeys(config) // Readers catch up independently, while eager observation bounds ordinary // read latency without creating state for sessions no consumer has read. diff --git a/packages/llm/token-meter/src/types.ts b/packages/llm/token-meter/src/types.ts index 7fb35af997..33aa3d4bb5 100644 --- a/packages/llm/token-meter/src/types.ts +++ b/packages/llm/token-meter/src/types.ts @@ -6,11 +6,8 @@ import type { TokenUsage } from '@deepseek-ai/dsh-llm' -/** Token-meter plugin configuration. */ -export interface TokenMeterConfig { - /** Service-wide context-window capacity in tokens. Defaults to `128000`. */ - contextWindow?: number -} +/** Token-meter plugin configuration; the fixed estimator has no settings. */ +export type TokenMeterConfig = object /** The baseline from which a signed surface delta produces current pressure. */ export type TokenMeasurementBaseline = diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index e7ddca6696..dc7d0c6e2e 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -87,29 +87,13 @@ function expectSurfaceTotal(measurement: TokenMeasurement): void { } describe('TokenMeterService configuration and registration', () => { - it('provides one zero-config context window', () => { - const service = meter() - expect(service.contextWindow).toBe(128_000) - }) - - it('accepts one service-wide context-window override', () => { - expect(meter({ contextWindow: 32_000 }).contextWindow).toBe(32_000) - }) - - it.each(['models', 'contextWidow'])('rejects unknown top-level config key %s', (key) => { - expect(() => meter({ [key]: {} })) - .toThrow(`TokenMeterConfig: unknown key "${key}"`) - }) - - it.each([ - { contextWindow: 0 }, - { contextWindow: -1 }, - { contextWindow: 1.5 }, - { contextWindow: Number.NaN }, - { contextWindow: null }, - ] as unknown as TokenMeterConfig[])('rejects invalid context capacity %#', (config) => { - expect(() => meter(config)).toThrow(/contextWindow .* positive integer/) - }) + it.each(['models', 'contextWindow', 'contextWidow'])( + 'rejects stale or unknown top-level config key %s', + (key) => { + expect(() => meter({ [key]: {} })) + .toThrow(`TokenMeterConfig: unknown key "${key}"`) + }, + ) it('registers and unregisters ctx.tokenMeter with its plugin fiber', async () => { const ctx = new Context() @@ -123,7 +107,7 @@ describe('TokenMeterService configuration and registration', () => { describe('TokenMeterService pricing', () => { it('prices every built-in content shape and merge-extended blocks with one fixed heuristic', () => { - const service = meter({ contextWindow: 100 }) + const service = meter() const blocks: ContentBlock[] = [ { type: 'text', text: 'abcd' }, { type: 'reasoning', text: 'ab' }, @@ -331,7 +315,7 @@ describe('replay anchors and surface folds', () => { }) it('keeps only the latest successful request anchor across model switches', () => { - const service = meter({ contextWindow: 1_000 }) + const service = meter() const session = new Session(SessionId('switch')) const alphaHeader = header('alpha', { system: 'same envelope' }) appendSuccessfulCall(session, alphaHeader, { usage: USAGE, providerText: 'alpha' }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 15780b17e0..1506d97104 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -34,6 +34,7 @@ export const LINK_MAP: Record = { ContinuationStop: 'core.md', GenerateOptions: 'core.md', LlmCallConfig: 'core.md', + LlmModelContext: 'core.md', LlmModelInfo: 'core.md', LlmProviderInfo: 'core.md', Message: 'core.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 202f2efb6e..a90c76c577 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -9,6 +9,7 @@ { "doc": "docs/core-data-structures/core.md", "symbol": "FinishReasonMap", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "LlmProviderInfo", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "LlmModelInfo", "source": "packages/llm/llm/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "LlmModelContext", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "GenerateOptions", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "ToolSchema", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "LlmCallConfig", "source": "packages/llm/llm/src/call-config.ts" }, diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 9cad9215fb..8a9142381d 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -544,7 +544,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t - `options` — the full request. A LOOP-built request arrives deep-frozen (mutation throws): its content is a pure function of the session log (the reconstructability Agent Note), so listeners read it, never rewrite it. A hand-built one-shot (compaction summarize) is the caller's own object and stays mutable here. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L43) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L50) ## session/* diff --git a/website/zh-CN/api/harness/llm.md b/website/zh-CN/api/harness/llm.md index e8e4d53a16..da8f2f4c33 100644 --- a/website/zh-CN/api/harness/llm.md +++ b/website/zh-CN/api/harness/llm.md @@ -6,7 +6,7 @@ The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L97) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L118) ### ctx.llm.registerAdapter(providers, adapter) @@ -29,7 +29,7 @@ Register an adapter for the given provider routes. Throws `LlmError` with code ` **Returns** the disposer that unregisters all of them. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L112) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L133) ### ctx.llm.listProviders() @@ -45,7 +45,7 @@ Describe provider routes with a registered adapter. **Returns** detached provider metadata in registration order. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L143) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L164) ### ctx.llm.listModels(provider) @@ -65,7 +65,30 @@ Discover models advertised by one registered provider. Catalog membership is adv **Returns** detached model metadata in adapter-preferred order. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L153) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L174) + +### ctx.llm.resolveModelContext(provider, model) + +```ts website-api +/** + * Resolve context capacity from the adapter that owns one exact route. + * This query is independent of the advisory model catalog: an unlisted model + * may return metadata, while `undefined` never rejects later routing. + * @param provider - registered provider route to inspect. + * @param model - exact model id passed to the adapter. + * @returns detached context metadata, or `undefined` when the adapter has none. + */ +async resolveModelContext( provider: string, model: string, ): Promise +``` + +Resolve context capacity from the adapter that owns one exact route. This query is independent of the advisory model catalog: an unlisted model may return metadata, while `undefined` never rejects later routing. + +- `provider` — registered provider route to inspect. +- `model` — exact model id passed to the adapter. + +**Returns** detached context metadata, or `undefined` when the adapter has none. + +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L209) ### ctx.llm.stream(options) @@ -91,4 +114,4 @@ Stream one model call as raw chunks (token-level deltas). Throws `LlmError` with **Returns** the chunk stream, possibly wrapped by `llm/stream` listeners. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L264) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L308) diff --git a/website/zh-CN/api/harness/token-meter.md b/website/zh-CN/api/harness/token-meter.md index 30b83f79d4..26ffb24b1a 100644 --- a/website/zh-CN/api/harness/token-meter.md +++ b/website/zh-CN/api/harness/token-meter.md @@ -6,18 +6,7 @@ Replay owner for one service-wide estimator and isolated per-session folds. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/token-meter/src/index.ts#L106) - -### ctx.tokenMeter.contextWindow - -```ts website-api -/** Provider context-window capacity used by pressure consumers. */ -readonly contextWindow: number -``` - -Provider context-window capacity used by pressure consumers. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/token-meter/src/index.ts#L112) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/token-meter/src/index.ts#L82) ### ctx.tokenMeter.measure(session, requestHeader?) @@ -50,7 +39,7 @@ Provider usage is reused only when the latest successful call's canonical reques **Returns** a detached deeply immutable pressure and surface measurement. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/token-meter/src/index.ts#L143) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/token-meter/src/index.ts#L114) ### ctx.tokenMeter.estimateMessage(message) @@ -69,4 +58,4 @@ Heuristically price one model-visible message. **Returns** content and role-framing tokens under the fixed service heuristic. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/token-meter/src/index.ts#L181) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/token-meter/src/index.ts#L152) diff --git a/website/zh-CN/guide/config.md b/website/zh-CN/guide/config.md index 35fb42185b..9c5acb415e 100644 --- a/website/zh-CN/guide/config.md +++ b/website/zh-CN/guide/config.md @@ -52,15 +52,17 @@ Harness 使用 `cordis.yml` 描述一个 Agent 加载哪些插件、以什么参 # LLM 后端:从 npm 包加载,具备接入 DeepSeek API 能力 # `!!js` 从环境变量读取密钥,不会写进配置文件 -# `models` 声明该适配器能处理哪些模型名 +# `models` 提供可选模型目录,并为精确模型声明上下文容量 - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' config: apiKey: !!js process.env.DEEPSEEK_API_KEY baseURL: !!js process.env.DEEPSEEK_BASE_URL models: - - deepseek-v4-pro - - deepseek-v4-flash + - id: deepseek-v4-pro + contextWindow: 128000 + - id: deepseek-v4-flash + contextWindow: 128000 # Bash 执行器:让 Agent 能跑 shell 命令 # timeoutMs 设置单条命令的超时时间 @@ -84,11 +86,9 @@ Harness 使用 `cordis.yml` 描述一个 Agent 加载哪些插件、以什么参 You are a coding agent powered by the {{model}} model. Verify your work by running the code or tests. Keep answers brief and factual. -# Token 计量:统一定义模型能看到的 token 上限 +# Token 计量:从持久日志计算请求压力;模型容量由上面的适配器提供 - id: token-meter name: '@deepseek-ai/dsh-token-meter' - config: - contextWindow: 128000 # 自动压缩:对话太长时自动总结旧内容,腾出上下文空间 # thresholdRatio 超过这个比例就触发压缩 @@ -97,7 +97,7 @@ Harness 使用 `cordis.yml` 描述一个 Agent 加载哪些插件、以什么参 name: '@deepseek-ai/dsh-compact-basic' config: thresholdRatio: 0.8 - retainTokens: 20480 + retainRatio: 0.16 maxTokens: 8192 compactionRetries: 1 @@ -243,7 +243,7 @@ config: |------|------|--------|------| | `apiKey` | string | `$DEEPSEEK_API_KEY` | API 密钥。省略则从环境变量读取 | | `baseURL` | string | `$DEEPSEEK_BASE_URL` 或官方地址 | API 端点 | -| `models` | string[] | `['deepseek-v4-flash', 'deepseek-v4-pro']` | 注册的模型名列表 | +| `models` | object[] | V4 Flash 与 V4 Pro(各 `128000` token) | 建议模型目录;每项包含 `id`,可选 `name`、`description` 与 `contextWindow` | | `thinking` | `'enabled'` \| `'disabled'` | `'enabled'` | 是否开启思维链 | | `reasoningEffort` | `'high'` \| `'max'` | — | 思维链深度(仅 thinking 开启时有效) | @@ -265,14 +265,16 @@ config: | 字段 | 类型 | 默认值 | 说明 | |------|------|--------|------| -| `contextWindow` | number | **必填** | 模型的上下文窗口大小(token) | -| `thresholdRatio` | number | **必填** | token 占用超过此比例时触发压缩(0-1) | -| `retainTokens` | number | **必填** | 压缩后至少保留多少 token 的近期内容 | -| `maxTokens` | number | **必填** | 总结时的最大输出 token | +| `thresholdRatio` | number | `0.8` | token 占用超过路由模型容量的此比例时触发压缩(0-1) | +| `retainRatio` | number | `0.16` | 压缩后按路由模型容量比例保留近期内容;与 `retainTokens` 互斥 | +| `retainTokens` | number | — | 按绝对 token 数保留近期内容;与 `retainRatio` 互斥 | +| `summarizationProvider` | string | `''`(使用当前提供方) | 专门用于总结的提供方;与 `summarizationModel` 同时设置 | | `summarizationModel` | string | `''`(用当前模型) | 专门用于总结的模型名 | -| `compactionRetries` | number | **必填** | 首次压缩后仍超标时的额外重试次数 | -| `auto` | boolean | `true` | 是否自动在每步前检查并触发压缩 | -| `charsPerToken` | number | `4` | 每 token 估算字符数。中文应设 1-2 | +| `maxTokens` | number | `8192` | 总结时的最大输出 token | +| `compactionRetries` | number | `1` | 首次压缩后仍超标时的额外重试次数 | +| `maxOverflowRetries` | number | `1` | 提供方确认上下文溢出后的最大恢复重试次数 | +| `modelPolicies` | object[] | `[]` | 按精确 `{ provider, model }` 组合部分覆盖上述策略 | +| `auto` | boolean | `true` | 是否在每个成功步骤后检查压力,并处理规范化上下文溢出 | ### fs-local(文件系统)