fix(token-meter): reject stale config (round 2)

This commit is contained in:
Hypatia May
2026-07-16 13:23:20 +08:00
parent 5c243e8a8d
commit 19a56ec542
9 changed files with 110 additions and 34 deletions

View File

@@ -273,7 +273,7 @@ estimateMessage(message: Message): number
Types: [Message](../core-data-structures/core.md)
Source: [`packages/llm/token-meter/src/index.ts:92`](../../packages/llm/token-meter/src/index.ts)
Source: [`packages/llm/token-meter/src/index.ts:107`](../../packages/llm/token-meter/src/index.ts)
## `ctx.tools` — `ToolRegistry`

View File

@@ -28,7 +28,7 @@ This is not a coupling smell — it is the contract's domain. The "only cordis"
### Abstract `compactIfNeeded` / `compactRegion`, algorithm in the backend
An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface. That recouples the contract to one strategy: a backend that wants a different retention policy or event sequence would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend and keeps the interface a statement of *what*. Token measurement is not a compaction hook at all; the standalone service lets multiple consumers share one model/session replay fold.
An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface. That recouples the contract to one strategy: a backend that wants a different retention policy or event sequence would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend and keeps the interface a statement of *what*. Token measurement is not a compaction hook at all; the singleton service lets multiple consumers share one per-session replay fold.
`compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` takes required pressure inputs and cancellation. The session comes from the agent. `compactRegion(session, start, end, agent, signal?)` keeps an optional signal for manual callers and requires `session === agent.session`; implementations reject mismatch before model resolution, lock acquisition, summarization, or log mutation. The pre-step integration resolves a provisional model from the latest logged request header, then `AgentOptions.model`; a model-less router-only first step skips pressure because `agent/request` can route later. The default summarizer resolves its model from explicit config, the latest logged routed model, then agent options.

View File

@@ -20,7 +20,7 @@ This backend owns the compaction policy:
## Config (`BasicCompactConfig`)
Every setting is optional. The pressure and retention policy applies to the token meter's single context window.
Every setting is optional. The pressure and retention policy applies to the token meter's single context window. Unrecognized top-level keys are rejected.
| Key | Required | Meaning |
|---|---|---|

View File

@@ -14,6 +14,28 @@ const DEFAULT_THRESHOLD_RATIO = 0.8
/** Default verbatim-tail fraction of the token meter's context window. */
const DEFAULT_RETAIN_RATIO = 0.16
/** Complete public configuration key set. */
const BASIC_COMPACT_CONFIG_KEYS: ReadonlySet<string> = new Set([
'thresholdRatio',
'retainTokens',
'summarizationModel',
'maxTokens',
'compactionRetries',
'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, summarizationModel, maxTokens, compactionRetries, auto)',
)
}
}
}
/**
* Resolve defaults and validate the service-wide compaction policy.
* @param config - raw compact-basic configuration.
@@ -24,6 +46,7 @@ export function resolveConfig(
config: BasicCompactConfig = {},
tokenMeter: TokenMeterService,
): ResolvedConfig {
validateConfigKeys(config)
const thresholdRatio = config.thresholdRatio ?? DEFAULT_THRESHOLD_RATIO
const retainTokens = config.retainTokens
?? Math.floor(tokenMeter.contextWindow * DEFAULT_RETAIN_RATIO)

View File

@@ -163,6 +163,8 @@ describe('compact configuration and defaults', () => {
[{ thresholdRatio: 1.1 }, /number in \(0, 1\]/],
[{ retainTokens: -1 }, /non-negative integer/],
[{ thresholdRatio: 0.5, retainTokens: 500 }, /less than threshold/],
[{ 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) {

View File

@@ -20,44 +20,75 @@ afterEach(async () => {
root = undefined
})
async function loadYaml(lines: readonly string[]): Promise<Context> {
root = await mkdtemp(join(tmpdir(), 'dsh-token-meter-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [...lines, ''].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-llm', LlmService],
['@deepseek-ai/dsh-token-meter', TokenMeterService],
['@deepseek-ai/dsh-compact-basic', BasicCompactService],
])
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await context.loader.await()
return context
}
describe('real Loader composition', () => {
it('loads the zero-config token-meter then compact-basic YAML pair', async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-token-meter-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
it('loads the flat token-meter and compact-basic YAML shape', async () => {
const loaded = await loadYaml([
"- name: '@deepseek-ai/dsh-llm'",
"- name: '@deepseek-ai/dsh-token-meter'",
' config:',
' contextWindow: 4096',
"- name: '@deepseek-ai/dsh-compact-basic'",
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-llm', LlmService],
['@deepseek-ai/dsh-token-meter', TokenMeterService],
['@deepseek-ai/dsh-compact-basic', BasicCompactService],
' config:',
' thresholdRatio: 0.5',
' retainTokens: 512',
' auto: false',
])
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await context.loader.await()
const unloaded = [...context.loader.entries()]
const unloaded = [...loaded.loader.entries()]
.filter(entry => entry.fiber === undefined && !entry.disabled)
.map(entry => entry.options.name)
expect(unloaded).toEqual([])
expect(context.tokenMeter.contextWindow).toBe(128_000)
expect(context.get('compact')).toBeInstanceOf(BasicCompactService)
expect(loaded.tokenMeter.contextWindow).toBe(4096)
expect(loaded.get('compact')).toBeInstanceOf(BasicCompactService)
expect((loaded.compact as BasicCompactService).config).toMatchObject({
thresholdRatio: 0.5,
retainTokens: 512,
auto: false,
})
})
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"/)
})
it('rejects stale compact-basic config after Schemastery normalization', async () => {
context = new Context()
await context.plugin(LlmService)
await context.plugin(TokenMeterService)
await expect(context.plugin(BasicCompactService, {
models: { legacy: { thresholdRatio: 0.5 } },
} as never)).rejects.toThrow(/BasicCompactConfig: unknown key "models"/)
})
})

View File

@@ -8,7 +8,7 @@ Replay-aware token measurement through the singleton `ctx.tokenMeter` service. I
|---|---:|---|
| `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.
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.
## Measurement contract

View File

@@ -23,6 +23,9 @@ 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<string> = new Set(['contextWindow'])
/** Fixed text-density estimate used until exact tokenization is needed. */
const CHARS_PER_TOKEN = 4
@@ -69,8 +72,20 @@ function optionalHeaderEquals(
return headerEquals(left, right)
}
/** 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)`,
)
}
}
}
/** 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

View File

@@ -81,6 +81,11 @@ describe('TokenMeterService configuration and registration', () => {
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 },