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/BENCHMARK.md +30 -0
- package/CHANGELOG.md +16 -0
- package/README.en.md +8 -5
- package/README.md +8 -5
- package/bench/soak.mjs +189 -0
- package/client.js +33 -1
- package/docs/REFERENCE.md +34 -15
- package/index.js +107 -18
- package/package.json +2 -1
- package/src/config.js +21 -5
- package/src/effort.js +9 -0
- package/src/lazy.js +220 -0
- package/src/metrics.js +48 -10
- package/src/state.js +8 -2
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.
|
|
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
|
-
|
|
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
|
|
806
|
+
const baseEffort = resolved?.reasoningEffort
|
|
733
807
|
const route = routeOf(resolved)
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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,
|