dsh-fast-mode 1.5.28 → 1.5.30

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.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.30(把 1.5.28 的修复拿真实流量做 A/B:同一份会话,同一串事件,两个版本)
4
+
5
+ - **新增 `bench/replay.mjs`**:把一份留存会话的事件流原样喂给插件自己的钩子(`session/event` + `agent/pre-step`),数出「这一版会发几条提醒、落在第几步」。会话 id 是合成的,`metrics`/`benchmarkFile` 关掉,不碰 `~/.dsh` 状态。
6
+ - **量化修复效果**:轮内压缩时宿主用**同一个 turn 号**重入该轮,而这次重入不落盘——所以真实会话记录里看不到它,只看得到「同一轮两条提醒」。`--simulate-reentry` 在每次 `compaction/start` 后按当前 turn 号补发一条 `turn/start`,把这个假设显式化:
7
+ - 1.5.27 的接线(`noteTurn` 不知道 turn 号):1529 个合格轮里 **940 轮**会被发两次以上;
8
+ - 1.5.28 的接线:同样 1529 轮 **0 轮**;
9
+ - 单会话对照更直观:`session-1ee3a310` 111 个合格轮,旧版 82 轮重复(最长的 121 号轮在第 4/13/38/65/102 步各发一次),新版 0 轮。
10
+ - **真实流量侧读数不变**:`bench/turns.py` 近 72 小时 651 个合格轮里 120 轮真收到两条、其中 110 轮两次之间夹着压缩,与重入假设一致。
11
+ - **没有行为变更**:只加脚本、改文档与版本号;`src/` 与 1.5.29 逐字相同。
12
+
13
+ ## 1.5.29(1.5.28 的补丁:把 `bench/turns.py` 真的放进包里)
14
+
15
+ - `package.json` 的 `files` 是一份白名单,1.5.28 新增的 `bench/turns.py` 没进去:`npm pack dsh-fast-mode@1.5.28` 里没有这个脚本,而 `CHANGELOG` 和 `bench/README.md` 都在教读者运行它。1.5.29 只改白名单 + 版本号,**代码与 1.5.28 逐字相同**。
16
+ - 复核方式:`npm pack dsh-fast-mode@1.5.29 --silent` 解包后应同时看到 `package/bench/turns.py` 与 `package/index.js` 里的 `VERSION = '1.5.29'`。
17
+
3
18
  ## 1.5.28(稳定性:压缩后同一轮里不再重复发同一条提醒)
4
19
 
5
20
  - **先量后改**:新增 `bench/turns.py`,把留存会话按 `turn/start` 切成轮,统计「够长却没收到提醒」「同一轮收到两次以上」「多轮会话里后续轮是否重新上膛」。72 小时窗口 68 个会话 / 1446 轮:647 个合格轮**646 个收到**提醒(99.8 %)、后续轮重新上膛 599/600;但**同一轮收到两次的有 114 轮**,其中 **105 轮(92 %)两次之间夹着一次上下文压缩**。
package/bench/README.md CHANGED
@@ -136,6 +136,18 @@ node bench/verify-arm-reminders.mjs --arms # 臂 x 场景的提
136
136
 
137
137
  采集方式:跑这一格时把臂补丁另存为 `<tag>.arm.yml` 并加 `debug: true` + `debugFile: <runs>/<tag>.debug`,跑完从调试日志里按 `step=N ... turnSteps=M ... directive=kind` 抽行,然后删掉日志与补丁。`debug` 只在步边界写一行已经格式化好的文本,不参与任何调度判断、不额外发请求;日志写在 run 目录之外,所以 `leak_scan`/session id 统计不受影响。老 result 行没有这些字段,读取方一律用 `.get()`,缺字段等于「没有证据」,不是「零提醒」。
138
138
 
