dsh-fast-mode 1.5.34 → 1.6.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/index.js CHANGED
@@ -85,6 +85,7 @@ import { createRecorder } from './src/metrics.js'
85
85
  import { readOutputTokens } from './src/usage.js'
86
86
  import { configOverridesFor } from './src/state.js'
87
87
  import { buildShellParallelTool } from './src/parallel.js'
88
+ import { beginStep as lazyBeginStep, createLazyWatch, DIRECTIVE_SUMMARY as LAZY_SUMMARY, NUDGE_SUMMARY as LAZY_NUDGE_SUMMARY, noteReasoning as lazyNoteReasoning, noteToolResult as lazyNoteToolResult, planEffort as lazyPlanEffort } from './src/lazy.js'
88
89
 
89
90
  /**
90
91
  * The settings-page schema. Re-exported from here because the Loader reads
@@ -98,7 +99,7 @@ const TAG = '[fast-mode]'
98
99
  * Shipped identity of this build. Traced into the debug log so a stale install is
99
100
  * visible at a glance, and pinned by a test against `package.json`.
100
101
  */
101
- export const VERSION = '1.5.34'
102
+ export const VERSION = '1.6.0'
102
103
  const GLOBAL_AGENT = '__global__'
103
104
 
104
105
  /**
@@ -140,6 +141,26 @@ function latestUserText(messages) {
140
141
  return ''
141
142
  }
142
143
 
144
+ /**
145
+ * How many messages in this turn came from the human. Plugin notices are also
146
+ * `role: 'user'`, so a bare role count would move every time this plugin spoke
147
+ * and every turn would look new.
148
+ *
149
+ * @param {unknown} messages
150
+ * @returns {number}
151
+ */
152
+ function claimedUserCount(messages) {
153
+ if (!Array.isArray(messages)) return 0
154
+ let count = 0
155
+ for (const m of messages) {
156
+ if (!m || m.role !== 'user') continue
157
+ const source = m.source
158
+ if (source && typeof source === 'object' && source.form === 'notice') continue
159
+ count += 1
160
+ }
161
+ return count
162
+ }
163
+
143
164
  function insertAfterClaimed(messages, claimed, notices) {
144
165
  const list = Array.isArray(messages) ? messages : []
145
166
  if (notices.length === 0) return list
@@ -189,6 +210,18 @@ function routeOf(resolved) {
189
210
  return typeof provider === 'string' && typeof model === 'string' ? `${provider}/${model}` : 'unknown'
190
211
  }
191
212
 
213
+ /**
214
+ * The effort label a journal row carries: the resolved value when it is a
215
+ * string, otherwise `undefined`. Kept separate from the value itself so a route
216
+ * that hands back a non-string can never be stringified into a fake rung.
217
+ *
218
+ * @param {unknown} value
219
+ * @returns {string | undefined}
220
+ */
221
+ function summaryEffort(value) {
222
+ return typeof value === 'string' ? value : undefined
223
+ }
224
+
192
225
  /**
193
226
  * Log, once per load, whether this entry's namespace is actually served by the
194
227
  * `settings` service — the fact the chip's state depends on.
@@ -396,6 +429,11 @@ export function apply(ctx, rawConfig) {
396
429
  // The scheduler's memory, one entry per conversation: what has been tried, how
397
430
  // long calls have been taking, and how many steps went by without a new result.
398
431
  const auto = createAutoState()
432
+ const lazyWatch = createLazyWatch()
433
+ // Which human turn each session is in. `agent/pre-step` carries no turn number,
434
+ // so the count of claimed (non-plugin) user messages is the boundary: it moves
435
+ // exactly when the user speaks again.
436
+ const lazyTurnKey = new Map()
399
437
  /**
400
438
  * Live stall ceilings per conversation, as computed by the scheduler.
401
439
  * Bounded like `compacted`: a long-lived host process sees thousands of
@@ -481,13 +519,27 @@ export function apply(ctx, rawConfig) {
481
519
  // `attemptId` whose prefix is the session. They carry no turn or step, so the
482
520
  // recorder attributes them to the step that session has open.
483
521
  ctx.on('agent/assistant-stream', (payload) => {
484
- if (!metrics.enabled) return
485
522
  const frame = payload && payload.frame
486
523
  if (!frame || frame.type !== 'chunk' || typeof frame.attemptId !== 'string') return
487
524
  const cut = frame.attemptId.lastIndexOf(':')
488
525
  if (cut <= 0) return
526
+ const sid = frame.attemptId.slice(0, cut)
489
527
  try {
490
- metrics.record({ id: frame.attemptId.slice(0, cut) }, {
528
+ // Lazy mode reads the same stream, and must keep reading when the journal
529
+ // is off: the effort step-down is decided from how much the route actually
530
+ // streamed, not from a usage field this route reports as zero.
531
+ if (lazyWatch.runs.has(sid)) {
532
+ const chunk = frame.chunk
533
+ const kind = chunk && typeof chunk === 'object' ? chunk.type : undefined
534
+ if (kind === 'reasoning-delta') {
535
+ const text = typeof chunk.text === 'string'
536
+ ? chunk.text
537
+ : typeof chunk.delta === 'string' ? chunk.delta : ''
538
+ lazyNoteReasoning(lazyWatch, sid, text.length)
539
+ }
540
+ }
541
+ if (!metrics.enabled) return
542
+ metrics.record({ id: sid }, {
491
543
  type: 'assistant/chunk',
492
544
  time: frame.time,
493
545
  data: { chunk: frame.chunk },
@@ -568,6 +620,25 @@ export function apply(ctx, rawConfig) {
568
620
  }
569
621
  }
570
622
  }
623
+ if (cfg.lazy === true) {
624
+ // Lazy mode's two prompt-side mechanisms: the ladder text on the first
625
+ // step of a turn, and at most two nudges when a turn's streamed reasoning
626
+ // passes its threshold. The tail of a long turn is where over-thinking
627
+ // costs the most and where a reminder changes the next step rather than
628
+ // the one already paid for.
629
+ const key = `${sid}\u0000${claimedUserCount(payload?.messages)}`
630
+ const fresh = lazyTurnKey.get(sid) !== key
631
+ if (fresh) {
632
+ lazyTurnKey.set(sid, key)
633
+ trim(lazyTurnKey, 200)
634
+ }
635
+ const plan = lazyBeginStep(lazyWatch, sid, { firstStep: fresh })
636
+ if (plan && plan.directive) notices.push(createNoticeMessage(plan.directive, LAZY_SUMMARY))
637
+ if (plan && plan.nudge) notices.push(createNoticeMessage(plan.nudge, LAZY_NUDGE_SUMMARY))
638
+ if (plan && initial.debug) {
639
+ appendDebug(initial.debugFile, `lazy v${VERSION}: turn=${key.split('\u0000').pop()} directive=${plan.directive ? 'yes' : 'no'} nudge=${plan.nudge ? 'yes' : 'no'}`)
640
+ }
641
+ }
571
642
  if (cfg.workspaceMap === true) {
572
643
  // One inventory per session, before the model spends a step finding out
573
644
  // what is in the directory (35.7 % of the recorded calls were exactly
@@ -714,6 +785,9 @@ export function apply(ctx, rawConfig) {
714
785
  // The scheduler reads the same observation: a call that returns the same
715
786
  // bytes for the same arguments is the raw material of the long tail.
716
787
  noteTool(auto, agent, observation)
788
+ // An error result is the one signal that the model may need more room to
789
+ // think, so it cancels any pending effort step-down for a couple of steps.
790
+ lazyNoteToolResult(lazyWatch, agent, observation)
717
791
  }
718
792
  } catch {
719
793
  // Observation is best-effort. A failure here must not affect the tool
@@ -729,30 +803,44 @@ export function apply(ctx, rawConfig) {
729
803
  const sid = agentKey(payload?.agent)
730
804
  const agent = sid === GLOBAL_AGENT ? undefined : sid
731
805
  const cfg = configFor(agent)
732
- const effort = resolved?.reasoningEffort
806
+ const baseEffort = resolved?.reasoningEffort
733
807
  const route = routeOf(resolved)
734
- const label = effort === undefined ? undefined : String(effort)
735
-
736
- // Fast mode does not touch the thinking budget, on purpose. The route and the
737
- // effort are the user's contract with the model: a mode that quietly thinks
738
- // less produces a different answer, not a faster one, and the measured public
739
- // benchmarks showed the effort ladder bought no wall-clock on this route
740
- // anyway (0 reasoning tokens in every arm). What is left is the part that
741
- // makes the same thinking finish sooner — wider steps, bounded calls, and a
742
- // breaker for the sessions that only look busy.
808
+
809
+ // The rule this plugin kept for five releases was "never touch the thinking
810
+ // budget", and it is still the rule unless the user turns on the one switch
811
+ // that says otherwise. See `src/lazy.js`: it lowers effort only after a step
812
+ // visibly over-thought, never below the user's own floor, twice a turn at
813
+ // most, and it restores the resolved value on the very next request — the
814
+ // 0.9.x damage was that a rewrite outlived the turn that justified it.
743
815
  if (!cfg.enabled) {
744
- metrics.label(sid, { mode: 'off', gear: 'off', effort: label, route })
816
+ metrics.label(sid, { mode: 'off', gear: 'off', effort: summaryEffort(baseEffort), effortBase: null, route })
745
817
  return resolved
746
818
  }
747
819
 
748
820
  const schedule = cfg.gear === AUTO ? autoSchedule(auto, sid, { emit: false }) : undefined
749
821
  checkParallelCap(cfg, schedule?.parallel)
750
- metrics.label(sid, { mode: 'on', gear: cfg.gear, effort: label, route })
822
+ const planned = cfg.lazy === true && cfg.lazyEffort === true
823
+ ? lazyPlanEffort(lazyWatch, sid, baseEffort)
824
+ : undefined
825
+ const applied = planned && planned.reduced ? planned.effort : baseEffort
826
+ // The journal keeps both halves in one call: `effort` is what the request
827
+ // carried, `effortBase` is what the user chose, so a comparison can tell a
828
+ // lazy step-down from a session that was simply started at a lower rung.
829
+ // `null`, not omitted, on a step that did not step down — otherwise the
830
+ // previous reduction would leak into every following row.
831
+ metrics.label(sid, {
832
+ mode: 'on',
833
+ gear: cfg.gear,
834
+ effort: summaryEffort(applied),
835
+ effortBase: planned && planned.reduced ? summaryEffort(baseEffort) : null,
836
+ route,
837
+ })
751
838
 
752
839
  if (cfg.debug) {
753
840
  appendDebug(
754
841
  cfg.debugFile,
755
- `turn=${payload?.turn} step=${payload?.step} gear=${cfg.gear} effort=${String(label)} (unchanged)`
842
+ `turn=${payload?.turn} step=${payload?.step} gear=${cfg.gear} effort=${String(summaryEffort(baseEffort))}`
843
+ + (planned && planned.reduced ? `->${String(applied)} (lazy step-down)` : ' (unchanged)')
756
844
  + ` route=${route}`
757
845
  + (schedule
758
846
  ? ` parallel=${schedule.parallel} budget=${schedule.stallBudgetMs}ms`
@@ -762,17 +850,18 @@ export function apply(ctx, rawConfig) {
762
850
  )
763
851
  }
764
852
 
765
- return resolved
853
+ return planned && planned.reduced ? { ...resolved, reasoningEffort: applied } : resolved
766
854
  })
767
855
 
768
856
  ctx.logger?.info?.(
769
- '%s v%s loaded: enabled=%s gear=%s parallel=%d adaptive=%s reasoningEffort=untouched metrics=%s',
857
+ '%s v%s loaded: enabled=%s gear=%s parallel=%d adaptive=%s reasoningEffort=%s metrics=%s',
770
858
  TAG,
771
859
  VERSION,
772
860
  initial.enabled,
773
861
  initial.gear,
774
862
  initial.parallel,
775
863
  initial.adaptive === true,
864
+ initial.lazy === true ? (initial.lazyEffort === false ? 'lazy-text-only' : 'lazy') : 'untouched',
776
865
  metrics.enabled ? metrics.file : 'off',
777
866
  )
778
867
  if (initial.legacyGear) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-fast-mode",
3
- "version": "1.5.34",
3
+ "version": "1.6.0",
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",
@@ -46,6 +46,7 @@
46
46
  "bench/ablate.py",
47
47
  "bench/turns.py",
48
48
  "bench/replay.mjs",
49
+ "bench/soak.mjs",
49
50
  "docs/",
50
51
  "LICENSE"
51
52
  ],
package/src/config.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * why the on/off switch and the gear selector exist there without a line of
11
11
  * client code.
12
12
  *
13
- * The seven form fields (`enabled`, `gear`, `parallel`, `sessions`,
13
+ * The form fields (`enabled`, `gear`, `parallel`, `sessions`,
14
14
  * `outputDiscipline`, `stepBudget`, `workspaceMap`) are `.volatile()`: that is
15
15
  * what makes them appear in the generated form *and* what lets an edit reach a
16
16
  * running fiber instead of forcing a remount. A non-volatile declaration is
@@ -18,7 +18,7 @@
18
18
  * keeps only volatile nodes, and `update()` refuses the path with "is not
19
19
  * volatile".
20
20
  *
21
- * The panel's three rows are volatile for exactly that reason: the client half
21
+ * The panel's rows are volatile for exactly that reason: the client half
22
22
  * has one channel to read state from, `describe()`, so a non-volatile field is a
23
23
  * row it can write into `sessions` but never paint — it would keep showing the
24
24
  * built-in default (`late` / 0 / off) while a hand-written `cordis.patch.yml`
@@ -39,9 +39,10 @@
39
39
  * the form.
40
40
  *
41
41
  * `enabled` defaults to **false** on purpose: opt-in is what makes "off means
42
- * completely off" verifiable. Nothing here can change the model's thinking
43
- * effort — the old `floor` / `ladder` / `aliases` knobs are gone, along with the
44
- * code that read them.
42
+ * completely off" verifiable. The old `floor` / `ladder` / `aliases` knobs are
43
+ * gone, along with the code that read them; the one field that can still move
44
+ * effort is `lazy`, and it is off by default because it can change the answer,
45
+ * not only the timing.
45
46
  *
46
47
  * @module dsh-fast-mode/config
47
48
  */
@@ -160,6 +161,21 @@ export const Config = z.object({
160
161
  '开局批量 / Opening batch(面板名「一次读多个文件」): 每轮第一步提醒一次「把已知要做的独立调用放进同一块、shell 命令用 && 串起来」。短任务的钱几乎全花在每步固定成本(首字延迟 ~0.5 s/次)上,少一次往返就省一次;只发一次,不改后续调度。面板可逐会话开;和 `workspaceMap` 一起开时实测 0.742×(11 对),单独开测不到显著,所以默认仍是 false。',
161
162
  ),
162
163
 
164
+ lazy: z
165
+ .boolean()
166
+ .default(false)
167
+ .volatile()
168
+ .description(
169
+ '想太久就少想一点 / Think less when it stalls(面板「想太久就少想一点」): 每轮第一步给一次「先找最省的一级、不扩范围、不砍信任边界校验/数据丢失处理/安全/可访问性」的提示;当上一步流出的思考字符 ≥ 1600、或本轮累计超过 4000/8000 时,把下一个请求的思考强度降一档(最低 low、从不升档、每轮最多两次、工具报错后两步内不降)。每个请求都从用户当下的选择重新读基准,所以下一次请求就恢复。默认关闭:它是唯一会改变答案而不只是时延的开关。',
170
+ ),
171
+
172
+ lazyEffort: z
173
+ .boolean()
174
+ .default(true)
175
+ .description(
176
+ 'lazy 的思考强度半边 / Lazy effort step-down: 设为 false 时 lazy 只发提示文字,一个请求字段都不写。对照臂,用来分辨收益来自提示词还是 provider 真的更低档;默认 true。',
177
+ ),
178
+
163
179
  preferPatch: z
164
180
  .boolean()
165
181
  .default(false)
package/src/effort.js CHANGED
@@ -276,6 +276,15 @@ export function resolveConfig(raw, overrides) {
276
276
  finishLineSteps: gear.directive
277
277
  ? Math.max(0, Math.floor(number(config.finishLineSteps, 0)))
278
278
  : 0,
279
+ /**
280
+ * Lazy mode: the one switch that may cut deliberation instead of only round
281
+ * trips. Off by default, because it changes what the model thinks about and
282
+ * every other switch keeps the "same depth, sooner" promise. `lazyEffort:
283
+ * false` keeps the ladder text and the nudge but never rewrites effort, so
284
+ * the two halves can be measured apart.
285
+ */
286
+ lazy: gear.directive && config.lazy === true,
287
+ lazyEffort: gear.directive && config.lazy === true && config.lazyEffort !== false,
279
288
  /**
280
289
  * The stall guard, the one *hard* bound this plugin owns: it cancels a single
281
290
  * tool call at its deadline. On by default; `false` or a negative
package/src/lazy.js ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Lazy mode: the one switch in this plugin that is allowed to touch how much the
3
+ * model deliberates.
4
+ *
5
+ * Everything else here buys speed while holding effort still — the promise is
6
+ * "same depth, sooner". This module is the deliberate exception, and it is the
7
+ * only place in the package that rewrites `reasoningEffort`. It exists because
8
+ * a long turn loses time in a way parallelism cannot fix: the model keeps
9
+ * expanding a problem it has already understood. The idea is borrowed from
10
+ * `ponytail` — a lazy senior developer solves the whole problem with the least
11
+ * new code, and is lazy about the solution but never about the reading.
12
+ *
13
+ * Three mechanisms, cheapest first:
14
+ *
15
+ * 1. **The ladder, in the prompt.** One short text asks for the first rung that
16
+ * works and a one-line "what I skipped". Cost is input tokens once per turn;
17
+ * it changes nothing about the harness.
18
+ * 2. **The nudge.** When a turn's streamed reasoning passes a threshold without
19
+ * a new tool result, one notice says so. It is observation, not policy: it
20
+ * repeats what the transcript already proves.
21
+ * 3. **The effort step-down.** Only when a step visibly over-thought (the
22
+ * streamed reasoner block is large) does the next request ask for one rung
23
+ * less. It never goes below `FLOOR` (the user's own floor, `low`), never
24
+ * fires twice in a row after an error, and is capped per turn.
25
+ *
26
+ * What is never cut, in this module or its text: validation at a trust boundary,
27
+ * error handling that protects data, security, accessibility, and the
28
+ * deliverable itself. `DIRECTIVE` says so explicitly, because a model told
29
+ * "write less" drops the wrong things first.
30
+ *
31
+ * ## Why the step-down keeps its own ledger
32
+ *
33
+ * The 0.9.x release lowered effort from step two of *every* turn. Measured
34
+ * later, that bought no wall clock on this route and did real damage: the
35
+ * rewrite reached the provider as a request header, so the composer showed an
36
+ * effort the user never chose, and it stayed there across restarts. So this
37
+ * module lowers effort only after evidence, restores the resolved value on the
38
+ * next request, and is off unless `lazy: true`.
39
+ */
40
+
41
+ /** The prompt section. Short on purpose: it is paid on every request of a turn. */
42
+ export const DIRECTIVE = `Work like a lazy senior developer: the best step is the one not taken.
43
+
44
+ - Read before deciding. Trace the real flow of the code the change touches, then decide. Being lazy about the solution never means being lazy about the reading.
45
+ - Once a step fully does the job, stop planning it. No option, abstraction, or "for later" code nobody asked for.
46
+ - Take the first rung that works: what this repo already has, then the platform or a tool you already hold, then one line, then the minimum that works.
47
+ - Never cut these to save code or time: validation where input crosses a trust boundary, error handling that protects data, security, accessibility, and the deliverable the user asked for.
48
+ - End the turn with one line: what you skipped, and the risk that carries.`
49
+
50
+ const DIRECTIVE_SUMMARY = 'lazy mode: first rung that works, no scope beyond the task'
51
+
52
+ /** Reasoning rungs a route may name, weakest first. */
53
+ export const EFFORT_RUNGS = Object.freeze(['minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
54
+
55
+ /**
56
+ * The lowest rung this module will ask for. Effort is the user's contract; a
57
+ * watchdog may spend a little of the headroom a high setting leaves, and stops
58
+ * where the default would have been.
59
+ */
60
+ const FLOOR = 'low'
61
+
62
+ /**
63
+ * Streamed reasoning characters in one step that count as over-thinking. On the
64
+ * measured route a normal step streams a few hundred characters and a step stuck
65
+ * on scope streams thousands, so this is deliberately not a tight bound.
66
+ */
67
+ const HOT_CHARS = 1600
68
+
69
+ /** Reasoning characters a turn may spend before the first nudge. */
70
+ const NUDGE_CHARS = 4000
71
+
72
+ /** How many notices one turn may receive. Two: one early, one late. */
73
+ const MAX_NUDGES = 2
74
+
75
+ /** How many effort reductions one turn may use. */
76
+ const MAX_REDUCTIONS = 2
77
+
78
+ /** Steps a reduction is off limits for after an error result (the step it fired on plus two). */
79
+ const ERROR_COOLDOWN = 2
80
+
81
+ /**
82
+ * The next rung down, or `undefined` when the value is not a rung this module
83
+ * understands (a route may use its own vocabulary — never guess at it) or is
84
+ * already at `FLOOR`.
85
+ *
86
+ * @param {string} value
87
+ * @returns {string | undefined}
88
+ */
89
+ export function cheaperEffort(value) {
90
+ const at = EFFORT_RUNGS.indexOf(value)
91
+ if (at <= 0) return undefined
92
+ const floor = EFFORT_RUNGS.indexOf(FLOOR)
93
+ if (at <= floor) return undefined
94
+ return EFFORT_RUNGS[at - 1]
95
+ }
96
+
97
+ /** Per-session bookkeeping; one turn at a time is all a session runs. */
98
+ const newTurn = () => ({
99
+ step: 0,
100
+ hot: false,
101
+ stepChars: 0,
102
+ turnChars: 0,
103
+ nudges: 0,
104
+ reductions: 0,
105
+ coolUntil: 0,
106
+ nudgedAt: 0,
107
+ reducedStep: -1,
108
+ })
109
+
110
+ /**
111
+ * Create the per-session ledger. Pure and dependency-free so the policy can be
112
+ * tested without a harness.
113
+ */
114
+ export function createLazyWatch() {
115
+ return { runs: new Map() }
116
+ }
117
+
118
+ /** @param {{runs: Map<string, any>}} watch @param {string} sid */
119
+ const runOf = (watch, sid) => {
120
+ let run = watch.runs.get(sid)
121
+ if (run === undefined) {
122
+ run = newTurn()
123
+ watch.runs.set(sid, run)
124
+ if (watch.runs.size > 64) watch.runs.delete(watch.runs.keys().next().value)
125
+ }
126
+ return run
127
+ }
128
+
129
+ /**
130
+ * Count streamed reasoning characters. Called from the stream tap, i.e. before
131
+ * the step ends, which is what makes the effort decision possible at all: the
132
+ * decision has to be made for the *next* request.
133
+ *
134
+ * @param {{runs: Map<string, any>}} watch @param {string} sid @param {number} chars
135
+ */
136
+ export function noteReasoning(watch, sid, chars) {
137
+ if (!Number.isFinite(chars) || chars <= 0) return
138
+ const run = runOf(watch, sid)
139
+ run.stepChars += chars
140
+ run.turnChars += chars
141
+ }
142
+
143
+ /**
144
+ * Open a step: close the previous one's verdict, and say whether this step is
145
+ * the one that carries the ladder text.
146
+ *
147
+ * @param {{runs: Map<string, any>}} watch @param {string} sid
148
+ * @param {{turn?: number, step?: number, firstStep?: boolean}} where
149
+ * @returns {{directive?: string, nudge?: string} | undefined}
150
+ */
151
+ export function beginStep(watch, sid, where) {
152
+ const run = runOf(watch, sid)
153
+ const step = Number.isFinite(where?.step) ? Math.floor(where.step) : run.step + 1
154
+ const newTurnStart = where?.firstStep === true || step <= 1
155
+ if (newTurnStart) {
156
+ const fresh = newTurn()
157
+ fresh.step = step
158
+ watch.runs.set(sid, fresh)
159
+ return { directive: DIRECTIVE }
160
+ }
161
+ // The previous step's streamed volume is the evidence the next request acts on.
162
+ run.hot = run.stepChars >= HOT_CHARS
163
+ run.stepChars = 0
164
+ run.step = step
165
+ const plan = {}
166
+ if (run.nudges < MAX_NUDGES && run.turnChars >= NUDGE_CHARS * (run.nudges + 1) && run.turnChars > run.nudgedAt) {
167
+ run.nudges += 1
168
+ run.nudgedAt = run.turnChars
169
+ run.hot = true
170
+ plan.nudge = `You have streamed about ${run.turnChars} characters of reasoning this turn. Use what you already have: decide, take the next rung, and say in one line what you skipped. Do not open a new line of investigation unless the last result contradicted an assumption.`
171
+ }
172
+ return Object.keys(plan).length === 0 ? undefined : plan
173
+ }
174
+
175
+ /**
176
+ * A tool result came back. An error is the one signal that the model may need
177
+ * *more* thinking, so it cancels any pending step-down for a couple of steps.
178
+ *
179
+ * @param {{runs: Map<string, any>}} watch @param {string} sid @param {{isError?: boolean}} observation
180
+ */
181
+ export function noteToolResult(watch, sid, observation) {
182
+ if (observation?.isError !== true) return
183
+ const run = runOf(watch, sid)
184
+ run.hot = false
185
+ run.coolUntil = run.step + ERROR_COOLDOWN + 1
186
+ }
187
+
188
+ /**
189
+ * The effort this request should carry.
190
+ *
191
+ * Returns `base` untouched unless the ledger holds evidence: a hot previous
192
+ * step, no pending cooldown, budget left, and a base that is a rung above
193
+ * `FLOOR`. The caller must treat `effort === base` as "nothing happened".
194
+ *
195
+ * @param {{runs: Map<string, any>}} watch @param {string} sid
196
+ * @param {string | undefined} base - the effort the harness resolved.
197
+ * @returns {{effort: string | undefined, reduced: boolean}}
198
+ */
199
+ export function planEffort(watch, sid, base) {
200
+ if (typeof base !== 'string') return { effort: base, reduced: false }
201
+ const run = runOf(watch, sid)
202
+ if (run.reducedStep === run.step) {
203
+ // One step, one reduction: a retry inside the same step must not walk the
204
+ // effort ladder down twice before anything has been measured again.
205
+ return { effort: base, reduced: false }
206
+ }
207
+ if (!run.hot || run.step < run.coolUntil || run.reductions >= MAX_REDUCTIONS) {
208
+ return { effort: base, reduced: false }
209
+ }
210
+ const cheaper = cheaperEffort(base)
211
+ if (cheaper === undefined) return { effort: base, reduced: false }
212
+ run.reductions += 1
213
+ run.reducedStep = run.step
214
+ return { effort: cheaper, reduced: true }
215
+ }
216
+
217
+ /** The notice body for the nudge, built where it is sent (see `index.js`). */
218
+ export const NUDGE_SUMMARY = 'lazy mode: decide with what you have'
219
+
220
+ export { DIRECTIVE_SUMMARY }
package/src/metrics.js CHANGED
@@ -113,6 +113,21 @@ const chunkKind = (chunk) => {
113
113
  return typeof chunk.type === 'string' ? chunk.type : 'unknown'
114
114
  }
115
115
 
116
+ /**
117
+ * The text a delta chunk carries. Adapters spell it `text` or `delta`; anything
118
+ * else is counted as zero characters rather than guessed at, so the column can
119
+ * be read as a lower bound and never as an estimate.
120
+ *
121
+ * @param {any} chunk
122
+ */
123
+ const deltaText = (chunk) => {
124
+ if (!chunk || typeof chunk !== 'object') return ''
125
+ if (typeof chunk.text === 'string') return chunk.text
126
+ if (typeof chunk.delta === 'string') return chunk.delta
127
+ const nested = chunk.delta
128
+ return nested && typeof nested === 'object' && typeof nested.text === 'string' ? nested.text : ''
129
+ }
130
+
116
131
  const payloadOf = (event) => {
117
132
  const data = event && typeof event === 'object' ? event.data : undefined
118
133
  return data && typeof data === 'object' ? data : (event ?? {})
@@ -144,7 +159,7 @@ export function createRecorder(options = {}) {
144
159
  const on = file.length > 0
145
160
  /** @type {Map<string, any>} */
146
161
  const steps = new Map()
147
- /** @type {Map<string, {mode: string, gear: string, effort?: string, route?: string}>} */
162
+ /** @type {Map<string, {mode: string, gear: string, effort?: string, effortBase?: string, route?: string}>} */
148
163
  const labels = new Map()
149
164
  let prepared = false
150
165
 
@@ -205,6 +220,8 @@ export function createRecorder(options = {}) {
205
220
  calls: 0,
206
221
  errors: 0,
207
222
  seen: 0,
223
+ reasoningChars: 0,
224
+ textChars: 0,
208
225
  }
209
226
  steps.set(key, entry)
210
227
  trim(steps, STEPS_MAX)
@@ -222,8 +239,14 @@ export function createRecorder(options = {}) {
222
239
  * Tell the next step rows which stance produced them. Called from the
223
240
  * request hook, which is the only place that knows the resolved gear.
224
241
  *
242
+ * A call may carry only some fields: the omitted ones keep the session's
243
+ * previous value, so a later call cannot blank the gear it does not know
244
+ * about. `effortBase: null` is the one explicit clear, used when a step did
245
+ * *not* step down — without it the previous reduction would be attributed to
246
+ * every following row.
247
+ *
225
248
  * @param {string|undefined} sid
226
- * @param {{mode: string, gear: string, effort?: string, route?: string}} label
249
+ * @param {{mode?: string, gear?: string, effort?: string, effortBase?: string|null, route?: string}} label
227
250
  */
228
251
  label(sid, label) {
229
252
  if (typeof sid !== 'string' || sid.length === 0) return
@@ -232,14 +255,19 @@ export function createRecorder(options = {}) {
232
255
  // not exist is pure retention. (1.5.33 and earlier kept them anyway.)
233
256
  if (!on) return
234
257
  const previous = labels.get(sid)
235
- if (previous && previous.mode === label.mode && previous.gear === label.gear
236
- && previous.effort === label.effort && previous.route === label.route) return
237
- labels.set(sid, {
238
- mode: label.mode,
239
- gear: label.gear,
240
- effort: label.effort,
241
- route: label.route,
242
- })
258
+ const next = {
259
+ mode: label.mode !== undefined ? label.mode : previous && previous.mode,
260
+ gear: label.gear !== undefined ? label.gear : previous && previous.gear,
261
+ effort: label.effort !== undefined ? label.effort : previous && previous.effort,
262
+ effortBase: label.effortBase === null
263
+ ? undefined
264
+ : label.effortBase !== undefined ? label.effortBase : previous && previous.effortBase,
265
+ route: label.route !== undefined ? label.route : previous && previous.route,
266
+ }
267
+ if (previous && previous.mode === next.mode && previous.gear === next.gear
268
+ && previous.effort === next.effort && previous.effortBase === next.effortBase
269
+ && previous.route === next.route) return
270
+ labels.set(sid, next)
243
271
  trim(labels, LABELS_MAX)
244
272
  },
245
273
 
@@ -302,6 +330,13 @@ export function createRecorder(options = {}) {
302
330
  }
303
331
  return
304
332
  }
333
+ // Streaming volume is the only live view of how much the model is
334
+ // deliberating: this route reports zero reasoning *tokens* in `usage`
335
+ // even when it streams a long reasoner block, so the character count is
336
+ // the measurement that can actually be compared across arms.
337
+ const carried = deltaText(data.chunk)
338
+ if (chunkType === 'reasoning-delta') entry.reasoningChars += carried.length
339
+ else if (chunkType === 'text-delta') entry.textChars += carried.length
305
340
  if (entry.chunkFirst === undefined) {
306
341
  entry.chunkFirst = time
307
342
  entry.chunkKind = chunkType
@@ -354,6 +389,7 @@ export function createRecorder(options = {}) {
354
389
  mode: label ? label.mode : 'unknown',
355
390
  gear: label ? label.gear : 'unknown',
356
391
  effort: label ? label.effort : undefined,
392
+ effortBase: label ? label.effortBase : undefined,
357
393
  route: label ? label.route : undefined,
358
394
  // `ttftMs` is queue + prefill: `step/start` to the first streamed chunk.
359
395
  // `decodeMs` is the generation window that follows it. `llmMs` stays the
@@ -370,6 +406,8 @@ export function createRecorder(options = {}) {
370
406
  input: entry.input,
371
407
  output: entry.output,
372
408
  reasoning: entry.reasoning,
409
+ reasoningChars: entry.reasoningChars,
410
+ textChars: entry.textChars,
373
411
  cacheRead: entry.cacheRead,
374
412
  cacheWrite: entry.cacheWrite,
375
413
  assistantMessages: entry.seen,