dsh-context-compression-improved 0.4.0-beta.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.ja.md +68 -36
- package/CHANGELOG.ko.md +67 -35
- package/CHANGELOG.md +195 -134
- package/CHANGELOG.zh.md +64 -36
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/docs/installation.ja.md +2 -2
- package/docs/installation.ko.md +2 -2
- package/docs/installation.md +103 -78
- package/docs/installation.zh.md +100 -77
- package/docs/repair-log.md +54 -0
- package/package.json +1 -1
- package/packages/selector/lib/{config.js → advisor-state.js} +329 -5
- package/packages/selector/lib/client.d.ts +7 -0
- package/packages/selector/lib/client.js +33 -3
- package/packages/selector/lib/index.d.ts +7 -0
- package/packages/selector/lib/index.js +112 -3
- package/packages/selector/lib/pruner.d.ts +128 -1
- package/packages/selector/lib/pruner.js +2802 -1374
- package/packages/selector/src/client/ReviewOverlay.tsx +1 -1
- package/packages/selector/src/client/index.ts +1 -1
- package/packages/selector/src/client/preset-options.ts +2 -0
- package/packages/selector/src/index.ts +129 -49
- package/packages/selector/src/profiles.ts +48 -0
- package/packages/selector/src/pruner/content.ts +18 -5
- package/packages/selector/src/pruner/state.ts +3 -0
- package/packages/selector/src/pruner/types.ts +23 -5
- package/packages/selector/src/pruner.ts +297 -162
- package/packages/selector/src/runtime/adaptive-cost.ts +23 -12
- package/packages/selector/src/runtime/audit.ts +40 -2
- package/packages/selector/src/runtime/config.ts +88 -1
- package/packages/selector/src/runtime/measurement.ts +31 -2
- package/packages/selector/src/runtime/reducers.ts +1115 -97
- package/packages/selector/src/runtime/tokenpilot/advisor-prompt.ts +188 -0
- package/packages/selector/src/runtime/tokenpilot/advisor-state.ts +133 -0
- package/packages/selector/src/runtime/tokenpilot/advisor.ts +419 -0
- package/packages/selector/src/runtime/tokenpilot/dedup.ts +1 -1
- package/packages/selector/src/runtime/tokenpilot/estimator.ts +8 -118
- package/packages/selector/src/runtime/tokenpilot/locator.ts +1 -1
- package/packages/selector/src/runtime/tokenpilot/proposal.ts +76 -32
- package/packages/selector/src/runtime/tokenpilot/read-state.ts +23 -2
- package/packages/selector/src/runtime/tokenpilot/review-registry.ts +117 -0
- package/packages/selector/src/runtime/tokenpilot/sidechannel.ts +303 -0
- package/packages/selector/src/runtime/toolclass.ts +103 -0
- package/packages/selector/src/runtime/types.ts +37 -0
- package/packages/selector/tests/advisor-report.host.spec.ts +223 -0
- package/packages/selector/tests/public/package-contract.client.spec.ts +2 -1
- package/packages/selector/tests/review-routes-registry.host.spec.ts +142 -0
- package/packages/selector/tests/runtime/adaptive-cost.spec.ts +7 -7
- package/packages/selector/tests/runtime/advisor-invariant.spec.ts +272 -0
- package/packages/selector/tests/runtime/advisor.spec.ts +226 -0
- package/packages/selector/tests/runtime/audit.spec.ts +88 -1
- package/packages/selector/tests/runtime/char-basis.spec.ts +30 -0
- package/packages/selector/tests/runtime/code-skeleton.spec.ts +14 -3
- package/packages/selector/tests/runtime/frequency-longstrings.spec.ts +74 -0
- package/packages/selector/tests/runtime/html-reducer.spec.ts +212 -0
- package/packages/selector/tests/runtime/line-mapping.spec.ts +153 -0
- package/packages/selector/tests/runtime/prose-reducers.spec.ts +133 -0
- package/packages/selector/tests/runtime/public/public-runtime.spec.ts +198 -27
- package/packages/selector/tests/runtime/read-input-cap.spec.ts +33 -0
- package/packages/selector/tests/runtime/search-reducer.spec.ts +110 -0
- package/packages/selector/tests/runtime/sidechannel.spec.ts +241 -0
- package/packages/selector/tests/runtime/toc-and-bundled.spec.ts +159 -0
- package/packages/selector/tests/runtime/tokenpilot/profile-baseline.spec.ts +12 -0
- package/packages/selector/tests/runtime/tokenpilot/proposal.spec.ts +194 -0
- package/packages/selector/tests/runtime/tokenpilot/pruner-review.spec.ts +70 -1
- package/packages/selector/tests/runtime/tokenpilot/read-state.spec.ts +24 -0
- package/packages/selector/tests/runtime/toolclass.spec.ts +156 -0
- package/scripts/toolclass-corpus-replay.mjs +281 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-source classification (R1/R2), narrowed to FOUR classes (tasks v2 G4):
|
|
3
|
+
* `read` / `shell` / `search` / `path-listing`, everything else `generic`.
|
|
4
|
+
*
|
|
5
|
+
* Form routing beats identity routing — most tools' results land through
|
|
6
|
+
* `read`/`cat` — so this layer exists to keep the OBSERVED misroutes out of
|
|
7
|
+
* the wrong reducers, not to add per-identity behavior:
|
|
8
|
+
* - `web_search` / `memory_search` are not content-search (findings §0)
|
|
9
|
+
* - `glob` is path listing, not search
|
|
10
|
+
* - `execute_sql` is not shell; `research` is not search (substring era bugs)
|
|
11
|
+
* - MCP output has no predictable identity (`mcp` token → generic, C8)
|
|
12
|
+
*
|
|
13
|
+
* Judgment order (C15): name whitelist → command hit → content fallback, and
|
|
14
|
+
* a hit at any earlier stage is never overridden by a later one.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type ToolClass = 'read' | 'shell' | 'search' | 'path-listing' | 'generic'
|
|
18
|
+
|
|
19
|
+
/** task_11 (AD3): exact-token matching, no substring hits. */
|
|
20
|
+
const TOKEN_SEPARATOR = /[-_/]+/
|
|
21
|
+
|
|
22
|
+
/** Multi-token names that must match as a whole word, not as a token. */
|
|
23
|
+
const READ_NAMES = new Set(['open_file'])
|
|
24
|
+
const READ_TOKENS = new Set(['read', 'cat', 'view'])
|
|
25
|
+
const SHELL_TOKENS = new Set(['shell', 'bash', 'pwsh', 'powershell', 'terminal', 'exec', 'command'])
|
|
26
|
+
const SEARCH_TOKENS = new Set(['grep', 'rg', 'ripgrep'])
|
|
27
|
+
const PATH_LISTING_TOKENS = new Set(['glob', 'tree', 'ls', 'fd', 'find'])
|
|
28
|
+
|
|
29
|
+
const SEARCH_COMMAND_PATTERN = /(?:^|\s)(?:rg|grep|ripgrep)\s/
|
|
30
|
+
const PATH_COMMAND_PATTERN = /(?:^|\s)(?:find|fd|ls|tree)\s/
|
|
31
|
+
|
|
32
|
+
/** grep-style hit line: `path:line[:column][: content]`. */
|
|
33
|
+
const PATH_LINE_CONTENT_PATTERN = /^(.*?):(\d+)(?::\d+)?(?::|\s+-\s+)(.*)$/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Classify a tool result's source.
|
|
37
|
+
* @param name - raw tool name (case-insensitive).
|
|
38
|
+
* @param command - extracted command argument, '' when absent.
|
|
39
|
+
* @param text - result text; enables the C13/C14 content fallback for names
|
|
40
|
+
* that no whitelist (and no command hit) claims.
|
|
41
|
+
* @returns the tool source class.
|
|
42
|
+
*/
|
|
43
|
+
export function classifyToolSource(name: string, command: string, text?: string): ToolClass {
|
|
44
|
+
const lowered = name.toLowerCase()
|
|
45
|
+
const tokens = lowered.split(TOKEN_SEPARATOR).filter(token => token !== '')
|
|
46
|
+
// C8: an `mcp` token means the output identity is arbitrary — generic first,
|
|
47
|
+
// before any content heuristic gets a vote.
|
|
48
|
+
if (tokens.includes('mcp')) return 'generic'
|
|
49
|
+
const byName = classifyByName(lowered, tokens)
|
|
50
|
+
if (byName !== 'generic') return byName
|
|
51
|
+
const byCommand = classifyByCommand(command)
|
|
52
|
+
if (byCommand !== 'generic') return byCommand
|
|
53
|
+
return text === undefined ? 'generic' : classifyByContent(text)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function classifyByName(lowered: string, tokens: readonly string[]): ToolClass {
|
|
57
|
+
if (READ_NAMES.has(lowered) || tokens.some(token => READ_TOKENS.has(token))) return 'read'
|
|
58
|
+
if (tokens.some(token => SHELL_TOKENS.has(token))) return 'shell'
|
|
59
|
+
if (tokens.some(token => SEARCH_TOKENS.has(token))) return 'search'
|
|
60
|
+
if (tokens.some(token => PATH_LISTING_TOKENS.has(token))) return 'path-listing'
|
|
61
|
+
return 'generic'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function classifyByCommand(command: string): ToolClass {
|
|
65
|
+
if (command === '') return 'generic'
|
|
66
|
+
if (SEARCH_COMMAND_PATTERN.test(command)) return 'search'
|
|
67
|
+
if (PATH_COMMAND_PATTERN.test(command)) return 'path-listing'
|
|
68
|
+
return 'generic'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A path-shaped hit locator needs a path-looking prefix, not just `:digits`. */
|
|
72
|
+
function looksLikeFilePath(prefix: string): boolean {
|
|
73
|
+
if (prefix.includes('://')) return false
|
|
74
|
+
return prefix.includes('/') || prefix.includes('\\') || /\.[A-Za-z0-9]{1,8}$/.test(prefix)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A bare path: no whitespace, no URL scheme, with a separator or an extension. */
|
|
78
|
+
function isPurePathLine(line: string): boolean {
|
|
79
|
+
if (/\s/.test(line) || line.includes('://')) return false
|
|
80
|
+
return line.includes('/') || line.includes('\\') || /\.[A-Za-z0-9]{1,8}$/.test(line)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Content fallback (C13/C14) for unknown names: one path-plausible
|
|
85
|
+
* `path:line:content` line reads as grep output; a body of ≥80% pure paths
|
|
86
|
+
* reads as a directory listing. Both require file-path evidence so prose
|
|
87
|
+
* (`Note: 2024 - ...`) and URLs (`https://host:8080 - ...`) stay generic.
|
|
88
|
+
*/
|
|
89
|
+
function classifyByContent(text: string): ToolClass {
|
|
90
|
+
let nonEmpty = 0
|
|
91
|
+
let purePaths = 0
|
|
92
|
+
let pathLineHits = 0
|
|
93
|
+
for (const line of text.split('\n')) {
|
|
94
|
+
if (line.trim() === '') continue
|
|
95
|
+
nonEmpty += 1
|
|
96
|
+
const match = PATH_LINE_CONTENT_PATTERN.exec(line)
|
|
97
|
+
if (match !== null && looksLikeFilePath(match[1] ?? '')) pathLineHits += 1
|
|
98
|
+
if (isPurePathLine(line)) purePaths += 1
|
|
99
|
+
}
|
|
100
|
+
if (pathLineHits >= 1) return 'search'
|
|
101
|
+
if (nonEmpty >= 4 && purePaths / nonEmpty >= 0.8) return 'path-listing'
|
|
102
|
+
return 'generic'
|
|
103
|
+
}
|
|
@@ -71,6 +71,20 @@ export interface PresetOptions {
|
|
|
71
71
|
readonly readState: boolean
|
|
72
72
|
/** Optional estimator channel; `''` keeps every estimator consumer on rule-only fallbacks (E1/E2). */
|
|
73
73
|
readonly estimator: { readonly mode: '' | 'host' | 'direct' }
|
|
74
|
+
/**
|
|
75
|
+
* Advisory relevance advisor: statistics and suggestions only — every output
|
|
76
|
+
* (summaries, scores, decay, recertification) is observational and must never
|
|
77
|
+
* suppress, delay, or rewrite any reduction that would land. `''` (the
|
|
78
|
+
* default) keeps the advisor fully off.
|
|
79
|
+
*/
|
|
80
|
+
readonly advisor: {
|
|
81
|
+
readonly mode: '' | 'host' | 'direct'
|
|
82
|
+
readonly timeoutMs: number
|
|
83
|
+
readonly refreshTurns: number
|
|
84
|
+
readonly scoreThreshold: number
|
|
85
|
+
readonly sampleLimit: number
|
|
86
|
+
readonly minTokens: number
|
|
87
|
+
}
|
|
74
88
|
/**
|
|
75
89
|
* Human-gated review pipeline (beta): edge/high-impact candidates queue for
|
|
76
90
|
* manual approval and execute in one merged batch at the next turn boundary
|
|
@@ -155,6 +169,13 @@ export interface PresetOptionsSettings {
|
|
|
155
169
|
readonly estimatorBaseUrl?: string
|
|
156
170
|
readonly estimatorApiKey?: string
|
|
157
171
|
readonly estimatorTimeoutMs?: number
|
|
172
|
+
/** Advisory advisor channel; `''` (the default) keeps the advisor off. */
|
|
173
|
+
readonly advisorMode?: '' | 'host' | 'direct'
|
|
174
|
+
readonly advisorTimeoutMs?: number
|
|
175
|
+
readonly advisorRefreshTurns?: number
|
|
176
|
+
readonly advisorScoreThreshold?: number
|
|
177
|
+
readonly advisorSampleLimit?: number
|
|
178
|
+
readonly advisorMinTokens?: number
|
|
158
179
|
}
|
|
159
180
|
|
|
160
181
|
/** Durable global preference exposed through `ctx.settings`. */
|
|
@@ -199,6 +220,14 @@ export interface ToolResultPruneConfig {
|
|
|
199
220
|
historyKeepRecentTokens?: number
|
|
200
221
|
/** Minimum reclaim required before historical aging is worth a cache break. Profile default when omitted. */
|
|
201
222
|
historyMinReclaimTokens?: number
|
|
223
|
+
/**
|
|
224
|
+
* Read-class input cap in characters. Invariant (startup-asserted in
|
|
225
|
+
* `resolvePolicy`): when set it must exceed `freshTriggerTokens × 4.0` —
|
|
226
|
+
* the conservative chars/token upper bound — otherwise the cap sits below
|
|
227
|
+
* the fresh trigger and silently silences the fresh path for every read
|
|
228
|
+
* result. Unset profiles (host cap 50k–59.5k chars observed) stay untouched.
|
|
229
|
+
*/
|
|
230
|
+
readInputCapChars?: number
|
|
202
231
|
/**
|
|
203
232
|
* Auto Compact threshold percent frozen into this deployment by the preset
|
|
204
233
|
* overlay generation (50–90 integer). When present it supersedes the live
|
|
@@ -228,6 +257,12 @@ export interface CompressionPolicy {
|
|
|
228
257
|
readonly historyKeepRecentToolCalls: number
|
|
229
258
|
readonly historyKeepRecentTokens: number
|
|
230
259
|
readonly historyMinReclaimTokens: number
|
|
260
|
+
/**
|
|
261
|
+
* Read-class input cap in characters when configured; absent otherwise.
|
|
262
|
+
* Startup-asserted to exceed `freshTriggerTokens × 4.0` so it can never
|
|
263
|
+
* silently silence the fresh path (G2 invariant).
|
|
264
|
+
*/
|
|
265
|
+
readonly readInputCapChars?: number
|
|
231
266
|
/**
|
|
232
267
|
* Auto Compact token watermark `A = floor(C × a)` when the standard-profile
|
|
233
268
|
* History linkage resolved for this Session; absent for Custom, Off, Native,
|
|
@@ -267,6 +302,8 @@ export interface ResolvedConfig {
|
|
|
267
302
|
readonly historyKeepRecentToolCalls?: number
|
|
268
303
|
readonly historyKeepRecentTokens?: number
|
|
269
304
|
readonly historyMinReclaimTokens?: number
|
|
305
|
+
/** Read-class input cap in characters when configured; absent otherwise. */
|
|
306
|
+
readonly readInputCapChars?: number
|
|
270
307
|
/**
|
|
271
308
|
* Auto Compact threshold percent frozen into this deployment by the preset
|
|
272
309
|
* overlay generation (50-90 integer). Supersedes the live Host setting.
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side guard for the advisory advisor's HTTP report route.
|
|
3
|
+
*
|
|
4
|
+
* Pins the read-only contract: the route registers under both prefixes only
|
|
5
|
+
* when the row opts in, a session whose advisor never ran reports nulls and
|
|
6
|
+
* empty arrays (not an error), a populated session reports its decay figure
|
|
7
|
+
* and score distribution content-free, and every error path answers a
|
|
8
|
+
* precise status — 400 for a missing sessionId, 404 for an unknown session,
|
|
9
|
+
* 503 when no agents service can resolve sessions at all.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Context } from '@deepseek-ai/cordis'
|
|
13
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
14
|
+
import { apply } from '../src/index.ts'
|
|
15
|
+
import { getAdvisorState, recordScore, recordRecertified } from '../src/runtime/tokenpilot/advisor-state.ts'
|
|
16
|
+
|
|
17
|
+
const REPORT_ROUTE = '/api/dsh-context-compression-improved/advisor-report'
|
|
18
|
+
const LEGACY_REPORT_ROUTE = '/endpoint/dsh-context-compression-improved/advisor-report'
|
|
19
|
+
|
|
20
|
+
interface RegisteredRoute {
|
|
21
|
+
kind: string
|
|
22
|
+
path: string
|
|
23
|
+
handler: (req: unknown, res: unknown) => unknown
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface FakeResponse {
|
|
27
|
+
status?: number
|
|
28
|
+
body?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let ctx: Context | undefined
|
|
32
|
+
|
|
33
|
+
afterEach(async () => {
|
|
34
|
+
await ctx?.fiber.dispose()
|
|
35
|
+
ctx = undefined
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const settle = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 20))
|
|
39
|
+
|
|
40
|
+
async function mountWebServer(runtime: Context, routes: RegisteredRoute[]): Promise<void> {
|
|
41
|
+
await runtime.plugin({
|
|
42
|
+
name: 'fake-webserver',
|
|
43
|
+
apply(webCtx) {
|
|
44
|
+
webCtx.provide('webServer', {
|
|
45
|
+
tables: { exact: new Map<string, RegisteredRoute>() },
|
|
46
|
+
register(this: { tables: { exact: Map<string, RegisteredRoute> } }, route: RegisteredRoute) {
|
|
47
|
+
const table = this.tables.exact
|
|
48
|
+
if (table.has(route.path)) {
|
|
49
|
+
throw new Error(`webserver: duplicate ${route.kind} route "${route.path}"`)
|
|
50
|
+
}
|
|
51
|
+
table.set(route.path, route)
|
|
52
|
+
routes.push(route)
|
|
53
|
+
return () => {}
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Mount an agents service that knows session 's1' (and nothing else). */
|
|
61
|
+
function mountAgents(runtime: Context): void {
|
|
62
|
+
void runtime.plugin({
|
|
63
|
+
name: 'fake-agents',
|
|
64
|
+
apply(serviceCtx) {
|
|
65
|
+
serviceCtx.provide('agents', {
|
|
66
|
+
get: (id: unknown) => {
|
|
67
|
+
if (id !== 's1') return undefined
|
|
68
|
+
const session = { id: 's1' }
|
|
69
|
+
const state = getAdvisorState(session as never)
|
|
70
|
+
state.summary = {
|
|
71
|
+
overallTask: 'migrate the auth module',
|
|
72
|
+
activeSubtasks: ['port login flow'],
|
|
73
|
+
keywords: ['auth'],
|
|
74
|
+
todoVersion: 'aaaa1111',
|
|
75
|
+
turn: 4,
|
|
76
|
+
}
|
|
77
|
+
state.lastDecay = { decay: 0.42, weightedChars: 10_000, turn: 4 }
|
|
78
|
+
recordScore(state, 3, { score: 0.95, turn: 4 })
|
|
79
|
+
recordScore(state, 5, { score: 0.10, turn: 4 })
|
|
80
|
+
recordRecertified(state, 5, 4)
|
|
81
|
+
return { session }
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function invoke(route: RegisteredRoute, req?: unknown): Promise<FakeResponse> {
|
|
89
|
+
const captured: FakeResponse = {}
|
|
90
|
+
const res = {
|
|
91
|
+
writeHead(code: number) { captured.status = code },
|
|
92
|
+
end(body?: string) { if (body !== undefined) captured.body = body },
|
|
93
|
+
}
|
|
94
|
+
await route.handler(req ?? { method: 'GET' }, res)
|
|
95
|
+
await settle()
|
|
96
|
+
return captured
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe('advisor report route registration', () => {
|
|
100
|
+
it('registers both prefixes only when the row opts in', async () => {
|
|
101
|
+
const routes: RegisteredRoute[] = []
|
|
102
|
+
const runtime = new Context()
|
|
103
|
+
ctx = runtime
|
|
104
|
+
await mountWebServer(runtime, routes)
|
|
105
|
+
apply(runtime, { advisorReportRoute: true })
|
|
106
|
+
await settle()
|
|
107
|
+
const paths = routes.map(route => route.path)
|
|
108
|
+
expect(paths).toContain(REPORT_ROUTE)
|
|
109
|
+
expect(paths).toContain(LEGACY_REPORT_ROUTE)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('registers nothing by default (advisor off adds zero behavior)', async () => {
|
|
113
|
+
const routes: RegisteredRoute[] = []
|
|
114
|
+
const runtime = new Context()
|
|
115
|
+
ctx = runtime
|
|
116
|
+
await mountWebServer(runtime, routes)
|
|
117
|
+
apply(runtime, {})
|
|
118
|
+
await settle()
|
|
119
|
+
expect(routes.map(route => route.path)).not.toContain(REPORT_ROUTE)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('advisor report route responses', () => {
|
|
124
|
+
async function mountedRoute(): Promise<RegisteredRoute> {
|
|
125
|
+
const routes: RegisteredRoute[] = []
|
|
126
|
+
const runtime = new Context()
|
|
127
|
+
ctx = runtime
|
|
128
|
+
await mountWebServer(runtime, routes)
|
|
129
|
+
apply(runtime, { advisorReportRoute: true })
|
|
130
|
+
await settle()
|
|
131
|
+
const route = routes.find(entry => entry.path === REPORT_ROUTE)
|
|
132
|
+
expect(route).toBeDefined()
|
|
133
|
+
return route as RegisteredRoute
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
it('answers 503 without an agents service', async () => {
|
|
137
|
+
const route = await mountedRoute()
|
|
138
|
+
const response = await invoke(route, { url: `${REPORT_ROUTE}?sessionId=s1` })
|
|
139
|
+
expect(response.status).toBe(503)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('answers 404 for an unknown session and 400 for a missing sessionId', async () => {
|
|
143
|
+
const routes: RegisteredRoute[] = []
|
|
144
|
+
const runtime = new Context()
|
|
145
|
+
ctx = runtime
|
|
146
|
+
await mountWebServer(runtime, routes)
|
|
147
|
+
mountAgents(runtime)
|
|
148
|
+
apply(runtime, { advisorReportRoute: true })
|
|
149
|
+
await settle()
|
|
150
|
+
const route = routes.find(entry => entry.path === REPORT_ROUTE) as RegisteredRoute
|
|
151
|
+
|
|
152
|
+
const unknown = await invoke(route, { url: `${REPORT_ROUTE}?sessionId=other` })
|
|
153
|
+
expect(unknown.status).toBe(404)
|
|
154
|
+
|
|
155
|
+
const missing = await invoke(route, { url: REPORT_ROUTE })
|
|
156
|
+
expect(missing.status).toBe(400)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('serves the decay figure, summary, and scores content-free', async () => {
|
|
160
|
+
const routes: RegisteredRoute[] = []
|
|
161
|
+
const runtime = new Context()
|
|
162
|
+
ctx = runtime
|
|
163
|
+
await mountWebServer(runtime, routes)
|
|
164
|
+
mountAgents(runtime)
|
|
165
|
+
apply(runtime, { advisorReportRoute: true })
|
|
166
|
+
await settle()
|
|
167
|
+
const route = routes.find(entry => entry.path === REPORT_ROUTE) as RegisteredRoute
|
|
168
|
+
|
|
169
|
+
const response = await invoke(route, { url: `${REPORT_ROUTE}?sessionId=s1` })
|
|
170
|
+
expect(response.status).toBe(200)
|
|
171
|
+
const body = JSON.parse(response.body ?? '{}') as {
|
|
172
|
+
ok: boolean
|
|
173
|
+
sessionId: string
|
|
174
|
+
advisor: {
|
|
175
|
+
summary: { overallTask: string } | null
|
|
176
|
+
decay: number | null
|
|
177
|
+
weightedChars: number | null
|
|
178
|
+
scores: { seq: number, score: number }[]
|
|
179
|
+
lowRelevanceSeqs: number[]
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
expect(body.ok).toBe(true)
|
|
183
|
+
expect(body.sessionId).toBe('s1')
|
|
184
|
+
expect(body.advisor.decay).toBeCloseTo(0.42, 12)
|
|
185
|
+
expect(body.advisor.summary?.overallTask).toBe('migrate the auth module')
|
|
186
|
+
expect(body.advisor.scores).toHaveLength(2)
|
|
187
|
+
expect(body.advisor.lowRelevanceSeqs).toEqual([5])
|
|
188
|
+
// Content-free: no message text ever rides along.
|
|
189
|
+
expect(response.body).not.toContain('"text"')
|
|
190
|
+
expect(response.body).not.toContain('"content"')
|
|
191
|
+
expect(response.body).not.toContain('apiKey')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('answers an empty report (nulls, not an error) for a session whose advisor never ran', async () => {
|
|
195
|
+
const routes: RegisteredRoute[] = []
|
|
196
|
+
const runtime = new Context()
|
|
197
|
+
ctx = runtime
|
|
198
|
+
await mountWebServer(runtime, routes)
|
|
199
|
+
void runtime.plugin({
|
|
200
|
+
name: 'fake-agents-empty',
|
|
201
|
+
apply(serviceCtx) {
|
|
202
|
+
serviceCtx.provide('agents', {
|
|
203
|
+
get: (id: unknown) => (id === 's1' ? { session: { id: 's1' } } : undefined),
|
|
204
|
+
})
|
|
205
|
+
},
|
|
206
|
+
})
|
|
207
|
+
apply(runtime, { advisorReportRoute: true })
|
|
208
|
+
await settle()
|
|
209
|
+
const route = routes.find(entry => entry.path === REPORT_ROUTE) as RegisteredRoute
|
|
210
|
+
|
|
211
|
+
const response = await invoke(route, { url: `${REPORT_ROUTE}?sessionId=s1` })
|
|
212
|
+
expect(response.status).toBe(200)
|
|
213
|
+
const body = JSON.parse(response.body ?? '{}') as {
|
|
214
|
+
ok: boolean
|
|
215
|
+
advisor: { summary: unknown, decay: unknown, scores: unknown[], lowRelevanceSeqs: unknown[] }
|
|
216
|
+
}
|
|
217
|
+
expect(body.ok).toBe(true)
|
|
218
|
+
expect(body.advisor.summary).toBeNull()
|
|
219
|
+
expect(body.advisor.decay).toBeNull()
|
|
220
|
+
expect(body.advisor.scores).toEqual([])
|
|
221
|
+
expect(body.advisor.lowRelevanceSeqs).toEqual([])
|
|
222
|
+
})
|
|
223
|
+
})
|
|
@@ -43,10 +43,11 @@ describe('standalone package contract', () => {
|
|
|
43
43
|
expect(rootManifest.dsh?.bundle?.patch).toBe('./packages/selector/cordis.patch.yml')
|
|
44
44
|
expect(rootManifest.dsh?.client?.inject).toEqual([
|
|
45
45
|
'@deepseek-ai/dsh-client-locale',
|
|
46
|
+
'@deepseek-ai/dsh-client-ui-primitives',
|
|
46
47
|
'@deepseek-ai/dsh-client-ui-slots',
|
|
47
48
|
'@deepseek-ai/dsh-client-ui-settings',
|
|
48
49
|
])
|
|
49
|
-
expect(rootManifest.engines?.dsh).toBe('>=0.1.5-
|
|
50
|
+
expect(rootManifest.engines?.dsh).toBe('>=0.1.5-rc.1 <0.2.0-0')
|
|
50
51
|
})
|
|
51
52
|
|
|
52
53
|
it('uses the community package in the one Bundle patch', () => {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Production-shaped guard for the review routes' pruner resolution.
|
|
3
|
+
*
|
|
4
|
+
* The older suite provided `toolResultPruner` at the TOP LEVEL, which is a shape
|
|
5
|
+
* production never has: `canonicalCompressionRows()` mounts every pruner inside
|
|
6
|
+
* an agent preset's isolated group, so a top-level `ctx.get('toolResultPruner')`
|
|
7
|
+
* resolves nothing and the queue route answered 503 "review pipeline
|
|
8
|
+
* unavailable" for every request while the pipeline itself ran normally.
|
|
9
|
+
*
|
|
10
|
+
* These cases mount NO top-level service and publish through the review registry
|
|
11
|
+
* from a CHILD fiber instead — the path the fix relies on.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Context } from '@deepseek-ai/cordis'
|
|
15
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
16
|
+
import { apply } from '../src/index.ts'
|
|
17
|
+
import { registerReviewPruner, type ReviewPrunerFace } from '../src/runtime/tokenpilot/review-registry.ts'
|
|
18
|
+
|
|
19
|
+
const QUEUE_ROUTE = '/api/dsh-context-compression-improved/review-queue'
|
|
20
|
+
|
|
21
|
+
interface RegisteredRoute {
|
|
22
|
+
kind: string
|
|
23
|
+
path: string
|
|
24
|
+
handler: (req: unknown, res: unknown) => unknown
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let ctx: Context | undefined
|
|
28
|
+
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
await ctx?.fiber.dispose()
|
|
31
|
+
ctx = undefined
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const settle = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 20))
|
|
35
|
+
|
|
36
|
+
/** A pruner face carrying one pending proposal for session `s1`. */
|
|
37
|
+
const SAMPLE = {
|
|
38
|
+
id: 'abc123def456',
|
|
39
|
+
kind: 'read-state',
|
|
40
|
+
items: [{ seq: 6, kind: 'read-state', component: 'history', tokensBefore: 2401, tokensAfter: 134 }],
|
|
41
|
+
benefit: { recoveredTokens: 400, penaltyTokens: 900, paybackTurns: 2.25 },
|
|
42
|
+
enqueuedTurn: 3,
|
|
43
|
+
lastTurnIndex: 3,
|
|
44
|
+
} as const
|
|
45
|
+
|
|
46
|
+
function samplePruner(): ReviewPrunerFace {
|
|
47
|
+
return {
|
|
48
|
+
listReviewProposals: (session: unknown) => ((session as { id?: string }).id === 's1' ? [SAMPLE] : []),
|
|
49
|
+
listAllReviewProposals: () => [{ sessionId: 's1', proposals: [SAMPLE] }],
|
|
50
|
+
reviewSummary: () => ({ autoApplied: 2, reviewApplied: 1, expired: 3, voided: 0 }),
|
|
51
|
+
decideReviewProposal: (_session: unknown, proposalId: string) => (proposalId === SAMPLE.id
|
|
52
|
+
? { ok: true }
|
|
53
|
+
: { ok: false, reason: 'unknown-proposal' }),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Mount the fake web server the routes register against. */
|
|
58
|
+
async function mountWebServer(runtime: Context, routes: RegisteredRoute[]): Promise<void> {
|
|
59
|
+
await runtime.plugin({
|
|
60
|
+
name: 'fake-webserver',
|
|
61
|
+
apply(webCtx) {
|
|
62
|
+
webCtx.provide('webServer', {
|
|
63
|
+
tables: { exact: new Map<string, RegisteredRoute>() },
|
|
64
|
+
register(this: { tables: { exact: Map<string, RegisteredRoute> } }, route: RegisteredRoute) {
|
|
65
|
+
const table = this.tables.exact
|
|
66
|
+
if (table.has(route.path)) throw new Error(`webserver: duplicate ${route.path}`)
|
|
67
|
+
table.set(route.path, route)
|
|
68
|
+
routes.push(route)
|
|
69
|
+
return () => {}
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Boot the host plugin with both routes opted in, as the Bundle row does. */
|
|
77
|
+
async function bootWithRoutes(): Promise<RegisteredRoute[]> {
|
|
78
|
+
const routes: RegisteredRoute[] = []
|
|
79
|
+
ctx = new Context()
|
|
80
|
+
await mountWebServer(ctx, routes)
|
|
81
|
+
apply(ctx, { estimatorCatalogRoute: true, reviewQueueRoute: true })
|
|
82
|
+
await settle()
|
|
83
|
+
return routes
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function readQueue(routes: RegisteredRoute[]): Promise<{ status: number, body: string }> {
|
|
87
|
+
const route = routes.find(candidate => candidate.path === QUEUE_ROUTE)
|
|
88
|
+
expect(route, `route ${QUEUE_ROUTE} must be registered`).toBeDefined()
|
|
89
|
+
// `exactOptionalPropertyTypes` forbids assigning an explicit `undefined` to an
|
|
90
|
+
// optional property, so the widened type must spell it out.
|
|
91
|
+
const captured: { status?: number, body?: string | undefined } = {}
|
|
92
|
+
const res = {
|
|
93
|
+
writeHead(code: number) { captured.status = code },
|
|
94
|
+
end(body?: string) { captured.body = body },
|
|
95
|
+
}
|
|
96
|
+
await route!.handler({ url: QUEUE_ROUTE }, res)
|
|
97
|
+
return { status: captured.status ?? 0, body: captured.body ?? '' }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe('review routes resolve a preset-scoped pruner', () => {
|
|
101
|
+
it('answers 503 while no pruner has been published', async () => {
|
|
102
|
+
const routes = await bootWithRoutes()
|
|
103
|
+
const { status, body } = await readQueue(routes)
|
|
104
|
+
expect(status).toBe(503)
|
|
105
|
+
expect(body).toContain('review pipeline unavailable')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('reaches a pruner published from a CHILD fiber with no top-level service', async () => {
|
|
109
|
+
const routes = await bootWithRoutes()
|
|
110
|
+
// Production shape: the pruner is mounted in a preset's isolated group.
|
|
111
|
+
await ctx!.plugin({
|
|
112
|
+
name: 'preset-scoped-pruner',
|
|
113
|
+
apply(childCtx) {
|
|
114
|
+
childCtx.effect(() => registerReviewPruner(samplePruner()), 'test-preset-pruner')
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
await settle()
|
|
118
|
+
|
|
119
|
+
const { status, body } = await readQueue(routes)
|
|
120
|
+
expect(status).toBe(200)
|
|
121
|
+
const parsed = JSON.parse(body) as { ok: boolean, total: number, pending: readonly { id: string }[] }
|
|
122
|
+
expect(parsed.ok).toBe(true)
|
|
123
|
+
expect(parsed.total).toBe(1)
|
|
124
|
+
expect(parsed.pending[0]?.id).toBe(SAMPLE.id)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('drops back to 503 once the publishing fiber disposes', async () => {
|
|
128
|
+
const routes = await bootWithRoutes()
|
|
129
|
+
const fork = await ctx!.plugin({
|
|
130
|
+
name: 'preset-scoped-pruner',
|
|
131
|
+
apply(childCtx) {
|
|
132
|
+
childCtx.effect(() => registerReviewPruner(samplePruner()), 'test-preset-pruner')
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
await settle()
|
|
136
|
+
expect((await readQueue(routes)).status).toBe(200)
|
|
137
|
+
|
|
138
|
+
await fork.dispose()
|
|
139
|
+
await settle()
|
|
140
|
+
expect((await readQueue(routes)).status).toBe(503)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -31,9 +31,9 @@ describe('Conservative Adaptive interval', () => {
|
|
|
31
31
|
expectedTokenizerRevision: 'DeepSeek-V4-Pro-0813',
|
|
32
32
|
previousRequestMeasurement: exact(30_500),
|
|
33
33
|
measuredNodes: [
|
|
34
|
-
{ seq: 0, count: exact(10_000) },
|
|
35
|
-
{ seq: 2, count: exact(20_000) },
|
|
36
|
-
{ seq: 3, count: exact(500) },
|
|
34
|
+
{ seq: 0, count: exact(10_000), characterPressure: 40_000 },
|
|
35
|
+
{ seq: 2, count: exact(20_000), characterPressure: 80_000 },
|
|
36
|
+
{ seq: 3, count: exact(500), characterPressure: 2_000 },
|
|
37
37
|
],
|
|
38
38
|
})).toEqual({
|
|
39
39
|
kind: 'available',
|
|
@@ -57,7 +57,7 @@ describe('Conservative Adaptive interval', () => {
|
|
|
57
57
|
estimatorRevision: 'r1',
|
|
58
58
|
calibration: { sampleCount: 1, conservativeMarginTokens: 200 },
|
|
59
59
|
}),
|
|
60
|
-
measuredNodes: [{ seq: 0, count: exact(10_000) }],
|
|
60
|
+
measuredNodes: [{ seq: 0, count: exact(10_000), characterPressure: 40_000 }],
|
|
61
61
|
})).toMatchObject({
|
|
62
62
|
kind: 'available',
|
|
63
63
|
measurementKind: 'tokenizer-estimate',
|
|
@@ -79,7 +79,7 @@ describe('Conservative Adaptive interval', () => {
|
|
|
79
79
|
previousPromptTokens: 200,
|
|
80
80
|
expectedTokenizerRevision: 'DeepSeek-V4-Pro-0813',
|
|
81
81
|
previousRequestMeasurement: measurement,
|
|
82
|
-
measuredNodes: [{ seq: 0, count: exact(50) }],
|
|
82
|
+
measuredNodes: [{ seq: 0, count: exact(50), characterPressure: 200 }],
|
|
83
83
|
})).toEqual({ kind: 'unknown', reason })
|
|
84
84
|
})
|
|
85
85
|
|
|
@@ -90,7 +90,7 @@ describe('Conservative Adaptive interval', () => {
|
|
|
90
90
|
previousPromptTokens: 200,
|
|
91
91
|
expectedTokenizerRevision: 'DeepSeek-V4-Pro-0813',
|
|
92
92
|
previousRequestMeasurement: exact(200, 'DeepSeek-V4-Flash-0731'),
|
|
93
|
-
measuredNodes: [{ seq: 0, count: exact(50) }],
|
|
93
|
+
measuredNodes: [{ seq: 0, count: exact(50), characterPressure: 200 }],
|
|
94
94
|
})).toEqual({ kind: 'unknown', reason: 'request-tokenizer-revision-mismatch' })
|
|
95
95
|
})
|
|
96
96
|
|
|
@@ -101,7 +101,7 @@ describe('Conservative Adaptive interval', () => {
|
|
|
101
101
|
previousPromptTokens: 200,
|
|
102
102
|
expectedTokenizerRevision: 'DeepSeek-V4-Pro-0813',
|
|
103
103
|
previousRequestMeasurement: exact(200),
|
|
104
|
-
measuredNodes: [{ seq: 0, count: exact(50, 'DeepSeek-V4-Flash-0731') }],
|
|
104
|
+
measuredNodes: [{ seq: 0, count: exact(50, 'DeepSeek-V4-Flash-0731'), characterPressure: 200 }],
|
|
105
105
|
})).toEqual({ kind: 'unknown', reason: 'exact-prefix-tokenizer-revision-mismatch' })
|
|
106
106
|
})
|
|
107
107
|
|