139
+ ### 同一份会话,两个版本各会发几条提醒
140
+
141
+ `bench/replay.mjs` 把留存会话的事件流喂回插件自己的钩子(`session/event` + `agent/pre-step`),输出每一轮的步数、压缩次数、提醒条数与落点。会话 id 是合成的,`metrics`/`benchmarkFile` 关掉,不写任何 `~/.dsh` 状态。
142
+
143
+ ```bash
144
+ node bench/replay.mjs ~/.dsh/sessions/<slug>/session-<uuid>/session.v4.jsonl.zstd
145
+ node bench/replay.mjs <transcript> --json
146
+ node bench/replay.mjs <transcript> --simulate-reentry # 每次压缩后按当前 turn 号重入
147
+ ```
148
+
149
+ `--simulate-reentry` 补的是会话记录里**不存在**的那条事件:宿主压缩后重入同一轮时用同一个 turn 号再发一次 `turn/start`,而这次重入不落盘。加上它以后,1.5.27 与 1.5.28 的差别就能在同一份数据上量出来(1529 个合格轮:940 → 0)。
150
+
139
151
  ### 提醒是否每轮重新上膛、且每轮只发一次
140
152
 
141
153
  `bench/turns.py` 读留存会话,按 `turn/start` 切轮,输出:轮数/会话、合格轮(默认 ≥ 4 步)、**没收到提醒**的合格轮、**收到两次以上**的轮、多轮会话里后续轮的重新上膛率。只有 `user/message` / `agent/inbox/spliced` / `system/message` 里的文案算投递,模型在自己正文里复述的不算。
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Replay one retained transcript through the plugin's own hooks.
4
+ *
5
+ * The reminder is delivered at a step boundary, so a transcript could only ever
6
+ * count *delivered* reminders, never the ones the plugin would have sent under a
7
+ * different version. This replays the recorded event stream into `apply()` with a
8
+ * stand-in context, counts what the scheduler decides to emit, and prints one
9
+ * line per turn: steps, reminders, and the step each reminder landed on.
10
+ *
11
+ * That is how the 1.5.28 latch fix is checked against real traffic instead of
12
+ * synthetic test events: same transcript, same hook order, two versions of
13
+ * `noteTurn`, two counts.
14
+ *
15
+ * Usage:
16
+ * node bench/replay.mjs ~/.dsh/sessions/<slug>/session-<uuid>/session.v4.jsonl.zstd
17
+ * node bench/replay.mjs <transcript> --min-steps 4 --json
18
+ * node bench/replay.mjs <transcript> --simulate-reentry
19
+ *
20
+ * `--simulate-reentry` adds the event the transcript cannot show: after each
21
+ * `compaction/start` it re-fires `turn/start` with the same turn number, which is
22
+ * the hypothesis for the doubled reminders (the harness re-enters a turn it
23
+ * compacted mid-flight, and that re-entry is not persisted). With the event
24
+ * added, the pre-1.5.28 latch resets and the same turn is reminded twice; with
25
+ * the turn guard it is not.
26
+ *
27
+ * The session id is synthetic, so the run never touches `~/.dsh` state; metrics
28
+ * and benchmark output are switched off in the config passed to `apply()`.
29
+ */
30
+ import { execFileSync } from 'node:child_process'
31
+ import { readFileSync } from 'node:fs'
32
+
33
+ import { apply } from '../index.js'
34
+
35
+ const NEEDLE = 'prose is now the cost'
36
+ const CONFIG = { enabled: true, gear: 'auto', outputDiscipline: 'late', metrics: false, benchmarkFile: '' }
37
+
38
+ /** Minimal stand-in for the Cordis context: handlers plus a silent logger. */
39
+ function fakeContext() {
40
+ const handlers = new Map()
41
+ return {
42
+ handlers,
43
+ on(event, handler) {
44
+ handlers.set(event, handler)
45
+ return () => handlers.delete(event)
46
+ },
47
+ inject: () => undefined,
48
+ get: () => undefined,
49
+ logger: { info: () => {} },
50
+ fire(event, ...args) {
51
+ const handler = handlers.get(event)
52
+ if (!handler) return undefined
53
+ return handler(...args)
54
+ },
55
+ }
56
+ }
57
+
58
+ /** Decide the way the harness does: enter, and keep whatever the plugin added. */
59
+ const preStep = (ctx, agentId, step) => {
60
+ const decision = {
61
+ kind: 'enter',
62
+ messages: [{ id: 'sys', role: 'system', content: [] }, { id: `last-${step}`, role: 'assistant', content: [] }],
63
+ }
64
+ return ctx.fire('agent/pre-step', { agent: { id: agentId }, messages: [] }, async () => decision)
65
+ }
66
+
67
+ const events = path => {
68
+ const raw = path.endsWith('.zstd')
69
+ ? execFileSync('zstd', ['-dc', path], { maxBuffer: 1 << 30 })
70
+ : readFileSync(path)
71
+ const out = []
72
+ for (const line of raw.toString('utf8').split('\n')) {
73
+ if (!line.trim()) continue
74
+ try {
75
+ out.push(JSON.parse(line))
76
+ } catch {
77
+ // a torn tail line is not fatal: the replay is a census, not a forensic read
78
+ }
79
+ }
80
+ return out
81
+ }
82
+
83
+ const reminderText = notice => {
84
+ const text = notice?.content?.[0]?.text ?? ''
85
+ return text.includes(NEEDLE)
86
+ }
87
+
88
+ async function main() {
89
+ const argv = process.argv.slice(2)
90
+ const path = argv.find(arg => !arg.startsWith('--'))
91
+ if (!path) throw new Error('usage: node bench/replay.mjs <transcript.jsonl.zstd> [--min-steps N] [--json]')
92
+ const minSteps = Number(argv[argv.indexOf('--min-steps') + 1]) || 4
93
+ const asJson = argv.includes('--json')
94
+ const reentry = argv.includes('--simulate-reentry')
95
+
96
+ const ctx = fakeContext()
97
+ apply(ctx, CONFIG)
98
+ const session = { id: 'replay' }
99
+ const turns = []
100
+ let cur = { steps: 0, reminders: [], compactions: 0, turnNo: 0 }
101
+ for (const event of events(path)) {
102
+ const type = event.type
103
+ const data = event.data ?? {}
104
+ if (type === 'turn/start') {
105
+ if (cur.steps || cur.reminders.length) turns.push(cur)
106
+ cur = { steps: 0, reminders: [], compactions: 0, turnNo: data.turn ?? turns.length + 1 }
107
+ await ctx.fire('session/event', session, event)
108
+ } else if (type === 'step/start') {
109
+ cur.steps += 1
110
+ await ctx.fire('session/event', session, event)
111
+ const decision = await preStep(ctx, session.id, cur.steps)
112
+ const added = (decision?.messages ?? []).filter(message => message?.source?.form === 'notice' && reminderText(message))
113
+ if (added.length) cur.reminders.push(cur.steps)
114
+ } else if (type === 'compaction/start') {
115
+ cur.compactions += 1
116
+ await ctx.fire('session/event', session, event)
117
+ if (reentry) {
118
+ // Same turn number, delivered the way the harness delivers it: the event
119
+ // exists in the hook stream but never reaches the transcript.
120
+ await ctx.fire('session/event', session, { type: 'turn/start', turn: cur.turnNo, time: event.time })
121
+ }
122
+ } else if (type === 'compaction/end') {
123
+ await ctx.fire('session/event', session, event)
124
+ } else if (type === 'assistant/message' || type === 'tool/call' || type === 'tool/result') {
125
+ await ctx.fire('session/event', session, event)
126
+ }
127
+ }
128
+ if (cur.steps || cur.reminders.length) turns.push(cur)
129
+
130
+ const rows = turns.map((turn, index) => ({
131
+ turn: index + 1,
132
+ steps: turn.steps,
133
+ compactions: turn.compactions,
134
+ reminders: turn.reminders.length,
135
+ at: turn.reminders,
136
+ }))
137
+ if (asJson) {
138
+ console.log(JSON.stringify({ path, minSteps, simulateReentry: reentry, turns: rows }, null, 2))
139
+ return
140
+ }
141
+ const qualifying = rows.filter(row => row.steps >= minSteps)
142
+ const doubled = qualifying.filter(row => row.reminders > 1)
143
+ console.log(`${path}`)
144
+ for (const row of qualifying) {
145
+ console.log(` turn ${row.turn}: steps ${row.steps} compactions ${row.compactions} `
146
+ + `reminders ${row.reminders}${row.at.length ? ` at ${row.at.join(',')}` : ''}`)
147
+ }
148
+ console.log(`qualifying turns (>= ${minSteps} steps) ${qualifying.length} doubled ${doubled.length}`)
149
+ }
150
+
151
+ main().catch(error => {
152
+ console.error(error.message)
153
+ process.exit(1)
154
+ })
package/bench/turns.py ADDED
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env python3
2
+ """Does the late-discipline reminder re-arm per turn, and only once per turn?
3
+
4
+ The reminder is a once-per-turn lever: `disciplineSent` latches it so a long turn
5
+ is told to stop narrating exactly once, and `noteTurn` clears the latch so the
6
+ next request in the same session is told again. Both halves are invisible in the
7
+ wall-clock numbers — a turn that never got the reminder looks like a turn that
8
+ ignored it, and a turn that got it twice looks like a model that keeps forgetting.
9
+
10
+ This reads retained transcripts, segments them on `turn/start`, and reports:
11
+
12
+ - turns per session, steps per turn;
13
+ - turns long enough to qualify (>= 4 steps) that got **no** reminder;
14
+ - turns that got **two or more** reminders (the latch leaking);
15
+ - re-arm rate: in multi-turn sessions, the share of later qualifying turns that
16
+ were reminded again.
17
+
18
+ Usage:
19
+ python3 bench/turns.py # suite sessions
20
+ python3 bench/turns.py --limit 100 # first 100 transcripts
21
+ python3 bench/turns.py --min-steps 4 --json
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import collections
28
+ import glob
29
+ import json
30
+ import os
31
+ import subprocess
32
+ import time
33
+
34
+ SESS = os.path.expanduser('~/.dsh/sessions')
35
+ NEEDLE = 'this turn is long enough that prose'
36
+ # A delivered directive is a message part; the model quoting it back inside its
37
+ # own prose is an echo, so assistant-authored text never counts as delivery.
38
+ PLUGIN_ROLES = {'user/message', 'agent/inbox/spliced', 'system/message'}
39
+
40
+
41
+ def transcripts(pattern, limit, exclude_arm=('off',)):
42
+ """Session transcripts whose run directory (the slug) is not one of `exclude_arm`.
43
+
44
+ The slug mirrors the run directory, so `...-suite-D2G2-ledger-p1-def15--`
45
+ identifies the arm without re-reading the YAML.
46
+ """
47
+ out = []
48
+ for d in sorted(glob.glob(os.path.join(SESS, pattern))):
49
+ name = os.path.basename(d)
50
+ if any(name.endswith(f'-{arm}--') for arm in exclude_arm):
51
+ continue
52
+ out.extend(glob.glob(os.path.join(d, 'session-*', 'session.v4.jsonl.zstd')))
53
+ return out[:limit] if limit else out
54
+
55
+
56
+ def text_of(data):
57
+ msg = data.get('message') or data
58
+ parts = msg.get('content') if isinstance(msg, dict) else None
59
+ if not isinstance(parts, list):
60
+ return ''
61
+ return ' '.join(p.get('text') or '' for p in parts
62
+ if isinstance(p, dict) and p.get('type') == 'text')
63
+
64
+
65
+ def scan(path):
66
+ """Return (started_ms, turns) for one transcript, or None if unreadable."""
67
+ p = subprocess.run(['zstd', '-dc', path], capture_output=True)
68
+ if p.returncode != 0:
69
+ return None
70
+ started = 0
71
+ turns = []
72
+ cur = {'steps': 0, 'reminders': 0, 'reminderSeqs': [], 'compactions': [],
73
+ 'reminderSteps': []}
74
+ for line in p.stdout.decode('utf-8', 'replace').splitlines():
75
+ if ('turn/start' not in line and 'step/start' not in line
76
+ and 'Auto schedule' not in line and 'compaction/start' not in line):
77
+ continue
78
+ try:
79
+ o = json.loads(line)
80
+ except Exception: # noqa: BLE001 - a torn line is not fatal
81
+ continue
82
+ kind = o.get('type')
83
+ data = o.get('data') or {}
84
+ if not started and isinstance(o.get('time'), int):
85
+ started = o['time']
86
+ if kind == 'turn/start':
87
+ if cur['steps'] or cur['reminders']:
88
+ turns.append(cur)
89
+ cur = {'steps': 0, 'reminders': 0, 'reminderSeqs': [], 'compactions': [],
90
+ 'reminderSteps': []}
91
+ elif kind == 'step/start':
92
+ cur['steps'] += 1
93
+ elif kind == 'compaction/start':
94
+ cur['compactions'].append(o.get('seq'))
95
+ elif kind in PLUGIN_ROLES and NEEDLE in text_of(data):
96
+ cur['reminders'] += 1
97
+ cur['reminderSeqs'].append(o.get('seq'))
98
+ cur['reminderSteps'].append(cur['steps'])
99
+ if cur['steps'] or cur['reminders']:
100
+ turns.append(cur)
101
+ return started, turns
102
+
103
+
104
+ def main():
105
+ ap = argparse.ArgumentParser()
106
+ ap.add_argument('--limit', type=int, default=0, help='transcripts; 0 = all')
107
+ ap.add_argument('--dir', default='--*dsh-runs-suite-*')
108
+ ap.add_argument('--min-steps', type=int, default=4)
109
+ ap.add_argument('--json', action='store_true')
110
+ ap.add_argument('--exclude-arm', action='append', default=[],
111
+ help='skip sessions whose run directory ends in this arm (default: off)')
112
+ ap.add_argument('--explain-doubles', action='store_true',
113
+ help='list every turn that got two reminders, with seqs and compactions')
114
+ ap.add_argument('--since-hours', type=float, default=0,
115
+ help='only sessions started within this many hours')
116
+ a = ap.parse_args()
117
+
118
+ sessions = turns_total = multi = 0
119
+ qualifying = reminded = 0
120
+ misses = doubled = doubled_compacted = 0
121
+ later_qualifying = later_reminded = 0
122
+ per_session_turns = collections.Counter()
123
+ entry_steps = collections.Counter()
124
+ offenders = []
125
+ cutoff = (time.time() - a.since_hours * 3600) * 1000 if a.since_hours else 0
126
+ skipped_old = 0
127
+ for f in transcripts(a.dir, a.limit, tuple(a.exclude_arm or ['off'])):
128
+ r = scan(f)
129
+ if not r:
130
+ continue
131
+ started, turns = r
132
+ if cutoff and started and started < cutoff:
133
+ skipped_old += 1
134
+ continue
135
+ if not turns:
136
+ continue
137
+ sessions += 1
138
+ turns_total += len(turns)
139
+ per_session_turns[len(turns)] += 1
140
+ if len(turns) > 1:
141
+ multi += 1
142
+ for i, t in enumerate(turns):
143
+ if t['steps'] >= a.min_steps:
144
+ qualifying += 1
145
+ if t['reminders']:
146
+ reminded += 1
147
+ else:
148
+ misses += 1
149
+ if i > 0:
150
+ later_qualifying += 1
151
+ later_reminded += 1 if t['reminders'] else 0
152
+ if t['reminders']:
153
+ entry_steps[t['reminderSteps'][0]] += 1
154
+ if t['reminders'] > 1:
155
+ doubled += 1
156
+ seqs = t['reminderSeqs']
157
+ if any(c is not None and seqs and seqs[0] < c < seqs[-1] for c in t['compactions']):
158
+ doubled_compacted += 1
159
+ if a.explain_doubles:
160
+ offenders.append({
161
+ 'session': os.path.basename(os.path.dirname(os.path.dirname(f))),
162
+ 'turn': i + 1,
163
+ 'steps': t['steps'],
164
+ 'reminderSeqs': seqs,
165
+ 'reminderSteps': t['reminderSteps'],
166
+ 'compactions': t['compactions'],
167
+ })
168
+
169
+ out = {
170
+ 'sessions': sessions,
171
+ 'turns': turns_total,
172
+ 'multi_turn_sessions': multi,
173
+ 'turns_per_session': dict(sorted(per_session_turns.items())),
174
+ 'min_steps': a.min_steps,
175
+ 'qualifying_turns': qualifying,
176
+ 'reminded': reminded,
177
+ 'missed': misses,
178
+ 'doubled': doubled,
179
+ 'doubled_with_compaction_between': doubled_compacted,
180
+ 'entry_steps': dict(sorted(entry_steps.items())),
181
+ 'later_qualifying_turns': later_qualifying,
182
+ 'later_reminded': later_reminded,
183
+ 'skipped_old': skipped_old,
184
+ 'since_hours': a.since_hours,
185
+ }
186
+ if a.json:
187
+ print(json.dumps(out, ensure_ascii=False, indent=2))
188
+ return
189
+ print(f"sessions {sessions} turns {turns_total} multi-turn {multi} "
190
+ f"({100 * multi / max(sessions, 1):.0f} %)")
191
+ print(f"turns per session: {dict(sorted(per_session_turns.items()))}")
192
+ print(f"qualifying turns (>= {a.min_steps} steps) {qualifying} "
193
+ f"reminded {reminded} ({100 * reminded / max(qualifying, 1):.0f} %) "
194
+ f"missed {misses} doubled {doubled} "
195
+ f"(with a compaction between: {doubled_compacted})")
196
+ if entry_steps:
197
+ print(f"first reminder landed at step: {dict(sorted(entry_steps.items()))}")
198
+ if a.explain_doubles and offenders:
199
+ print('doubled turns:')
200
+ for o in offenders[:20]:
201
+ print(f" {o['session']} turn {o['turn']} steps {o['steps']} "
202
+ f"reminders at steps {o['reminderSteps']} seqs {o['reminderSeqs']} "
203
+ f"compactions {o['compactions']}")
204
+ print(f"later turns in multi-turn sessions: qualifying {later_qualifying} "
205
+ f"reminded {later_reminded} "
206
+ f"({100 * later_reminded / max(later_qualifying, 1):.0f} %)")
207
+
208
+
209
+ if __name__ == '__main__':
210
+ main()
package/index.js CHANGED
@@ -97,7 +97,7 @@ const TAG = '[fast-mode]'
97
97
  * Shipped identity of this build. Traced into the debug log so a stale install is
98
98
  * visible at a glance, and pinned by a test against `package.json`.
99
99
  */
100
- export const VERSION = '1.5.28'
100
+ export const VERSION = '1.5.30'
101
101
  const GLOBAL_AGENT = '__global__'
102
102
 
103
103
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-fast-mode",
3
- "version": "1.5.28",
3
+ "version": "1.5.30",
4
4
  "description": "DeepSeek Harness fast mode: same thinking effort, fewer round trips. An adaptive parallel tool fan-out, output discipline that never trims the answer, and a cancellable stall guard. Never changes reasoning effort, the model, or the request body. Provider-agnostic, one dependency (@deepseek-ai/schemastery).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -44,6 +44,8 @@
44
44
  "bench/reminders.py",
45
45
  "bench/patchshare.py",
46
46
  "bench/ablate.py",
47
+ "bench/turns.py",
48
+ "bench/replay.mjs",
47
49
  "docs/",
48
50
  "LICENSE"
49
51
  ],