dsh-fast-mode 1.5.17 → 1.5.19
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 +121 -16
- package/CHANGELOG.md +19 -0
- package/README.en.md +21 -6
- package/README.md +29 -14
- package/docs/REFERENCE.en.md +3 -2
- package/docs/REFERENCE.md +3 -2
- package/index.js +10 -2
- package/package.json +1 -1
- package/src/auto.js +201 -58
- package/src/config.js +30 -6
- package/src/effort.js +2 -0
- package/src/exit.js +190 -0
- package/src/parallel.js +72 -9
- package/src/stallguard.js +32 -5
package/src/auto.js
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
38
|
import { statSync } from 'node:fs'
|
|
39
|
+
import { isAbsolute, resolve as resolvePath } from 'node:path'
|
|
39
40
|
/** The gear id the adaptive scheduler runs under. */
|
|
40
41
|
export const AUTO = 'auto'
|
|
41
42
|
|
|
@@ -131,7 +132,7 @@ export function noteEntry(state, agent, width) {
|
|
|
131
132
|
*
|
|
132
133
|
* @param {ReturnType<typeof createAutoState>} state
|
|
133
134
|
* @param {string} agent
|
|
134
|
-
* @param {{lateDiscipline?: boolean, stepBudget?: number, finishLineSteps?: number, outputBudget?: number, outputBudgetAfterSteps?: number, openBatch?: boolean, shapeGate?: boolean, scheduler?: boolean, fanoutWidth?: number, batchVerify?: boolean, preferPatch?: boolean, disciplineAfterSteps?: number, disciplineRepeatAtSteps?: number, disciplineEarlyCalls?: number}} policy
|
|
135
|
+
* @param {{lateDiscipline?: boolean, stepBudget?: number, finishLineSteps?: number, outputBudget?: number, outputBudgetAfterSteps?: number, openBatch?: boolean, shapeGate?: boolean, scheduler?: boolean, fanoutWidth?: number, batchVerify?: boolean, preferPatch?: boolean, disciplineAfterSteps?: number, disciplineRepeatAtSteps?: number, disciplineEarlyCalls?: number, rereadHint?: boolean}} policy
|
|
135
136
|
*/
|
|
136
137
|
export function notePolicy(state, agent, policy) {
|
|
137
138
|
const entry = entryOf(state, agent)
|
|
@@ -146,6 +147,10 @@ export function notePolicy(state, agent, policy) {
|
|
|
146
147
|
// The reminder above is once per turn; a long turn drifts back to prose after
|
|
147
148
|
// it, so an explicit step can repeat the same ask exactly once more.
|
|
148
149
|
entry.disciplineRepeatAtSteps = stepCount(policy?.disciplineRepeatAtSteps, 0, 0)
|
|
150
|
+
// One extra sentence, not one extra message: the compression reminder already
|
|
151
|
+
// owns a step where the turn has proven itself long, and that is the only
|
|
152
|
+
// place a re-read rule can be said for free.
|
|
153
|
+
entry.rereadHint = policy?.rereadHint === true
|
|
149
154
|
// 0 keeps the early reminder unconditional, which is the 1.5.12 behaviour and
|
|
150
155
|
// the arm that measured slower on short turns.
|
|
151
156
|
entry.disciplineEarlyCalls = stepCount(policy?.disciplineEarlyCalls, 0, 0)
|
|
@@ -223,15 +228,21 @@ function entryOf(state, agent) {
|
|
|
223
228
|
/** @type {Map<string, {epoch: number, digest: string}>} */
|
|
224
229
|
checks: new Map(),
|
|
225
230
|
/**
|
|
226
|
-
* Stat fingerprints of files this session wrote,
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
+
* Stat fingerprints of the files this session wrote, keyed by absolute path
|
|
232
|
+
* and captured when the write was observed. They are only read to snapshot
|
|
233
|
+
* the artifact versions a passing check covered; they are never rebaselined
|
|
234
|
+
* by a later check, because that would let one pass make a different pass
|
|
235
|
+
* look current.
|
|
231
236
|
*
|
|
232
237
|
* @type {Map<string, {mtimeMs: number, size: number}>}
|
|
233
238
|
*/
|
|
234
239
|
writes: new Map(),
|
|
240
|
+
/**
|
|
241
|
+
* True once a write happened whose target could not be fingerprinted. The
|
|
242
|
+
* artifact set is then unknown, and no stored pass can be defended by a stat
|
|
243
|
+
* re-read, so reuse is not offered for the rest of the session.
|
|
244
|
+
*/
|
|
245
|
+
writesUncertain: false,
|
|
235
246
|
/** Signature of a check that just repeated with a still-valid stored pass. */
|
|
236
247
|
reuseHint: '',
|
|
237
248
|
/** Epoch in which the reuse reminder last fired, so it fires once per epoch. */
|
|
@@ -249,8 +260,15 @@ function entryOf(state, agent) {
|
|
|
249
260
|
disciplineSent: false,
|
|
250
261
|
/** Step for the optional repeat of that reminder; 0 = off. */
|
|
251
262
|
disciplineRepeatAtSteps: AUTO_LIMITS.disciplineRepeatAtSteps,
|
|
263
|
+
/** Append the "do not re-read what this turn wrote" sentence to the reminder. */
|
|
264
|
+
rereadHint: false,
|
|
252
265
|
/** True once the repeat has been sent. */
|
|
253
266
|
disciplineRepeatSent: false,
|
|
267
|
+
/**
|
|
268
|
+
* The step the real-time reminder was locked to for this turn, decided once
|
|
269
|
+
* from the first step's call density. 0 means "not decided yet".
|
|
270
|
+
*/
|
|
271
|
+
disciplineAtLocked: 0,
|
|
254
272
|
/** Hard stop: the step count that ends the turn with "report now". */
|
|
255
273
|
turnStepBudget: AUTO_LIMITS.turnStepBudget,
|
|
256
274
|
/** Soft stop: one "no more scope" reminder at this step, 0 = off. */
|
|
@@ -304,6 +322,7 @@ export function noteTurn(state, agent) {
|
|
|
304
322
|
entry.stepProgress = false
|
|
305
323
|
entry.disciplineSent = false
|
|
306
324
|
entry.disciplineRepeatSent = false
|
|
325
|
+
entry.disciplineAtLocked = 0
|
|
307
326
|
entry.finishLineSent = false
|
|
308
327
|
entry.outBudgetSent = false
|
|
309
328
|
entry.openingSent = false
|
|
@@ -386,14 +405,22 @@ export function noteStep(state, agent) {
|
|
|
386
405
|
*
|
|
387
406
|
* @param {ReturnType<typeof createAutoState>} state
|
|
388
407
|
* @param {string} agent
|
|
389
|
-
* @param {{name?: string, argsKey?: string, isError?: boolean, chars?: number, digest?: string,
|
|
408
|
+
* @param {{name?: string, argsKey?: string, isError?: boolean, chars?: number, digest?: string,
|
|
409
|
+
* durationMs?: number, paths?: string[], command?: string, cwd?: string}} observation
|
|
410
|
+
* `argsKey` is dedupe identity only (sorted, truncated); `paths`, `command` and
|
|
411
|
+
* `cwd` are the untruncated execution facts the verification evidence needs.
|
|
390
412
|
*/
|
|
391
413
|
export function noteTool(state, agent, observation) {
|
|
392
414
|
const entry = entryOf(state, agent)
|
|
393
415
|
const obs = observation && typeof observation === 'object' ? observation : {}
|
|
394
416
|
const name = typeof obs.name === 'string' && obs.name.length > 0 ? obs.name : 'unknown'
|
|
395
417
|
const argsKey = typeof obs.argsKey === 'string' ? obs.argsKey : ''
|
|
396
|
-
|
|
418
|
+
// Dedupe identity: bounded and cheap. Execution semantics come from the
|
|
419
|
+
// untruncated command/paths the observation carries, never from this key.
|
|
420
|
+
const command = typeof obs.command === 'string' ? obs.command : ''
|
|
421
|
+
const signature = SHELL_CALLS.has(name) && command.length > 0
|
|
422
|
+
? `${name}\u0000${command}`
|
|
423
|
+
: `${name}\u0000${argsKey}`
|
|
397
424
|
const chars = Number.isFinite(obs.chars) ? Number(obs.chars) : -1
|
|
398
425
|
const digest = typeof obs.digest === 'string' && obs.digest.length > 0 ? obs.digest : `chars:${chars}`
|
|
399
426
|
const isError = obs.isError === true
|
|
@@ -417,7 +444,7 @@ export function noteTool(state, agent, observation) {
|
|
|
417
444
|
// command often names a build tool (`npm test`, `make test`): bumping the
|
|
418
445
|
// epoch on the check itself is what used to make the one command this
|
|
419
446
|
// reminder targets unable to produce it.
|
|
420
|
-
const shellKind = SHELL_CALLS.has(name) ? classifyShell(argsKey) : undefined
|
|
447
|
+
const shellKind = SHELL_CALLS.has(name) ? classifyShell(command.length > 0 ? command : argsKey) : undefined
|
|
421
448
|
if (MUTATING_CALLS.has(name) || shellKind === 'unknown') {
|
|
422
449
|
entry.epoch += 1
|
|
423
450
|
entry.checks.clear()
|
|
@@ -425,7 +452,7 @@ export function noteTool(state, agent, observation) {
|
|
|
425
452
|
entry.writesSinceCheck += 1
|
|
426
453
|
entry.batchVerifySent = false
|
|
427
454
|
}
|
|
428
|
-
if (MUTATING_CALLS.has(name)) recordWritePaths(entry,
|
|
455
|
+
if (MUTATING_CALLS.has(name)) recordWritePaths(entry, obs)
|
|
429
456
|
|
|
430
457
|
// A whole-file argument is the compressible half of output: the file lands on
|
|
431
458
|
// disk either way, so the only thing a hunk form changes is how many tokens
|
|
@@ -453,7 +480,7 @@ export function noteTool(state, agent, observation) {
|
|
|
453
480
|
else if (previous.digest !== digest) progress = true
|
|
454
481
|
entry.seen.set(signature, { digest, chars, errored: false })
|
|
455
482
|
entry.errors = 0
|
|
456
|
-
noteCheck(entry,
|
|
483
|
+
noteCheck(entry, signature, digest, shellKind)
|
|
457
484
|
// Only a verification run is evidence that the current files were checked;
|
|
458
485
|
// a read-only shell command says nothing about the edits still unverified.
|
|
459
486
|
if (shellKind === 'verify') {
|
|
@@ -498,6 +525,42 @@ const WRITE_WINDOW = 64
|
|
|
498
525
|
const MUTATING_CALLS = Object.freeze(new Set(['write', 'write_file', 'create_file', 'edit', 'multi_edit', 'apply_patch', 'patch']))
|
|
499
526
|
const SHELL_CALLS = Object.freeze(new Set(['bash', 'shell', 'terminal', 'run_code', 'exec']))
|
|
500
527
|
|
|
528
|
+
/**
|
|
529
|
+
* What a tool call actually names: the files a mutation targets, the command text
|
|
530
|
+
* a shell call runs. Read from the **untruncated** arguments the hook receives.
|
|
531
|
+
*
|
|
532
|
+
* This exists because the dedupe key cannot be reused as execution semantics: it
|
|
533
|
+
* is sorted by field name and cut at 400 characters, so a whole-file write whose
|
|
534
|
+
* `content` sorts before `path` loses the path entirely — the tracking table then
|
|
535
|
+
* stays empty while the write did happen. Callers pass the result into
|
|
536
|
+
* `noteTool` so the reuse evidence covers the real artifact.
|
|
537
|
+
*
|
|
538
|
+
* @param {unknown} args
|
|
539
|
+
* @returns {{paths: string[], command: string}}
|
|
540
|
+
*/
|
|
541
|
+
export function toolTargets(args) {
|
|
542
|
+
const paths = []
|
|
543
|
+
const commands = []
|
|
544
|
+
if (typeof args === 'string') {
|
|
545
|
+
commands.push(args)
|
|
546
|
+
} else if (args && typeof args === 'object') {
|
|
547
|
+
for (const field of PATH_FIELDS) {
|
|
548
|
+
const value = args[field]
|
|
549
|
+
if (typeof value === 'string') paths.push(value)
|
|
550
|
+
else if (Array.isArray(value)) paths.push(...value.filter(part => typeof part === 'string'))
|
|
551
|
+
}
|
|
552
|
+
for (const field of COMMAND_FIELDS) {
|
|
553
|
+
const value = args[field]
|
|
554
|
+
if (typeof value === 'string') commands.push(value)
|
|
555
|
+
else if (Array.isArray(value)) commands.push(...value.filter(part => typeof part === 'string'))
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return {
|
|
559
|
+
paths: paths.filter(path => path.length > 0 && path.length <= 4096),
|
|
560
|
+
command: commands.join('\n').slice(0, 4000),
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
501
564
|
/** @param {string} key @returns {unknown} */
|
|
502
565
|
function parseArgs(key) {
|
|
503
566
|
try {
|
|
@@ -599,11 +662,28 @@ function classifyShell(argsKey) {
|
|
|
599
662
|
* @param {string} argsKey
|
|
600
663
|
* @returns {string[]}
|
|
601
664
|
*/
|
|
602
|
-
function writePathsOf(
|
|
603
|
-
const
|
|
604
|
-
|
|
665
|
+
function writePathsOf(obs) {
|
|
666
|
+
const direct = obs && Array.isArray(obs.paths) ? obs.paths : []
|
|
667
|
+
const named = direct.filter(path => typeof path === 'string' && path.length > 0 && path.length <= 4096)
|
|
668
|
+
if (named.length > 0) return named
|
|
669
|
+
return pathsFromKey(typeof obs?.argsKey === 'string' ? obs.argsKey : '')
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Fallback target extraction from the bounded dedupe key. Only for hosts that do
|
|
674
|
+
* not pass the real arguments: the key is cut at 400 characters and sorted by
|
|
675
|
+
* field name, so a large whole-file write can lose its path. Treat a key-derived
|
|
676
|
+
* target as a hint, never as proof that the rest of the call touched nothing —
|
|
677
|
+
* the caller marks such a write uncertain when a target cannot be fingerprinted.
|
|
678
|
+
*
|
|
679
|
+
* @param {string} key
|
|
680
|
+
* @returns {string[]}
|
|
681
|
+
*/
|
|
682
|
+
function pathsFromKey(key) {
|
|
683
|
+
const argsKey = typeof key === 'string' ? key : ''
|
|
684
|
+
if (argsKey.length === 0) return []
|
|
605
685
|
const paths = []
|
|
606
|
-
const parsed = parseArgs(
|
|
686
|
+
const parsed = parseArgs(argsKey)
|
|
607
687
|
if (typeof parsed === 'string') paths.push(parsed)
|
|
608
688
|
else if (parsed && typeof parsed === 'object') {
|
|
609
689
|
for (const field of PATH_FIELDS) {
|
|
@@ -613,17 +693,34 @@ function writePathsOf(argsKey) {
|
|
|
613
693
|
}
|
|
614
694
|
} else {
|
|
615
695
|
const field = /"(?:path|file_path|filePath|target|file|filename)"\s*:\s*("(?:[^"\\]|\\.)*")/g
|
|
616
|
-
let match = field.exec(
|
|
696
|
+
let match = field.exec(argsKey)
|
|
617
697
|
while (match) {
|
|
618
698
|
const value = parseArgs(match[1])
|
|
619
699
|
if (typeof value === 'string') paths.push(value)
|
|
620
|
-
match = field.exec(
|
|
700
|
+
match = field.exec(argsKey)
|
|
621
701
|
}
|
|
622
|
-
if (paths.length === 0 && !
|
|
702
|
+
if (paths.length === 0 && !argsKey.startsWith('{') && !argsKey.startsWith('[')) paths.push(argsKey)
|
|
623
703
|
}
|
|
624
704
|
return paths.filter(path => path.length > 0 && path.length <= 4096)
|
|
625
705
|
}
|
|
626
706
|
|
|
707
|
+
/**
|
|
708
|
+
* Resolve a tool-named path the way the tool itself would. A relative path needs
|
|
709
|
+
* the session's project directory: the host process runs somewhere else, so
|
|
710
|
+
* `statSync('./src/a.js')` against the wrong base silently tracked the wrong
|
|
711
|
+
* file (or nothing at all).
|
|
712
|
+
*
|
|
713
|
+
* @param {string} cwd
|
|
714
|
+
* @param {string} path
|
|
715
|
+
* @returns {string|undefined}
|
|
716
|
+
*/
|
|
717
|
+
function absolutePath(cwd, path) {
|
|
718
|
+
if (typeof path !== 'string' || path.length === 0) return undefined
|
|
719
|
+
if (isAbsolute(path)) return path
|
|
720
|
+
if (typeof cwd !== 'string' || cwd.length === 0) return undefined
|
|
721
|
+
return resolvePath(cwd, path)
|
|
722
|
+
}
|
|
723
|
+
|
|
627
724
|
/**
|
|
628
725
|
* A stat fingerprint, or `undefined` when the path is not visible from this
|
|
629
726
|
* process. Only visible targets are tracked; the epoch remains the evidence for
|
|
@@ -645,7 +742,7 @@ function fingerprintOf(path) {
|
|
|
645
742
|
/** @param {Map<string, {mtimeMs: number, size: number}>} writes @param {string} path */
|
|
646
743
|
function trackPath(writes, path) {
|
|
647
744
|
const print = fingerprintOf(path)
|
|
648
|
-
if (!print) return
|
|
745
|
+
if (!print) return false
|
|
649
746
|
writes.delete(path)
|
|
650
747
|
writes.set(path, print)
|
|
651
748
|
while (writes.size > WRITE_WINDOW) {
|
|
@@ -653,34 +750,59 @@ function trackPath(writes, path) {
|
|
|
653
750
|
if (oldest === undefined) break
|
|
654
751
|
writes.delete(oldest)
|
|
655
752
|
}
|
|
753
|
+
return true
|
|
656
754
|
}
|
|
657
755
|
|
|
658
|
-
/**
|
|
659
|
-
|
|
660
|
-
|
|
756
|
+
/**
|
|
757
|
+
* Fingerprint the files a mutation named, resolved against the session directory.
|
|
758
|
+
*
|
|
759
|
+
* @param {object} entry
|
|
760
|
+
* @param {{paths?: string[], cwd?: string, argsKey?: string}} obs
|
|
761
|
+
*/
|
|
762
|
+
function recordWritePaths(entry, obs) {
|
|
763
|
+
const cwd = obs && typeof obs.cwd === 'string' ? obs.cwd : ''
|
|
764
|
+
const paths = writePathsOf(obs)
|
|
765
|
+
let tracked = 0
|
|
766
|
+
for (const path of paths) {
|
|
767
|
+
const absolute = absolutePath(cwd, path)
|
|
768
|
+
if (absolute === undefined) continue
|
|
769
|
+
if (trackPath(entry.writes, absolute)) tracked += 1
|
|
770
|
+
}
|
|
771
|
+
// A mutation whose target could not be fingerprinted leaves the artifact set
|
|
772
|
+
// unknown. No stored pass can then be defended by a stat re-read, so the
|
|
773
|
+
// session stops offering reuse instead of guessing.
|
|
774
|
+
if (tracked === 0) entry.writesUncertain = true
|
|
661
775
|
}
|
|
662
776
|
|
|
663
777
|
/**
|
|
664
|
-
*
|
|
665
|
-
*
|
|
666
|
-
*
|
|
778
|
+
* Snapshot the versions of every tracked artifact at this instant. This is the
|
|
779
|
+
* evidence a passing check covers: the files on disk when the pass ran, not the
|
|
780
|
+
* state at the last write (which the pass may have been re-run against) and not a
|
|
781
|
+
* session-wide table a different check could refresh.
|
|
667
782
|
*
|
|
668
783
|
* @param {object} entry
|
|
784
|
+
* @returns {Map<string, {mtimeMs: number, size: number}>}
|
|
669
785
|
*/
|
|
670
|
-
function
|
|
671
|
-
|
|
786
|
+
function evidenceSnapshot(entry) {
|
|
787
|
+
const evidence = new Map()
|
|
788
|
+
for (const path of entry.writes.keys()) {
|
|
789
|
+
const print = fingerprintOf(path)
|
|
790
|
+
if (print) evidence.set(path, print)
|
|
791
|
+
}
|
|
792
|
+
return evidence
|
|
672
793
|
}
|
|
673
794
|
|
|
674
795
|
/**
|
|
675
|
-
* True when a file
|
|
676
|
-
*
|
|
677
|
-
*
|
|
796
|
+
* True when a file covered by the stored pass no longer matches the fingerprint
|
|
797
|
+
* captured with that pass. A stat that fails counts as changed: a pass is never
|
|
798
|
+
* offered on evidence that could not be re-read.
|
|
678
799
|
*
|
|
679
|
-
* @param {Map<string, {mtimeMs: number, size: number}>}
|
|
800
|
+
* @param {Map<string, {mtimeMs: number, size: number}>} evidence
|
|
680
801
|
* @returns {boolean}
|
|
681
802
|
*/
|
|
682
|
-
function
|
|
683
|
-
|
|
803
|
+
function evidenceStale(evidence) {
|
|
804
|
+
if (!(evidence instanceof Map)) return true
|
|
805
|
+
for (const [path, print] of evidence) {
|
|
684
806
|
const now = fingerprintOf(path)
|
|
685
807
|
if (!now || now.mtimeMs !== print.mtimeMs || now.size !== print.size) return true
|
|
686
808
|
}
|
|
@@ -699,28 +821,35 @@ function withdrawCheck(entry, signature) {
|
|
|
699
821
|
}
|
|
700
822
|
|
|
701
823
|
/**
|
|
702
|
-
* Store a passing verification as a reusable result,
|
|
703
|
-
*
|
|
824
|
+
* Store a passing verification as a reusable result, together with the artifact
|
|
825
|
+
* versions **that pass actually covered**, and raise the hint when the same check
|
|
826
|
+
* repeats while that evidence still holds.
|
|
827
|
+
*
|
|
828
|
+
* The evidence is per check, never refreshed by a different check: a later pass
|
|
829
|
+
* of command B used to re-baseline the session-wide fingerprints, which made an
|
|
830
|
+
* older pass of command A look current although A had run against older files.
|
|
831
|
+
* Each stored pass now snapshots `entry.writes` at the moment it is seen, and is
|
|
832
|
+
* only offered while those exact versions are still on disk and no write or
|
|
833
|
+
* unrecognised command has bumped the epoch since.
|
|
704
834
|
*
|
|
705
|
-
* Only a recognised verification run is stored. A repeated read or grep is not
|
|
706
|
-
*
|
|
707
|
-
*
|
|
835
|
+
* Only a recognised verification run is stored. A repeated read or grep is not a
|
|
836
|
+
* check: offering to "reuse" its output would be a claim about an artifact that
|
|
837
|
+
* nothing verified.
|
|
708
838
|
*
|
|
709
839
|
* @param {object} entry
|
|
710
|
-
* @param {string} argsKey
|
|
711
840
|
* @param {string} signature
|
|
712
841
|
* @param {string} digest
|
|
713
842
|
* @param {'readonly'|'verify'|'unknown'|undefined} shellKind
|
|
714
843
|
*/
|
|
715
|
-
function noteCheck(entry,
|
|
716
|
-
if (shellKind !== 'verify' ||
|
|
717
|
-
refreshWritePaths(entry)
|
|
844
|
+
function noteCheck(entry, signature, digest, shellKind) {
|
|
845
|
+
if (shellKind !== 'verify' || signature.length === 0) return
|
|
718
846
|
const stored = entry.checks.get(signature)
|
|
719
|
-
if (stored && stored.epoch === entry.epoch && stored.digest === digest
|
|
847
|
+
if (stored && stored.epoch === entry.epoch && stored.digest === digest
|
|
848
|
+
&& entry.reuseHint === '' && !entry.writesUncertain && !evidenceStale(stored.evidence)) {
|
|
720
849
|
entry.reuseHint = signature
|
|
721
850
|
return
|
|
722
851
|
}
|
|
723
|
-
entry.checks.set(signature, { epoch: entry.epoch, digest })
|
|
852
|
+
entry.checks.set(signature, { epoch: entry.epoch, digest, evidence: evidenceSnapshot(entry) })
|
|
724
853
|
if (entry.checks.size > 64) {
|
|
725
854
|
const oldest = entry.checks.keys().next().value
|
|
726
855
|
if (oldest !== undefined) entry.checks.delete(oldest)
|
|
@@ -918,11 +1047,19 @@ function directiveFor(entry, stats) {
|
|
|
918
1047
|
// step's own calls have not happened yet, so counting them would let a step pay
|
|
919
1048
|
// for its own admission.
|
|
920
1049
|
const callsBefore = entry.turnCalls - entry.stepCalls
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1050
|
+
// The bet is decided once, from the first step's density, and then frozen for
|
|
1051
|
+
// the turn. Re-reading the cumulative count at every step made the reminder
|
|
1052
|
+
// drift: a two-call first step qualified for the early slot at step 2, then the
|
|
1053
|
+
// same turn looked dense at step 3 and pushed the reminder to step 4 instead.
|
|
1054
|
+
if (entry.disciplineEarlyCalls > 0
|
|
1055
|
+
&& earlyAt < AUTO_LIMITS.disciplineAfterSteps
|
|
1056
|
+
&& entry.disciplineAtLocked === 0
|
|
1057
|
+
&& stats.turnSteps >= 2) {
|
|
1058
|
+
entry.disciplineAtLocked = callsBefore < entry.disciplineEarlyCalls
|
|
1059
|
+
? AUTO_LIMITS.disciplineAfterSteps
|
|
1060
|
+
: earlyAt
|
|
1061
|
+
}
|
|
1062
|
+
const disciplineAt = entry.disciplineAtLocked > 0 ? entry.disciplineAtLocked : earlyAt
|
|
926
1063
|
if (entry.lateDiscipline && !entry.disciplineSent && stats.turnSteps >= disciplineAt) {
|
|
927
1064
|
// Late engagement: the prompt carried no prose rules, so the only cost of a
|
|
928
1065
|
// short task was its own work. By this step the task is demonstrably long,
|
|
@@ -935,7 +1072,11 @@ function directiveFor(entry, stats) {
|
|
|
935
1072
|
text: 'Auto schedule: this turn is long enough that prose is now the cost. '
|
|
936
1073
|
+ 'For the remaining steps write only what the next action needs: the command, the file, the '
|
|
937
1074
|
+ 'conclusion. No restating file contents, no narrating what you are about to do, no summary of '
|
|
938
|
-
+ 'what a tool returned. Keep the answer complete; cut the commentary.'
|
|
1075
|
+
+ 'what a tool returned. Keep the answer complete; cut the commentary.'
|
|
1076
|
+
+ (entry.rereadHint
|
|
1077
|
+
? ' For a file this turn already wrote, work from the last edit and the check output; do not read '
|
|
1078
|
+
+ 'the whole file again just to confirm.'
|
|
1079
|
+
: ''),
|
|
939
1080
|
summary: 'auto: compress output for the rest of the turn',
|
|
940
1081
|
}
|
|
941
1082
|
}
|
|
@@ -954,7 +1095,7 @@ function directiveFor(entry, stats) {
|
|
|
954
1095
|
}
|
|
955
1096
|
}
|
|
956
1097
|
const outBudget = entry.outBudget
|
|
957
|
-
const budgetArmed =
|
|
1098
|
+
const budgetArmed = stats.turnSteps >= (entry.outBudgetAfterSteps || 0)
|
|
958
1099
|
if (outBudget > 0 && budgetArmed && !entry.outBudgetSent && stats.turnOutTokens >= outBudget) {
|
|
959
1100
|
// Evidence that the turn is expensive, not that it is long: the counter is
|
|
960
1101
|
// the provider's own usage. A three-step turn that wrote 30 000 tokens is a
|
|
@@ -1010,14 +1151,16 @@ function directiveFor(entry, stats) {
|
|
|
1010
1151
|
// already passed and neither a write nor an unrecognised shell command ran
|
|
1011
1152
|
// since. Both of those bump the epoch, so reaching this branch already means
|
|
1012
1153
|
// the tool call stream saw nothing that could have changed the result. The
|
|
1013
|
-
//
|
|
1014
|
-
// that arrived some other way; a
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1154
|
+
// artifacts that particular pass covered are re-read here because the stream
|
|
1155
|
+
// cannot see a change that arrived some other way; a pass whose own evidence
|
|
1156
|
+
// no longer matches is withdrawn, and so is every pass once a write left the
|
|
1157
|
+
// artifact set unknown.
|
|
1158
|
+
const stored = entry.checks.get(entry.reuseHint)
|
|
1159
|
+
const backedByEvidence = stored !== undefined && stored.epoch === entry.epoch
|
|
1160
|
+
&& !entry.writesUncertain && !evidenceStale(stored.evidence)
|
|
1161
|
+
if (!backedByEvidence) {
|
|
1162
|
+
entry.checks.delete(entry.reuseHint)
|
|
1018
1163
|
entry.reuseHint = ''
|
|
1019
|
-
entry.writesSinceCheck += 1
|
|
1020
|
-
entry.batchVerifySent = false
|
|
1021
1164
|
} else {
|
|
1022
1165
|
entry.reuseHint = ''
|
|
1023
1166
|
entry.reuseSentEpoch = entry.epoch
|
package/src/config.js
CHANGED
|
@@ -10,12 +10,26 @@
|
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* volatile
|
|
13
|
+
* The seven form fields (`enabled`, `gear`, `parallel`, `sessions`,
|
|
14
|
+
* `outputDiscipline`, `stepBudget`, `workspaceMap`) are `.volatile()`: that is
|
|
15
|
+
* what makes them appear in the generated form *and* what lets an edit reach a
|
|
16
|
+
* running fiber instead of forcing a remount. A non-volatile declaration is
|
|
17
|
+
* invisible in the form — `describe()` projects through `volatileForm()`, which
|
|
18
|
+
* keeps only volatile nodes, and `update()` refuses the path with "is not
|
|
19
|
+
* volatile".
|
|
20
|
+
*
|
|
21
|
+
* The panel's three rows are volatile for exactly that reason: the client half
|
|
22
|
+
* has one channel to read state from, `describe()`, so a non-volatile field is a
|
|
23
|
+
* row it can write into `sessions` but never paint — it would keep showing the
|
|
24
|
+
* built-in default (`late` / 0 / off) while a hand-written `cordis.patch.yml`
|
|
25
|
+
* said something else. Reading the effective config from the plugin half instead
|
|
26
|
+
* was rejected: it needs a new remote method and a new config field to carry
|
|
27
|
+
* values `describe()` already projects, and the client still could not read them.
|
|
28
|
+
*
|
|
29
|
+
* Volatile does not mean "not saved": the host strips volatile keys from the raw
|
|
30
|
+
* layer and merges the projected form back on top, so an explicit value in the
|
|
31
|
+
* patch layer survives a write. It only means an edit needs no remount. The knobs
|
|
32
|
+
* below stay non-volatile: they are read once at load, like `debugFile`.
|
|
19
33
|
*
|
|
20
34
|
* Only the fields that belong on a settings page are declared. Every other
|
|
21
35
|
* knob (`metrics`, `metricsFile`, `benchmarkFile`, `debug`, `debugFile`,
|
|
@@ -80,6 +94,7 @@ export const Config = z.object({
|
|
|
80
94
|
outputDiscipline: z
|
|
81
95
|
.union([z.boolean(), z.const('late')])
|
|
82
96
|
.default('late')
|
|
97
|
+
.volatile()
|
|
83
98
|
.description(
|
|
84
99
|
'输出纪律 / Output discipline(面板「少写过程」,也是没有自己条目的会话的默认值): 让模型少写过程、只在结论处落笔。`late`(默认)不往系统提示里放文风规则,第 4 步之后由调度器提醒一次——短任务零成本,长任务实测 6 对:墙钟中位 2.68×、步数 10.5→7.5、输出 token −52%、正确性 12/12 不变。`true` 全程生效;`false` 关闭。',
|
|
85
100
|
),
|
|
@@ -97,6 +112,7 @@ export const Config = z.object({
|
|
|
97
112
|
.min(0)
|
|
98
113
|
.max(80)
|
|
99
114
|
.default(0)
|
|
115
|
+
.volatile()
|
|
100
116
|
.description(
|
|
101
117
|
'单轮步数预算 / Steps per turn(面板「最多几步」,0 显示为「不限」): 用户自设的硬上限,到达后调度器提醒一次按现状汇报;0 = 不限(默认)。步数只说明成本,不说明任务是否完成——默认不再按步数催停,避免把仍在推进的长任务截断。需要成本上限时自己设。',
|
|
102
118
|
),
|
|
@@ -181,9 +197,17 @@ export const Config = z.object({
|
|
|
181
197
|
'压缩指令的重复步 / Compression repeat step: `outputDiscipline: late` 的提醒每回合只发一次,第 4 步之后的长回合(10 步以上)输出会重新变长。给一个正数就按那一步再提醒一次「仍然只写下一步要用的」,仍然每回合最多一次。0 = 不发(默认,行为不变)。',
|
|
182
198
|
),
|
|
183
199
|
|
|
200
|
+
compactRereadHint: z
|
|
201
|
+
.boolean()
|
|
202
|
+
.default(false)
|
|
203
|
+
.description(
|
|
204
|
+
'别重读刚写过的文件 / No re-read after edit: `outputDiscipline: late` 的第 4 步提醒里追加一句「本回合已经写过的文件,按最后一次编辑和检查输出来判断,不要为了确认整份重读」。依据是迁移类任务的长尾:一个回合 12–20 次读文件调用里有一部分是刚写完又整份读回来,省下的是工具参数与回显的输出 token。只在压缩提醒里多一句话,不新增消息、不改调度;默认关闭以保持 1.5.17 行为。',
|
|
205
|
+
),
|
|
206
|
+
|
|
184
207
|
workspaceMap: z
|
|
185
208
|
.boolean()
|
|
186
209
|
.default(false)
|
|
210
|
+
.volatile()
|
|
187
211
|
.description(
|
|
188
212
|
'工作区清单 / Workspace inventory(面板「先列文件清单」): 会话开始时不调用任何工具,harness 自己走一遍 cwd(深度 2、最多 60 条、跳过 node_modules 等)把路径、大小和现有测试入口作为一条提示交给模型。实测 `def15` 会话里 35.7% 的工具调用是 ls/find/cat 这类探路,其中 40% 出现在前两次调用里——这一步就是把那次往返拿掉。只发一次,不隐藏任何文件,读文件仍由模型自己决定。',
|
|
189
213
|
),
|
package/src/effort.js
CHANGED
|
@@ -266,6 +266,8 @@ export function resolveConfig(raw, overrides) {
|
|
|
266
266
|
disciplineRepeatAtSteps: gear.directive
|
|
267
267
|
? Math.max(0, Math.floor(number(config.disciplineRepeatAtSteps, 0)))
|
|
268
268
|
: 0,
|
|
269
|
+
/** Extra sentence in the compression reminder: do not re-read a file this turn wrote. */
|
|
270
|
+
rereadHint: gear.directive && config.compactRereadHint === true,
|
|
269
271
|
/** One "batch the opening step" reminder per turn, for short tasks. */
|
|
270
272
|
openBatch: gear.directive && config.openBatch === true,
|
|
271
273
|
/** One "send hunks, not whole files" reminder after a large whole-file write. */
|