mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Expose audited hardcoded tunables as plugin config
The audit swept every packages/*/* plugin for the new AGENTS.md
convention (no hardcoded tunables in plugins) and exposes each finding
as a defaulted, validated Config field. Defaults are the previously
hardcoded values throughout, so no deployment or golden changes.
- tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes,
readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow —
read-render already documented that the consumer applies the caps, so
they become explicit per-request fields.
- tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the
schemastery default). Also fixes the stale GREP_LIMIT references in
search.ts and the web-capability-seam RFC (no such constant exists).
- bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The
RunInternals.graceMs test seam is gone: graceMs is now a required
SpawnSpec field filled from config, so tests exercise the real
config path and the defaults live in exactly one place.
- subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec
fields become required for the same one-defaulting-layer reason.
- session-persistence-sqlite: journalMode ('wal' default; the
rollback-journal modes serve filesystems where WAL's shared-memory
files do not work, e.g. network mounts).
- hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted
hook/result stderr summary. The duplicated summarize() helpers merge
into hook-protocol's summarizeStderr(stderr, maxChars), beside the
HookResultRecord field it feeds, with the bound parameterized the
same way runHook's defaultTimeoutMs already is.
- compact-basic: charsPerToken for the token estimator (default 4, the
English-text heuristic; CJK-heavy deployments need ~1-2 or compaction
fires far too late). Also corrects the BasicCompactService class doc,
which claimed defaults the required-field config never had.
- fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead
FsIoInternals.streamMinSize seam — the read-routing bound lives in
the consumer (tool-fs), where it is now config. This is item 1 of
the proposed prune-write-only-fs-surface RFC, annotated accordingly.
Every new field gets range validation (following the existing
assertPositiveFinite pattern), a README row, and tests covering the
configured behavior, the schema default, and load-time rejection.
This commit is contained in:
@@ -32,6 +32,7 @@ Every knob is **required** except `auto` — there is no concrete data yet to ju
|
||||
| `maxTokens` | yes | Provider generation cap for the summarization call; may include reasoning tokens. |
|
||||
| `compactionRetries` | yes | Extra compaction attempts after the first if the compacted surface remains over threshold. |
|
||||
| `auto` | no (default `true`) | Register the `agent/pre-step` auto-compaction listener. Set `false` for manual-only. |
|
||||
| `charsPerToken` | no (default `4`) | Token-estimator text density (estimated tokens = chars / `charsPerToken`; may be fractional). The default suits English text; CJK-heavy deployments should set ~1-2 or the estimate undershoots several-fold and compaction fires too late. |
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -148,9 +148,11 @@ function finishError(finish: FinishReason): Error | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic, dependency-light compaction backend. Defaults target a 128K context
|
||||
* window, compacting at 80% utilization and retaining ~20K tokens of recent
|
||||
* context.
|
||||
* Basic, dependency-light compaction backend: estimates the surface's token
|
||||
* footprint, summarizes the stale prefix through the model, and shadows it
|
||||
* behind a durable checkpoint. Every threshold/budget knob is required config
|
||||
* ({@link BasicCompactConfig}); the estimator's text density is the
|
||||
* `charsPerToken` knob.
|
||||
*/
|
||||
export class BasicCompactService extends CompactService {
|
||||
static inject = ['llm']
|
||||
@@ -207,24 +209,27 @@ export class BasicCompactService extends CompactService {
|
||||
|
||||
// ---- Token estimation (overridable hooks) ----
|
||||
|
||||
// TODO: char/4 is a coarse heuristic. Replace with an exact count — a real
|
||||
// tokenizer, or the provider's post-response `usage` (input tokens) fed back
|
||||
// as a correction — so threshold decisions match the model's actual budget.
|
||||
// TODO: chars/charsPerToken is a coarse heuristic. Replace with an exact
|
||||
// count — a real tokenizer, or the provider's post-response `usage` (input
|
||||
// tokens) fed back as a correction — so threshold decisions match the
|
||||
// model's actual budget.
|
||||
/**
|
||||
* Estimate the token count of content blocks — char/4 with per-block
|
||||
* overhead. Override in a subclass to plug in a real tokenizer.
|
||||
* Estimate the token count of content blocks — chars divided by the
|
||||
* `charsPerToken` config, with per-block overhead. Override in a subclass to
|
||||
* plug in a real tokenizer.
|
||||
*/
|
||||
estimateContentTokens(blocks: readonly ContentBlock[]): number {
|
||||
const { charsPerToken } = this.config
|
||||
let tokens = 0
|
||||
for (const block of blocks) {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
case 'reasoning':
|
||||
tokens += Math.ceil(block.text.length / 4) + BLOCK_OVERHEAD
|
||||
tokens += Math.ceil(block.text.length / charsPerToken) + BLOCK_OVERHEAD
|
||||
break
|
||||
case 'tool-call':
|
||||
tokens += Math.ceil(block.name.length / 4)
|
||||
+ Math.ceil(block.arguments.length / 4)
|
||||
tokens += Math.ceil(block.name.length / charsPerToken)
|
||||
+ Math.ceil(block.arguments.length / charsPerToken)
|
||||
+ BLOCK_OVERHEAD
|
||||
break
|
||||
case 'tool-result':
|
||||
@@ -236,7 +241,7 @@ export class BasicCompactService extends CompactService {
|
||||
default:
|
||||
// Unknown block types (merge-extensible ContentBlockMap):
|
||||
// estimate conservatively via JSON stringify.
|
||||
tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / 4)
|
||||
tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / charsPerToken)
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
@@ -266,7 +271,7 @@ export class BasicCompactService extends CompactService {
|
||||
total += this.estimateContentTokens(msg.content)
|
||||
total += ROLE_OVERHEAD
|
||||
}
|
||||
if (systemPrompt) total += Math.ceil(systemPrompt.length / 4)
|
||||
if (systemPrompt) total += Math.ceil(systemPrompt.length / this.config.charsPerToken)
|
||||
return total
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Backend configuration. Every knob is REQUIRED except `auto`: there is no
|
||||
* concrete data yet to justify default thresholds/budgets, so a consumer must
|
||||
* state each value explicitly rather than inherit a guessed default. `auto`
|
||||
* alone defaults to `true` (auto-compaction is the intended posture).
|
||||
* Backend configuration. Every knob is REQUIRED except `auto` and
|
||||
* `charsPerToken`: there is no concrete data yet to justify default
|
||||
* thresholds/budgets, so a consumer must state each value explicitly rather
|
||||
* than inherit a guessed default. `auto` alone defaults to `true`
|
||||
* (auto-compaction is the intended posture), and `charsPerToken` defaults to
|
||||
* the English-text heuristic its estimator was calibrated on.
|
||||
*/
|
||||
export interface BasicCompactConfig {
|
||||
/** Context window size in tokens. */
|
||||
@@ -30,13 +32,21 @@ export interface BasicCompactConfig {
|
||||
compactionRetries: number
|
||||
/** Enable automatic compaction on the `agent/pre-step` seam (default true). */
|
||||
auto?: boolean
|
||||
/**
|
||||
* Text density for the token estimator: estimated tokens = chars /
|
||||
* `charsPerToken`. Defaults to 4 (typical English text). A CJK-heavy
|
||||
* deployment should set ~1-2 — CJK runs at roughly 1-2 chars per token, so
|
||||
* the default UNDERestimates several-fold and compaction fires far too late.
|
||||
* May be fractional.
|
||||
*/
|
||||
charsPerToken?: number
|
||||
}
|
||||
|
||||
/** Resolved config with `auto` defaulted. */
|
||||
/** Resolved config with `auto` and `charsPerToken` defaulted. */
|
||||
export type ResolvedConfig = Required<BasicCompactConfig>
|
||||
|
||||
/**
|
||||
* Default `auto` when unset and reject nonsensical numeric knobs.
|
||||
* Default `auto`/`charsPerToken` when unset and reject nonsensical numeric knobs.
|
||||
*
|
||||
* Convergence is not a static config invariant: provider generation caps can be
|
||||
* spent on hidden or surfaced reasoning tokens, and the model may emit a summary
|
||||
@@ -46,13 +56,14 @@ export type ResolvedConfig = Required<BasicCompactConfig>
|
||||
* throwing if the surface still exceeds the threshold.
|
||||
*/
|
||||
export function resolveConfig(config: BasicCompactConfig): ResolvedConfig {
|
||||
const resolved: ResolvedConfig = { auto: true, ...config }
|
||||
const resolved: ResolvedConfig = { auto: true, charsPerToken: 4, ...config }
|
||||
|
||||
assertPositiveInteger('contextWindow', resolved.contextWindow)
|
||||
assertRatio('thresholdRatio', resolved.thresholdRatio)
|
||||
assertNonNegativeInteger('retainTokens', resolved.retainTokens)
|
||||
assertPositiveInteger('maxTokens', resolved.maxTokens)
|
||||
assertNonNegativeInteger('compactionRetries', resolved.compactionRetries)
|
||||
assertPositiveFinite('charsPerToken', resolved.charsPerToken)
|
||||
if (typeof resolved.summarizationModel !== 'string') {
|
||||
throw new Error('BasicCompactConfig: summarizationModel must be a string.')
|
||||
}
|
||||
@@ -74,6 +85,12 @@ function assertNonNegativeInteger(name: string, value: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
function assertPositiveFinite(name: string, value: number): void {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive finite number.`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertRatio(name: string, value: number): void {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a number in (0, 1].`)
|
||||
|
||||
@@ -820,6 +820,19 @@ describe('BasicCompactService token estimation (char/4 heuristic)', () => {
|
||||
const svc = new BasicCompactService(new Context(), cfg({ auto: false }))
|
||||
expect(svc.estimateContentTokens([])).toBe(0)
|
||||
})
|
||||
|
||||
it('honors a configured charsPerToken (fractional densities included)', () => {
|
||||
// 'this is a somewhat longer text block' = 36 chars.
|
||||
const blocks: ContentBlock[] = [{ type: 'text', text: 'this is a somewhat longer text block' }]
|
||||
// charsPerToken 2: ceil(36/2)+4 = 22 — a CJK-density config doubles the estimate.
|
||||
const dense = new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: 2 }))
|
||||
expect(dense.estimateContentTokens(blocks)).toBe(22)
|
||||
// Fractional density is legal: ceil(36/1.5)+4 = 28.
|
||||
const fractional = new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: 1.5 }))
|
||||
expect(fractional.estimateContentTokens(blocks)).toBe(28)
|
||||
// The system-prompt term scales with the same knob: 36-char prompt at density 2 → ceil(36/2) = 18.
|
||||
expect(dense.estimateTokens([], 'this is a somewhat longer text block')).toBe(18)
|
||||
})
|
||||
})
|
||||
|
||||
describe('BasicCompactService HMR safety', () => {
|
||||
@@ -862,6 +875,10 @@ describe('BasicCompactService config validation', () => {
|
||||
)).toThrow(/summarizationModel must be a string/)
|
||||
expect(() => new BasicCompactService(new Context(), cfg({ auto: 'no' } as unknown as Partial<BasicCompactConfig>)))
|
||||
.toThrow(/auto must be a boolean/)
|
||||
expect(() => new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: 0 })))
|
||||
.toThrow(/charsPerToken .* positive finite number/)
|
||||
expect(() => new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: Number.NaN })))
|
||||
.toThrow(/charsPerToken .* positive finite number/)
|
||||
})
|
||||
|
||||
it('accepts a large retain budget because convergence is enforced dynamically', () => {
|
||||
|
||||
Reference in New Issue
Block a user